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

Interfaceskolan del 3: vägen mot oförstörbar kod, bygg in testbarhet från början

Vi börjar med tipset:

Riktlinjer som förenklar testningen av din kod:

  1. Låt inte dina klasser prata direkt med varandra!
    Låt istället dina klasser prata med ett interface.
  2. Se till att dina klasser har ett ansvar.
Tips: Se till att skriva dina klasser för testbarhet från början.


 

Enhetstestning

Enhetstestning (eng: unit testing) handlar om att skriva kod som testar att dina funktioner och klasser gör det du förväntar dig. Du skriver dina enhetstestfall i samma språk som dina klasser, dvs du skriver C++-kod som testar dina C++-klasser.

Enhetstestning underlättar ditt arbete bl.a. på följande sätt:

  • Du får snabb feedback på vad som fungerar och inte fungerar.
  • Du vågar göra ändringar i ditt program (eng: refactoring) eftersom du har testfall som ser till att beteendet inte ändrats.
  • Du kan lätt provköra stora delar av din kod.
  • Du kan lätt kan återskapa knepiga fall i din kod jämfört med om du kör hela ditt program (t.ex. race conditions).
testning

När du enhetstestar kommer du att ha minst två körbara program: ditt riktiga program och ett (eller flera) testprogram som motionerar din kod. Koden för ditt testprogram "återanvänder" delar av ditt riktiga program och provkör dem. För att sätta upp och styra provkörningen behövs testkod. Och faktum är att man ofta får mer testkod än produktionskod! Ingenting är gratis, tyvärr...

Gör det lätt för dig från början

Det krävs både eftertanke och arbete för att en klass ska gå att testa. Om du har testbarhet i åtanke redan från början kommer du spara mycket tid. Omvänt kan man säga att det kan vara väldigt svårt att testa en klass som inte är skriven för testbarhet. Det blir ofta till att skriva om stora delar.

Med några enkla riktlinjer kan du spara mycket tid:

Riktlinjer som förenklar testningen av din kod:

  1. Låt inte dina klasser prata direkt med varandra!
    Låt istället dina klasser prata med ett interface.
  2. Se till att dina klasser har ett ansvar.
Tabell 1: Se till att skriva dina klasser för testbarhet från början.

Riktlinje 1: Prata aldrig med någon annan än ett interface

Men vad han tjatar om interface tänker du! Och ja, jag erkänner. Men det är bara för att jag tycker att det är en av de absolut viktigaste bitarna av objektorienterad programmering. Det kan göra hela skillnaden mellan bra programkod och riktigt grisig sådan.

Tänk dig följande: du vill provköra klass A som innehåller en pekare till klass B. Klass B är deklarerad med class B; i headerfilen A.hpp. Inga beroenden där, skönt. Men vad sa egentligen intefaceskolan del 2: bli av med dina beroenden? Jo, att även om headerfilen för A inte beror på headerfilen för B så är A hårt knuten till B. Antag att du provkör klass A som i sin tur anropar B via pekaren. Du kommer då att få beteendet hos klass B på köpet. Det behöver inte vara något dåligt i sig själv (även om B kanske använder sig av någon klass C, osv). Problemet är att du inte kan få B att göra det du vill.

För att ge ett exempel: vi låter A vara en klass som hanterar bankkonton och B en databashanterare. Vi vill testa att det går att kolla saldot på ett konto. Kontohanteraren borde kolla med databasen att kontonumret finns och vad saldot är. Om A (bankkontohanteraren) har en pekare till B (databasen) så kommer databasen delta i testningen. Vi måste alltså mata databasen med data innan vi kör testfallet. Detta är dåligt på många sätt: vi måste starta och konfigurera en databas, många olika saker kan gå fel, det är svårt att simulera databasfel, m.m. Databasklassen ger oss en massa problem som vi inte behöver.

Låtsasklasser

Alltså, vi kan inte låta B delta i testningen. Vi ersätter istället pekaren till B med en pekare till en interfaceklass. Vår interfaceklass får sedan vara basklass till B. Vi skapar även en mockklass BMock som får ärva från interfaceklassen. En mockklass är en testklass som gör precis det du vill. I vår produktionskod ser vi till att interfacepekaren pekar ut ett B-objekt. I vår testkod däremot, ser vi till att interfacepekaren pekar ut ett BMock-objekt.

