Den här artikeln tar upp några punkter att tänka på i enhetstestning, i synnerhet underhåll av tester. Fokus är på stora legacysystem där andra förutsättningar gäller jämfört med tex testdriven nyutveckling.
Enhetstestning
Tänkte börja med att börja definiera vad ett enhetstest är.
Ett test av en enhet vilken typiskt utgörs av en klass. Vid enhetstestning är tanken att ett eventuellt fel i testet med stor sannolikhet ska bero på fel i enheten utan test. Enhetens beroenden abstraheras ofta bort med mock/stubb objekt.
Grunden
Enhetstestning tillför otroligt mycket till ett projekt, de dokumenterar klasser, eliminerar buggar/buggletning, skapar en säkerhet som möjliggör ändringar/refaktorisering. Jag skulle vilja sammanfatta dem alla till att de sparar tid åt projektet, i synnerhet i det långa loppet.
Något som ofta glöms bort är att det även finns negativa sidor med enhetstestning. Den tunga posten i större och långlivade system är underhållet av testerna och det kommer denna artikel att fokusera på.
För att ett enhetstest ska vara så bra som möjligt ska det under sin livslängd maximera den tidssparande effekten av testet och minimera underhållet. Om ett test kostar mer än det smakar finns det mycket goda chanser att det går att justera genom att ändra testets utformning, mer om det snart.
En central fråga är hur mycket man ska testa, vid en viss punkt tillför testet mindre än underhållskostnaden, en tumregel är att låta testerna exekvera mellan 70-95% av antalet kodrader.
Allmänna testtips
När det är dags att skriva ett test, definiera testet tydligt. Gärna i JavaDoc innan du börjar skriva testet. Det hjälper dig att hålla testets fokus. Ett test bör designas som en metod, det bör testa en sak och testa den bra. En tydlig definition som dessutom återfinns i javadoc gör omskrivning och korrigering av testet betydligt enklare. Ett spretigt odefinierat test är betydligt jobbigare att underhålla.
Testets namn bör återspegla definitionen och vara så talande som möjligt. Att inkludera ord som enhetens namn, test,verify eller liknande i namn/doc är onödigt och leder bort från relevanta namn.
Vad föredrar du i testrapporten när något har smällt?
TestClass: TestOfUnit
failing testWorkingUnit
Eller flera mindre väldefinierade tester.
TestClass: TestOfUnit
failing: errorOnInvalidLocalizedInput
failing: constructorAllowsValidInput
Även verifieringarna man gör i testet bör återspegla definitionen. Jag kan frestas av att lägga till orelaterade verifieringar om det inte kostar mig mer än en rad. Den impulsen bör inte bejakas! Gör istället ett eget test för den verifieringen. Att hålla testerna små, väldefinierade och väl namngivna kan drastiskt minska underhåll och ger en bättre dokumentation av testet såväl som enheten under test.
Mockning/Stubbning
I underhåll av tester kan valet av en stubb eller mock kraftigt påverka, jag börjar därför med att titta lite på skillnaderna mellan de två.
Det finns två huvudsakliga skolor i enhetstestning, att mocka eller stubba klasser vi inte vill testa. Detta motsvarar två olika sätt att se på testning, tillstånds eller interaktionsbaserad testning.
Ett litet exempel, vi har en metod vi vill testa som tar en dao som argument.
ClassUnderTest
methodUnderTest(MyDao myDao)
I vårat mocktest låter vi ett ramverk skapa en mock med vissa förväntade anrop.
myMockDao = createMock(MyDao);
expect(myMockDao.insert(1)).once()
methodUnderTest(myMockDao);
Vi har nu kontrollerat att i interaktionen mellan ClassUnderTest och MyDao så körs metoden select med vissa argument.
Motsvarande test i stubbning. Vi skapar en stubb implementation av MyDao fast lägger till en testmetod för att hämta stubbens tillstånd, getInsertedNumbers.
myMockDao = new MyDaoStub()
methodUnderTest(myMockDao)
assert myMockDao.getInsertedNumbers().get(0) ==1
I detta fall bryr vi oss inte om vilken metod som användes för att komma till läget där siffran 1 är inlagt utan bara om det faktum att det på något sätt gjordes. En viktig effekt av detta blir att vi inte beror på implementationen av ClassUnderTest utan konstaterar endast att den levererar.
Vad som är rätt eller bäst är en filosofisk fråga men det är viktigt att notera att det är en grundläggande skillnad. Mockramverken blir bättre och skillnaderna blir mindre i och med ”wildcarding” av inparametrar och returvärden men i grunden är metoderna fortfarande olika. Det finns mycket mer att säga om skillnaderna mellan mockning/stubbning, Martin Fowler har gjort en utmärkt artikel Mocks Aren’t Stubs.
Överlag medför mockning ett mer fullfjädrat test men kan vara vanskligt ur underhållssynpunkt då man knyter sig till implementationen av klassen under test. Mockning kommer ut XP och TDD rörelsen och det märks. I stora äldre system med mycket hårda beroenden lämpar sig mockning sämre, mer om det snart.
Personligen gillar jag att blanda de två sättet beroende på vad jag vill testa. Om vi tar exemplet med en klass vi vill testa som interagerar med ett dao-objekt. När jag vill verifiera att klassen returnerar korrekta resultat använder jag tillståndsbaserad testning och stubbar. Vill jag exempelvis säkerställa min klass prestanda bör dao:n används på ett smart sätt och interaktionstestning är att föredra.
Design/Testbarhet
Hårda beroenden i sin kod genom statiska anrop eller new kan verkligen ställa till det i testningen. Ett exempel på hårda beroenden på klasserna HelperClass samt HelperClass2.
testThisMethod(myDao){
HelperClass.doAll(myDao);
new HelperClass2().doAll(myDao);
}
En möjlighet är att följa det statiska anropet i alla nivåer för att hitta anropen till myDao och hantera anropen i sin mock-dao. Gör aldrig det! Testet har tappat fokus och med all sannolikhet har vi höjt underhållskostnaden många gånger. Har vi testat interaktionsbaserat har vi nu ett testfall som beror på implementationen av HelperClass och alla de anrop i anropskedjan efter doAll metoden som använder dao:n. En effekt blir att vid ändringar av implementationer kan helt orelaterade enhetstester fallera. Om man vill testa denna kod interaktionsbaserat måste man mocka HelperClass för att inte bli underhållsmässigt olycklig.
Att designa om klassen med dependency injection kan kosta mer tid momentant, vi sparar tid i att skriva testfallet men omdesignen av koden tar tid. Fundera på om inte den tiden sparas in i underhållet av både kod och testfall. Om omdesign är farligt då det saknas enhetstester (vi skapar ju det nu), hitta lämpligt funktionstest/systemtest eller göra ett. Ofta kan man även tillfälligt fuska för att få till ett underhållbart test för att skjuta på omdesign. Ett av många sådana tricks är att bryta ut det hårda beroendet till en egen metod, i testfallet ärver man sedan sin klass under test och överlagrar metoden med det hårda beroendet.
När ett test fallerar
När ett test inte längre går igenom har man ett par alternativ, reparera, ta bort eller invertera testet.
Vid invertering av ett test bör man alltid skapa ett ärende eller dylikt för att säkerställa att det inte glöms bort.
Att invertera ett test kan typiskt vara att ändra en assert ok
till assert !ok.
Andra inverteringar är att förvänta sig någon typ av throwable vid ett visst anrop, här bör man vara så specifik som möjligt.
Dåligt exempel på inverterat test
testMethod() {
try {
doSomething();
doSomething2();
// ignore! test inverted
} catch(Throwable e){ }
}
Bättre exempel på inverterat test
testMethod() {
doSomething(); // not in try, still testing
try {
doSomething2();
assert false; // test inverted to expect exception see issue:17
} catch(FileNotFoundException e){
assert e.getMessage().contains("projectFile");
}
}
Huvudidén med att invertera ett test istället för att stänga av det är att när någon ändrar koden så att testet fungerar så kommer den utvecklaren att märka det och kan stänga ärendet. Notera assert false på rad 5, när någon medvetet eller omedvetet lagat felet kommer han att märka det när han kör testerna.
Andra fördelar med att invertera ett test är att testet fortfarande kan testa delar av logiken. Att förvänta sig ett visst undantag på rad 6 meddelar en utvecklare som introducerar ett nytt fel om vad som händer.
Notera att stänga av testet inte är listat som ett alternativ. Att stänga av ett test kan vara att ta bort testannotationen, använda en avstängningsflagga eller rent av kommentera ut testet, allt detta bör alltså undvikas! Det kräver ofta lite mer ansträngning att invertera ett test men det är väl värt det.