Enhetstester i Clojure


torsdag 1. juli 2010 Clojure Testing

Da jeg presenterte kassaapparat-kataen i clojure sa jeg at jeg ikke la vekt på enhetstester. Det betyr derimot ikke at jeg ikke skrev tester i det hele tatt.

Clojure kommer med et eget namespace for å skrive enhetstester, og fornuftig nok heter det clojure.test. Tidligere var dette kun en del av clojure-contrib, men det har nå blitt tatt opp i selve Clojure. For å ta det i bruk benytter jeg først ns-funksjonen til å definere et namespace i filen min, og kan der inkludere avhengigheter jeg ønsker å benytte, slik som dette:

  1 (ns marosoft.kata.cash-register
  2     (:use clojure.test))

For å skrive enhetstestene er det så kun to ting du trenger å vite om: Du definerer en test ved hjelp av deftest. En test ser ut som en vanlig funksjon, men uten parametre. Når du skal gjøre en "assert" i testen bruker du makroen is. Hvis argumentet til is ikke evaluerer til true vil testen feile.

Her er testene jeg skrev for basisfunkjonaliteten i kassaapparat-kataen:

106 ; --- TESTS ---
107
108 (deftest test-that-price-returns-correct-values
109          (is (= 3.99 (price "bread")))
110          (is (= 2.50 (price "milk")))
111          (is (= 4.00 (price "butter"))))
112
113 (deftest price-for-2-milk
114          (is (= (* 2.50 2)
115                 (price-for-selection "milk" 2))))
116
117 (deftest price-for-3-bread-using-overload
118          (is (= (* 3.99 3)
119                 (price-for-selection ["bread" 3]))))
120
121 (deftest total-of-a-bread-2-milk-3-butter
122          (is (= (+ 3.99 (* 2 2.50) (* 3 4.00))
123                 (total [["bread" 1]["milk" 2]["butter" 3]]))))
124
125 (deftest buy-will-update-cart
126          (let [original-cart
127                 [["bread" 2]]
128                 updated-cart
129                 (buy "butter" 1 original-cart)]
130            (is (= 1 (count original-cart)))
131            (is (= 2 (count updated-cart)))
132            (is (= 1 (count (filter
133                              #(= (first %) "butter") 
134                              updated-cart))))))
135
136 (deftest cart-will-not-be-cleared-if-not-enough-money
137          (let [updated-cart (checkout 10.0 [["milk" 10]])]
138            (is (= 1 (count updated-cart)))))
139
140 (deftest cart-will-be-cleared-if-enough-money
141          (let [updated-cart (checkout 100.0 [["milk" 10]])]
142            (is (= 0 (count updated-cart)))))
143

For å kjøre testene kaller du den innebygde funkjonen run-tests ved for eksempel å skrive (run-tests) i bunnen av fila. Da jeg implementerte kataen min gjorde jeg det derimot litt anderledes…

Integrere testene som en del av programmet

Da jeg implementerte kassaapparatet skrev jeg testene i samme fil som selve programmet. Jeg utvidet så brukerens meny til å inkludere en opsjon for å kjøre alle testene. Dette gjorde jeg ved å legge linjen nedenfor til i dispatch-command listen (se forrige blogpost).

72      "test" (fn [state] (do (run-tests) (System/exit 0)))

Jeg kunne dermed starte programmet og skrive ordet "test" for å kjøre testene. Jeg valgte også å la programmet avslutte etter at testene var kjørt ved å eksekvere (System/exit 0), som er Java interop og tilsvarer å kalle System.exit(0).

Kult?

Flere muligheter

is-makroen kan ta en ekstra streng-parameter som beskriver hva som testes, om du liker å gjøre det. I tillegg finnes det en testing-makro som lar deg definere mere beskrivende tester, ala BDD / RSpec.

Man kan også definere test fixtures, som i praksis gir deg setup (before) og teardown (after) logikk. Og man kan komponere ulike sett av tester, og spesifisere hvilke namespace man ønsker å kjøre tester for når man kaller run-tests.

Og man kan definere en funksjon og testene for funksjonen i par, slik at det ikke er noen avstand mellom testen og koden. Bruker man dette vil man nok tvinge seg selv til å gjennomføre ganske god TDD. Man kan også sette et flagg som dropper testene under kompilering, slik at det å ha testene sammen med produksjonkoden ikke er noen issue.

Her har jeg laget en funksjon som er definert sammen med testene sine, og hvor jeg også bruker testing-makroen for å gruppere (det gir også bedre rapportering ved feil):

146 (with-test
147   (defn can-watch-movie?
148         "Can person watch movie based on MPAA rating?"
149         ([rating age] ; overload 1 (defaults to no adult supervision)
150          (can-watch-movie? rating age false))
151         ([rating age with-adult?] ; overload 2..
152          (condp = rating
153                 "R" (or (>= age 17) with-adult?)
154                 "NC-17" (> age 17)
155                 true))) ; defaults to true for all other ratings
156   (testing "Non-restrictive ratings"
157            (is (true? (can-watch-movie? "G" 1 false)))
158            (is (true? (can-watch-movie? "PG" 2 false)))
159            (is (true? (can-watch-movie? "PG-13" 12 false))))
160   (testing "Restricted rating (adult guardian required if under 17)"
161            (is (false? (can-watch-movie? "R" 16 false)))
162            (is (true? (can-watch-movie? "R" 16 true)))         
163            (is (true? (can-watch-movie? "R" 17 false))))
164   (testing "NC-17 - No One 17 and Under Admitted"
165            (is (false? (can-watch-movie? "NC-17" 17 false)))
166            (is (false? (can-watch-movie? "NC-17" 17 true)))
167            (is (true? (can-watch-movie? "NC-17" 18 false))))
168   (testing "Using overload without specifying supervision"
169            (is (false? (can-watch-movie? "R" 16)))))

For en fullstendig oversikt over hva du har tilgjengelig kan du ta en titt på clojure.test API-referansen.


comments powered by Disqus