Med en mockklass DatabaseMock kan du låtsas att få vilket svar som helst från databasen. Du kan se till att kontot inte finns, att saldot är negativt (en skuld), att databasen inte svarar osv. Du kan också kontrollera att kontohanteraren ber om rätt saker från databasen. Du vill ju inte att kontohanteraren ska formattera hårddisken eller göra andra oönskade operationer i tid och otid.

Riktlinje 2: Gör ett jobb, och gör det bra

Tänk dig följande scenario: Du ska skriva enhetstester för instrumentbrädan på en bil. Instrumentbrädan innehåller bl.a. förarens gränssnitt för vindrutetorkare och tuta (ett vred och en stor knapp). Låt säga att du implementerar all logik för instrumentbrädan i en klass. Instrumentbrädan pratar i sin tur med två interface: ett för vindrutetorkarna och ett för tutan. Du skapar därför två mockklasser. Nu vill du testa att allt fungerar som det ska.

Du börjar med att göra ett testfall där du trycker på den stora knappen och förväntar dig att tutan ska ljuda. Och ett testfall där du drar vindrutetorkarvredet till första läget. Men hmm, vad händer om jag har vindrutetorkarna på max och försöker tuta? Tänk om det inte går. Tutan är ju den viktigaste delen av bilen. Kritisk funktionalitet. Bäst att du testar alla kombinationer av vredet och knappen. Men oj oj, nu säger de till dig att du måste lägga till funktionalitet för blinkers också! Få se här nu, blinka höger, blinka vänster, blinkers av. Då måste du ju tredubbla alla testfall!

instrumentbräda

Man kan ana vart detta är på väg. Problemet är att din klass har mer än ett ansvar. I klassen finns kod både för kopplingen mellan vred och vindrutetorkare och kopplingen från den stora knappen till tutan. I och med att det finns kod för båda finns risken att de påverkar varandra. Och då måste de testas tillsammans. Man får en kombinatorisk effekt. Två lägen på den stora knappen gånger fyra lägen på vredet ger minst åtta olika fall att testa. Och så tre lägen på blinkersspaken ger 24 potentiella testfall.

Lösningen på dilemmat är att skapa klasser med ett ansvar. Samtidigt bör man sträva efter ortogonalitet (eng: orthogonality), dvs att varje klass har ett ansvar som inte överlappar någon annans. Du skapar därför tre klasser: en för vredet (vindrutetorkaren), en för den stora knappen (tutan) och en för spaken (blinkersen). Istället för 4 * 2 * 3 = 24 testfall får du nu 4 + 2 + 3 = 9 testfall. En klar förbättring.

Klassen för instrumentbrädan får även den ett ansvar: att vara den plats där föraren hittar bilens alla reglage. Eller för att prata kod: i klassen för instrumentbrädan finns tre funktioner som ger dig tillgång till ett reglage vardera. Till exempel finns en funktion som ger dig en referens till vredet för vindrutetorkaren. Instumentbrädan blir nu väldigt lätt att testa.

Tekniker som förenklar

Vi pratade om att man skulle låta bankkontohanteraren använda en databas i produktionskoden och en mockklass vid testing. Samtidigt vill man undvika att mockklassen omnämns i produktionskoden eftersom det lätt blir fel, mockkoden tar plats m.m. Därmed kan kontohanteraren inte innehålla kod för att skapa en mockklass. På något sätt måste kontohanteraren kunna använda databas eller mockklass utan att känna till någon av dem.

Beroendeinjektion

Men hur går det till? Det finns en teknik som du säkert använt, men kanske aldrig haft ett namn för: beroendeinjektion (eng: dependency injection).

Vi vill undvika att kontohanteraren känner till mockklassen. Vi har skapat ett databasinterface som kontohanteraren får prata med. Men hur får kontohanteraren tag i mockobjektet? Jo, vi skickar in det i konstruktorn!

class BankAccountHandler
{
public:
    BankAccountHandler(DatabaseInterface &database)
      : database_(database)
    {}

    /* ... */

private:
    DatabaseInterface &database_;
};

Kodexempel 1: Beroendeinjektion, konstruktorn tar ett interface som argument.

Vi ser att kontohanteraren sparar undan referensen till databasen. När kontohanteraren sedan behöver komma åt databasen vet kontohanteraren inte vem han pratar med. Det kan vara ett mockobjekt eller en riktig databashanterare. Om man använder ett mockobjekt i ett testfall kan det se ut såhär:

void testCase()
{
    DatabaseMock databaseMock;
    BankAccountHandler accountHandler(databaseMock);
    /* test the account handler... */
};

Kodexempel 2: Vi skickar in ett mockobjekt till konstruktorn.

Att ta emot ett interface i konstruktorn kallas för konstruktorinjektion (eng: constructor injection). Din produktionskod kommer att se precis likadan ut, fast med mockobjektet ersatt av en riktig databasinstans som du skickar in i konstruktorn.

Beroendeinjektion är en tillämpning av något som kallas omvänd kontroll (eng: inversion of control). Wikipedia beskriver detta på ett bra sätt: "Normal control flow occurs when user procedures call library procedures. Library procedures may call other library procedures, but they never call back into the user code. Inversion of control occurs when a library procedure calls user procedure(s)."

Att automatisera mockskrivandet

Jag nämnde att det kan bli ganska mycket testkod. De interface som deltar i ett testfall behöver en mockklass. Det kan t.o.m. vara så att samma interface behöver massor med olika mockklasser för att testa olika fall. (Kom ihåg, man skulle kunna skriva en stor mockklass som kan allt, men då får den inte ett ansvar.) Man blir snabbt ganska trött på att skriva mockklasser. Det måste finnas ett bättre sätt.

Faktum är att det finns ett mycket bättre sätt. Man kan använda sig av programmerbara mockklasser! Vi tar ett exempel för att förklara vad jag menar:

void testCase()
{
    DatabaseMock databaseMock;
    BankAccountHandler accountHandler(databaseMock);

    // setup mock object
    databaseMock.
        expectCallToFunction(
            &DatabaseInterface::getBalance).
        withArguments(4711).
        andReturn(100.00);

    // execute and verify
    assert(accountHandler.getBalance(4711) == 100.00);
};

Kodexempel 3: Exempel på användning av ett bibliotek för programmerbara mockklasser.

Koden ovan testar en förenklad variant av att hämta saldot på kontot (vi kollar inte att kontot finns, att databasen svarar etc.) Med lite fantasi kan man nästan läsa koden som en mening på engelska: "The database mock object expects a call to function getBalance with arguments 4711 and returns 100.00." Alltså, mockklassen förväntar sig ett anrop till getBalance för kontonummer 4711 och ska då returnera 100 kr. Sen provkör vi kontohanteraren med kontonummer 4711. Då kommer mockobjektet att verifiera att rätt anrop gjordes (det till getBalance)! Mockobjektet returnerar att saldot är 100.00. Vi kollar till sist att kontohanteraren ger rätt värde tillbaka med en assert.

Så varför är det så bra med programmerbara mockklasser? Till och börja med slipper du skriva en massa mockklasser. Därmed slipper du fundera ut vad de ska göra, avlusa dem, underhålla dem och dokumentera dem. Men minst lika värdefullt är att du nu har all logik för testningen i testfallet! Det gör testfallet väldigt lätt att förstå (när man väl vant sig vid syntaxen).

Bibliotek för mockklasser

Så vad finns det för bibliotek att tillgå? En hel del gratisbibliotek för programmerbara mockklasser hittar du på wikisidan för mockobjekt (under "Mock object library for C++"). Alla bibliotek har sina för- och nackdelar. På senare tid har jag använt Google mocks som har väldigt mycket användbar funktionalitet. Rekommenderas.

Enhetstestramverk

I Kodexempel 3 använde vi assert vilket avbryter körningen av dina testfall vid första felet. Det är inte helt bra. Man vill gärna se hur många testfall som lyckats och misslyckats.

Boost Test är ett kanonbra enhetstestramverk som underlättar ditt liv med allt som ett enhetstestramverk bör ha. Den har även lite extrafunktionalitet såsom checkpoints och koll för minnesläckor. Använder du Boost Test ersätter du assert med t.ex. BOOST_CHECK_EQUAL. Ramverk för enhetstester är tyvärr ett helt ämne i sig själv som vi nog får återkomma till en annan gång.

Om du inte gjort det redan så sätt igång nu direkt och testa din kod! Lycka till med testningen.



Relaterade artiklar: