Testování na základě vlastností
Často se setkávám s názorem, že psaní jednotkových testů je nuda neboť manuální vymýšlení a psaní fixtur je zdlouhavé a repetitivní. Dnes se pokusím ukázat a na pár příkladech vysvětlit, jak testuju svůj kód já. Technika se nazývá property based testing a psaní takových testů rozhodně nenudí.
Klasický test porovnává aktuální výsledek s nějakou předpočítanou hodnotou. Když začínám s implementací, začnu přesně tímto testem. Přesně si tak definuji zadání a poté programuju tak dlouho, dokud test nezačne procházet.
Ne vždy ale pokryju všechno. Vlastně tím jedním testem pokryju dost málo. Řekněme, že jsem chtěl implementovat funkci sum
, která sečte dvě čísla dohromady. Test může být třeba (is (= 3 (sum 1 2)))
. Bude ale moje funkce fungovat pro vstup 5
a 6
? A co pro 3
a 1
? Nebo prostě pro jakákoliv čísla? V tuhle chvíli můžu otrocky začít vypisovat všemožné kombinace a zastavit až podle toho, jak moc si chci být jistý. Čím více vstupů vyzkouším, tím větší mám jistotu. Nicméně to je přesně to neoblíbené a nudné opakování se. Opakování, u kterého navíc nepotřebuju nijak přemýšlet.
Testování na základě vlastností
Programování je matematika a testování na základě vlastností je pro mě samotná esence programování. Testování na základě vlastností je hledání matematických důkazů. Ale nebojte se. Přestože občas skončíte u důkazu kruhem, není to při troše kreativity a znalosti následujícíh postupů něco nepřekonatelného. Pro mě osobně je psaní takových testů velkou zábavou.
Na každé věci, kterou programujete můžete vypozorovat určitou vlastnost a tuto vlastnost použít pro testování dané věci. Abych to vysvětlil na příkladu, tak si vezměme funkci sum
. Jednou z jejích vlastností je například to, že sčítání je asociativní:
(is (= (sum 1 2)
(sum 2 1)))
Všimněte si toho zásadního rozdílu mezi původním a aktuálním testem. V aktuálním testu neporovnáme s žádnou předpočítanou konstantou. Vstup můžeme libovolně měnit, přičemž rovnost by měla stále platit. Díky tomu můžeme pokročit o krok dále a vstupy si nechat generovat náhodně!
Generativní testování
Pro ukázky kódu jsem si vybral clojure, protože to je jazyk, ve kterém v poslední době trávím nejvíce času, a knihovnu, kterou budu používat je https://github.com/clojure/test.check. Nicméně v každém rozumném jazyce najdete podobnou knihovnu se stejnou funkcionalitou.
Základem každé takové knihovny jsou generátory (čísel, keywordů, vektorů, listů, map, setů, ...) Navíc generátory můžete kombinovat a/nebo transformovat (vektor přirozených čísel od 1 do 1000 apod).
(gen/sample gen/nat 15)
; -> (0 1 2 3 2 4 0 3 5 9 6 1 10 5 2)
Důležitou součástí generátorů je, jak generují hodnoty. Všimněte si na ukázce výše, že "složitost" generovaných hodnot se postupně lineárně zvyšuje. Zároveň se ale občas "vrací" k těm méně složitým. To je dobré třeba pro vektory, kdy se pomalu zvětšuje délka vektoru a složitost hodnot, ale zároveň je potřeba otestovat i delší vektory s jednoduchými hodnotami a naopak.
Definice vlastnosti a její použití v testu vypadá následovně:
(def sum-assoc-prop
(prop/for-all
[a gen/nat
b gen/nat]
(= (sum a b)
(sum b a))))
(tc/quick-check 10 sum-assoc-prop)
; -> {:result true, :num-tests 10, :seed 1466676721427}
Právě jsem spustil test, který vygeneruje 10 náhodných vstupů a otestuje, zda pro každý vstup platí definovaný předpoklad. Pokud bych si chtěl být jistější, nechal bych vygenerovat mnohonásobně více vstupů.
V případě, že bych ve svojí implementaci něco opomněl, pak dostanu nejmenší možný neprocházející vstup. Toho je dosaženo takzvaným shrinkingem, kdy funkce quick-check
jde ve složitosti zpět:
(defn sum [a b]]
(+ (if (= a 5) 0 a) b))
(tc/quick-check 10 sum-assoc-prop)
; -> {:result false,
; :seed 1466678535511,
; :failing-size 6,
; :num-tests 7,
; :fail [5 3],
; :shrunk {:total-nodes-visited 7, :depth 1, :result false, :smallest [5 0]}}
Nejčastější postupy
1. Alternativní algoritmus
Pracujete-li na super-optimalizovaném algoritmu se spoustou vychytávek, pak je často nejjednodušší a nejefektivnější otestovat ho oproti přímočarému a naivnímu řešení téhož problému:
(is (= (+ a b)
(sum a b)))
2. Asociativita
Mnoha algoritmům nezáleží na pořadí vstupů a přesto by měly vracet stejný výsledek:
(is (= (+ a b)
(+ b a)))
3. Idempotence
Je úplně jedno kolikrát něco zavolám, pokaždé bych měl dostal stejný výsledek:
(is (= (sort nums)
(sort (sort nums))))
4. Roundtrip
Obzvláště dobré když píšete nějakou serializační/deserializační funkcionalitu:
(is (= str
(js/atob (js/btoa str))))
5. Kreativita
Občas nezbyde nic jiného, než zapojit vlastní rozum a vymyslet elegantní důkaz:
(is (true? (apply <= (sort nums))))
Samé výhody
Psaní testů tímto způsobem je nejen zábavné, ale také dost úsporné, přičemž v konečném důsledku váš kód prověří mnohem důkladněji a pomůže vám tak odhalit případy, na které jste nemysleli. A to je podle mě hlavní důvod, proč bychom měli do testů investovat.