Č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.