This is an archived page. Back to the blog.
C++-arkivet
 

Praktisk enhetstestning: att handskas med problematiska typer

(30 juni 2009) Vi börjar med tipset:

Om du har en problematisk datatyp P som hindrar enhetstestningen av din klass U (t.ex. för att P har sidoeffekter):

  1. Skapa en interfaceklass.
  2. Skapa en hjälpklass som ärver från interfaceklassen.
  3. Låt hjälpklassen använda P.
  4. Låt U prata med interfaceklassen istället för P.

Du har nu samma beteende som förut men kan lätt lägga till en testklass som ärver av interfaceklassen.

Tips: Alla klasser kan testas genom att införa en extra interfaceklass.


 

Tappa kontrollen

Testdriven utveckling (eng: test driven development) och agila utvecklingsmetoder (eng: agile) förutsätter bra enhetstesning (eng: unit testing). Enhetstestning handlar om att skriva kod som testar kod. Man skriver sina tester för att snabbt få reda på om ens programkod fungerar efter en ändring. Enhetstestning är det billigaste sättet att få bra kvalitet på din applikation. Dels för att du kan undvika den kombinatorik man får om man testar hela applikationen samtidigt, men även för att du då hittar buggar så tidigt som möjligt efter att koden skrevs.

En kommentar om ovanstående kan vara på sin plats: Att du kan koden bäst kan även anses vara en nackdel i sammanhanget. Du har redan en åsikt om vad din kod borde göra, vilket kan begränsa din fantasi. För att få riktigt bra kvalitet måste någon annan testa hela applikationen på det sätt den är tänkt att användas. Förhoppningsvis är detta en QA-avdelning (eng: quality assurance), i värsta fall kunden ;).

problem

För att maximera fördelarna med enhetstestning måste dina tester vara lätta att köra, de måste köra snabbt och alltid ge samma resultat. Det är tyvärr inte alltid så lätt att åstadkomma... Vad händer t.ex. om din kod använder klasser ur ett bibliotek för nätverkskommunikation (NetworkComm)? Kommunikationslogiken (CommLogic) i din applikation har en funktion sendRequest som använder NetworkComm för att skapa en ändpunkt, sätta upp en förbindelse och skicka meddelandet (se Figur 1).

problematisk typ
Figur 1: Din klass använder nätverkskommunikation.

Du vill skriva tester för kommunikationslogiken i CommLogic. Då måste någon ge dig svar på de förfrågningar du skickar över nätverket. Du måste alltså starta ett externt program för att få svar. Frågan är om det går snabbt nog och alltid ger samma svar (tänk tusentals testfall)? Och att starta externa program är krångligt. Det bästa vore om dina tester var helt fristående.

Kommunikation med världen utanför ditt program är bara ett exempel på svårigheter vid testning. De flesta problem man stöter på faller inom någon av följande kategorier:

  • Beteende man har svårt att återskapa. Exempel: testning av timeout, nätverksfel, bitfel.
  • Resurskrävande datatyper. Exempel: användning av en databas eller nätverk gör att testfallen tar lång tid, stora datatyper äter minne.
  • Datatyper med sidoeffekter. Exempel: databasskrivningar ger beroenden mellan testfall.
  • Icke-deterministisk funktionalitet. Exempel: testning av kod som använder slump eller nuvarande tid.
  • Kombinatorik. Exempel: din klass har ett ansvar, men anropar en annan klass med ett annat ansvar. Dina tester måste ta hänsyn till att de två klasserna påverkar varandras beteende.

Stöter du på ovanstående problem har du helt enkelt inte full kontroll över alla delar av testfallet. Det blir krångligt att testa. Det måste finnas ett bättre sätt.

Arvlös

Som vi såg i interfaceskolan del 3: testbarhet så kan man bli av med en problematisk typ (nätverkskommunikationen i exemplet ovan) genom att skapa ett interface. Man låter den problematiska typen ärva från interfacet. Vi testning kan man sen skapa en testklass som ärver från interfacet. Testklassen gör precis det du vill, och du har återfått kontrollen! I Figur 1 visas åter kommunikationslogiken CommLogic som använder nätverkskommunikation genom klassen NetworkComm. Med ett interface bryter man detta direktberoende och kan även skapa en testklass (NetworkCommMock), se Figur 2:

vi ärver från en interfaceklass
Figur 2: Vi återfår kontrollen genom att införa en interfaceklass.

Men, vad händer om du inte kan ärva? Det kan t.ex. vara så att du använder klasser ur Boosts förträffliga asio-bibliotek (asynchronous I/O). Du vill inte gärna modifiera bibliotekskoden. Så vad göra?

Typer man saknar kontroll över

Det är tyvärr ganska vanligt att man använder kod man inte har kontroll över. Här är några exempel på orsaker till att man inte kan använda en interfaceklass på det sätt vi pratade om ovan:

  • Man äger inte koden. Exempel: Legacykod är helt enkelt gammal kod som man inte vågar ändra. Tredjepartskod (kod som man t.ex. köpt in) och bibliotekskod (t.ex. standardbiblioteket) vill man inte ändra eftersom man då får problem med underhåll när nästa version kommer.
  • Man vill inte ärva. Exempel: Vissa klasser, såsom de med väldigt få medlemmar (flugviktsklasser), vill man inte ändra för att man då ändrar deras egenskaper (man inför t.ex. en pekare till en virtuell tabell som äter minne).
  • Man kan inte ärva. Exempel: Fria funktioner (t.ex. rand()) ligger inte i en klass och kan då inte ärva. Templatemedlemsfunktioner kan inte vara virtual. Medlemsfunktioner i en templateklass beror ofta på templatetypen och då kan man inte skapa ett interface (om interfacet inte är en templateklass även den).

Stöter vi på någon av de ovanstående exemplen så har vi alltså ett problem vi inte kan lösa. Så vad göra?

Omdirigerad

David Wheeler (1927-2004) var den datalog som myntade uttrycket "Any problem in computer science can be solved with another layer of indirection" (på svengelska ungefär: en extra omdirigering). Och ojojoj, om han visste vad han pratade om. Denna observation går att tillämpa på allt, från lägsta nivå (t.ex. pekare) till mest abstrakta nivå (såsom design patterns).

Ett bra exempel på extra omdirigering såg vi i Figur 2. Istället för att CommLogic använder NetworkComm direkt så pratar CommLogic via interfacet till NetworkComm. Jämför detta med vårt dilemma: vi får problem med testning för att vi använder en problematisk typ (t.ex. en biblioteksklass). Vi behöver bryta beroendet till den problematiska typen. Vi lägger till ett interface!

Men vänta! Har vi inte redan diskuterat om hur tråkigt och jobbigt det var att vi inte kunde ärva ett interface. Nyckelordet här är "ärva". Våra problematiska datatyper ska inte ärva vårt nya interface.

Hjälp

Åter till exemplet ovan. Låt säga att klassen NetworkComm är del av ett bibliotek. Vi har alltså ingen kontroll över koden och kan inte ärva som i Figur 2. Vi inför istället en hjälptyp NetworkCommAccess (se Figur 3). Behöver vi något från NetworkComm så ber vi accessobjektet om detta. NetworkCommAccess skickar sedan förfrågan vidare till NetworkComm.

accessklass
Figur 3: Vi inför en accessklass.

Så vad har vi åstadkommit? Nästan ingenting! CommLogic beror fortfarande på NetworkComm, fast nu via NetworkCommAccess. Och vår testning har inte blivit något lättare alls. Men vi har kommit en bit på väg. Nu har vi nämligen en klass som vi har kontroll över!

Inget gott som inte har något ont med sig

David Wheelers citat löd egentligen "Any problem in computer science can be solved with another layer of indirection. But that usually will create another problem." Och det är detta som är kruxet. Allt som är bra har alltid någonting dåligt med sig. (Jag måste bara få citera en helgalen lunchkonversation angående bra och dåligt: "Men lax, det är ju både gott och nyttigt, så vad skulle vara dåligt med lax?" -"Men varför äter du inte lax hela dagarna istället för att jobba? Jo, för att det är dyrt och bara är nyttigt i rimliga mängder!" Slutsats: om det fanns något som bara var bra så skulle man göra det och inget annat! :)

I vårt fall är nackdelen ökad komplexitet. Som vi sa förut, för att få bra testbarhet måste vi införa ett interface (se Figur 4):

interface
Figur 4: Vi får full kontroll genom att införa ett interface.

Vårt nya interface NetworkCommAccessInterface har funktioner för att lätt komma åt nätverket (setupAndSend). Vi har även lagt till en testklass NetworkCommAccessMock. Denna klass kommer att avlyssna alla anrop som CommLogic gör till NetworkCommAccessInterface. På så sätt kan den kontrollera att kommunikationslogiken fungerar som den ska.

CommLogic behöver få tillgång till rätt klass att prata med: antingen produktionsklass eller testklass. Interfaceskolan del 3: testbarhet beskrev en teknik som heter beroendeinjektion (dependency injection): Vi låter CommLogics konstruktor ta en referens till en NetworkCommAccessInterface. Produktionskoden skickar en NetworkCommAccess till konstruktorn. Testkoden skickar en NetworkCommAccessMock till konstruktorn.

Fördelar och Facade

Lägg märke till att det bara är CommLogic och NetworkComm som är nödvändiga för vår produktionskod! Det verkar inte bättre än att vi lagt till tre klasser bara för att få testbarhet. Det är dock inte riktigt sant. Vår kod har samtidigt blivit bättre.

För det första har interfacet minskat beroendet mellan våra klasser. Vi får färre omkompileringar och lättare underhåll. Att införa testbarhet (som är bra) fick alltså bieffekten minskade beroenden (som också är bra), vilket är märkligt. Men som sagt, vi har den ökade komplexiteten (som är dåligt) att väga upp med.

ringar på vattnet

För det andra så har NetworkCommAccess minskat kopplingen (eng: coupling, alltså hur mycket den ena beror på den andres implementation) mellan våra klasser CommLogic och NetworkComm. Men vadå?, säger du. Man har ju bara flyttat anropen så att de går via hjälpklassen NetworkCommAccess. Jovisst, men skillnaden är att vi har infört en abstraktion i NetworkCommAccess. Vi använder inte längre createEndpoint, setupConnection och send. Istället använder vi en funktion setupAndSend som jobbar på en högre nivå. Vill vi ändra hur vi skickar nätverksmeddelanden är det bara att ändra i setupAndSend. CommLogic påverkas inte. Nyckeln till låg koppling är alltså ett stabilt gränssnitt mellan klasserna.

Samma idé ligger bakom designmönstret Facade (eng: the Facade design pattern, från boken Gamma et. al. "Design patterns - Elements of reusable object-oriented software"). Facade tar ett komplext delsystem (t.ex. nätverkskommunikation) och döljer det bakom ett enkelt gränssnitt. Gränssnittet mellan subsystemen blir mer stabilt och kopplingen därmed lägre. Nackdelen med Facade är att man döljer en del funktionalitet som då inte går att komma åt.

Sammanfattning

Det vi diskuterat är ett mönster som du kan använda då du har en datatyp User som du vill testa men som beror på en datatyp Problematic som gör testning svårt.

koncept: problem
Figur 5: Problem: vår klass User använder en klass Problematic som vi
inte har kontroll över. Vi får därför svårt att testa.

Du skapar alltså en ny hjälpklass (Access) och låter den ärva från ett enkelt interface AccessInterface. User pratar nu bara med interfacet. Access tar förfrågan och skickar den vidare till Problematic.

koncept: lösning
Figur 6: Lösning på problemet i Figur 5: vi inför en accessklass och
ett interface. Då kan vi även införa en testklass utan problem.

User kan nu testas genom att skicka in AccessMock som även den ärver från AccessInteface.



Relaterade artiklar: