5. Test Driven Development

Wat is de beste manier om het aantal bugs in code te reduceren?

Test-Driven Development

TDD (Test-Driven Development) is een hulpmiddel bij softwareontwikkeling om minder fouten te maken en sneller fouten te vinden, door éérst een test te schrijven en dan pas de implementatie. Die (unit) test zal dus eerst falen (ROOD), want er is nog helemaal geen code, en na de correcte implementatie uiteindelijk slagen (GROEN).

graph LR;
    T{"Write FAILING<br/> test"}
    D{"Write<br/> IMPLEMENTATION"}
    C{"Run test<br/> SUCCEEDS"}
    S["Start Hier"]
    S --> T
    T --> D
    D --> C
    C --> T

Testen worden opgenomen in een build omgeving, waardoor alle testen automatisch worden gecontroleerd bij bijvoorbeeld het compileren, starten, of packagen van de applicatie. Op deze manier krijgt men onmiddellijk feedback van modules die door bepaalde wijzigingen niet meer werken zoals beschreven in de test.

Soorten van Testen

Er zijn drie grote types van testen:

1. Unit Testing (GROEN)

Een unit test test zaken op individueel niveau, per klasse dus. De meeste testen zijn unit testen. Hoe kleiner het blokje op bovenstaande figuur, hoe beter de F.I.R.S.T. principes kunnen nageleefd worden. Immers, hoe meer systemen opgezet moeten worden voordat het assertion framework zijn ding kan doen, hoe meer tijd verloren, en hoe meer tijd de test in totaal nodig heeft om zijn resultaat te verwerken.

2. Integration Testing (ORANJE)

Een integratie test test het integratie pad tussen twee verschillende klasses. Hier ligt de nadruk op interactie in plaats van op individuele functionaliteit, zoals bij de unit test. We willen bijvoorbeeld controleren of een bepaalde service wel iets wegschrijft naar de database, maar het schrijven zelf is op unit niveau bij de database reeds getest. Waar wij nu interesse in hebben, is de interactie tussen service en database, niet de functionaliteit van de database.

Typische eigenschappen van integration testen:

  • Test geïntegreerd met externen. (db, webservice, …)
  • Test integratie twee verschillende lagen.
  • Trager dan unit tests.
  • Minder test cases.

3. End-To-End Testing (ROOD)

Een laatste groep van testen genaamd end-to-end testen, ofwel scenario testen, testen de héle applicatie, van UI tot DB. Voor een GUI applicatie bijvoorbeeld betekent dit het simuleren van de acties van de gebruiker, door op knoppen te klikken en te navigeren doorheen de applicatie, waarbij bepaalde verwachtingen worden afgetoetst. Bijvoorbeeld, klik op ‘voeg toe aan winkelmandje’, ga naar ‘winkelmandje’, controleer of het item effectief is toegevoegd.

Typische eigenschappen van end-to-end testen:

  • Test hele applicatie!
  • Niet alle limieten.
  • Traag, moeilijker onderhoudbaar.
  • Test integratie van alle lagen.

In plaats van dit in (Java) code te schrijven, is het ook mogelijk om de Selenium IDE extentie voor Google Chrome of Mozilla Firefox te gebruiken. Deze browser extentie laat recorden in de browser zelf toe, en vergemakkelijkt het gebruik (er is geen nood meer aan het vanbuiten kennen van zulke commando’s). Dit wordt in de praktijk vaak gebruikt door software analisten of testers die niet de technische kennis hebben om te programmeren, maar toch deel zijn van het ontwikkelteam.

Recente versies van de Selenium IDE plugin bewaren scenario’s in .side bestanden, wat een JSON-notatie is. Oudere versies bewaren commando’s in het .html formaat. deze bestanden bevatten een lijst van je opgenomen records:

  "tests": [{
    "id": "73bc78d5-1ca2-44c4-9ad2-6ccfe7cba5fe",
    "name": "bla",
    "commands": [{
      "id": "f192c93d-064a-4298-8d84-a44fd617622b",
      "comment": "",
      "command": "open",
      "target": "/mattersof/workshops",
      "targets": [],
      "value": ""
    }, {
      "id": "b94bd35b-0fc0-4ede-9b47-7e24d78126e8",
      "comment": "",
      "command": "setWindowSize",
      "target": "1365x691",
      "targets": [],
      "value": ""
    }, {
        ...

Opgave rond end-to-end testen

Gebruik Selenium IDE om een test scenario op te nemen van het volgende scenarios op de website https://www.saucedemo.com/. :

  1. Log in met “locked_out_user” en wachtwoord “secret_sauce” en verifieer dat je een error boodschap krijgt.
  2. Log in met “standard_user” en wachtwoord “secret_sauce”, klik op het eerste item, voeg toe aan je winkelmandje, ga naar je winkelmandje. Verifieer dat er een product inzit.
  3. Log in met “standard_user” en wachtwoord “secret_sauce” en test of de afbeeldingen van de producten verschillend zijn.
  4. Log in met “problem_user” en wachtwoord “secret_sauce” en test of de afbeeldingen van de producten verschillend zijn. (Deze test moet nu falen omdat je je voordoet als een user die een bug ervaart.)

Je zal voor deze opgave dus de Selenium (Chromium/Firefox) plugin moeten installeren: zie hierboven.

Important

In de volgende delen wordt er dieper ingegaan op de verschillende concepten die komen kijken bij Test Driven Development. We gaan de theoretische concepten echter enkel aanhalen in de tekst rond TDD in Java, maar die zijn natuurlijk wel van toepassing op alle talen zoals Python en C. We halen later bij de pagina’s rond TDD in Python en C die theoretische kant niet zo zwaar meer boven en houden ons daar meer bezig met de praktische kant.

Subsections of 5. Test Driven Development

Debugger tools

De wie, wat en waarom van debugger tools

Een debugger in een geïntegreerde ontwikkelomgeving (IDE) is een tool waarmee je stap voor stap door je code kan gaan, je kan code uitvoeren en de toestand van variabelen en van het programma als geheel bekijken tijdens het uitvoeren van je programma. Hier zijn de voordelen van het gebruik van een debugger ten opzichte van het handmatig uitprinten van waarden:

  • Interactieve stapsgewijze uitvoering: Met een debugger kunnen ontwikkelaars hun code uitvoeren en pauzeren bij specifieke punten, zoals breakpoints of fouten. Ze kunnen de code stap voor stap doorlopen om te zien hoe de waarden van variabelen veranderen en om eventuele fouten te identificeren.

  • Breakpoints: Ontwikkelaars kunnen breakpoints instellen op specifieke regels in hun code, waardoor het programma pauzeert wanneer het deze regels bereikt. Dit stelt ontwikkelaars in staat om de staat van het programma te onderzoeken op een specifiek punt in de uitvoering, wat handig is voor het debuggen van specifieke problemen.

  • Variabele inspectie: Ontwikkelaars kunnen variabelen inspecteren terwijl ze door de code stappen. Dit stelt hen in staat om de waarden van variabelen te bekijken en te begrijpen hoe deze veranderen tijdens de uitvoering van het programma, wat nuttig is bij het debuggen van complexe problemen.

  • Dynamische wijziging van variabelen: Sommige debuggers bieden de mogelijkheid om variabelen dynamisch te wijzigen tijdens het debuggen. Dit kan handig zijn om te testen hoe verschillende waarden van variabelen van invloed zijn op de uitvoering van het programma, zonder de code opnieuw te hoeven compileren en uitvoeren.

  • Call stack en uitvoeringspad: Een debugger biedt informatie over de call stack en het uitvoeringspad van het programma. Dit helpt ontwikkelaars om te begrijpen welke functies worden aangeroepen en in welke volgorde, wat kan helpen bij het opsporen van bugs die zich voordoen als gevolg van onverwachte uitvoeringspaden.

Over het algemeen biedt een debugger een krachtige set van tools voor het effectief debuggen van code, waardoor ontwikkelaars efficiënter problemen kunnen oplossen en complexe codebases beter kunnen begrijpen. Het is een essentiële tool voor elke software developper.

Debuggen met VSCode

VSCode biedt voor verschillende talen (eventueel met behulp van extensies) de mogelijkheid om programma’s te debuggen met een geïntegreerde debugger.

TDD in Java

Een TDD Scenario

Stel dat een programma een notie van periodes nodig heeft, waarvan elke periode een start- en einddatum heeft, die al dan niet ingevuld kunnen zijn. Een contract bijvoorbeeld geldt voor een periode van bepaalde duur, waarvan beide data ingevuld zijn, of voor gelukkige werknemers voor een periode van onbepaalde duur, waarvan de einddatum ontbreekt:

public class Contract {
    private Periode periode;
}

public class Periode {
    private Date startDatum;
    private Date eindDatum; 
}

We wensen aan de Periode klasse een methode toe te voegen om te controleren of periodes overlappen, zodat de volgende statement mogelijk is: periode1.overlaptMet(periode2).

1. Schrijf Falende Testen

Voordat de methode wordt opgevuld met een implementatie dienen we na te denken over de mogelijke gevallen van een periode. Wat kan overlappen met wat? Wanneer geeft de methode true terug, en wanneer false? Wat met lege waardes?

  • Het standaard geval: beide periodes hebben start- en einddatum ingevuld, en de periodes overlappen.
@Test
public void gegevenBeidePeriodesDatumIngevuld_wanneeroverlapt_danIsTrue() {
    var jandec19 = new Periode(new Date(2019, 01, 01), 
            new Date(2019, 12, 31));
    var maartnov19 = new Periode(new Date(2019, 03, 01),
            new Date(2019, 11, 31));

    assert(jandec19.overlaptMet(maartnov19) == true);
}
  • Beide periodes hebben start- en einddatum ingevuld, en periodes overlappen niet.
@Test
public void gegevenNietOverlappendePeriodes_wanneerOverlaptMet_danIsFalse() {
    var jandec19 = new Periode(new Date(2019, 01, 01), 
            new Date(2019, 12, 31));
    var maartnov20 = new Periode(new Date(2020, 03, 01),
            new Date(2020, 11, 31));

    assert(jandec19.overlaptMet(maartnov20) == false);
}

Merk op dat de namen van de testen zeer descriptief zijn. Op die manier wordt in één opslag duidelijk waar er problemen opduiken in je code.

  • … Er zijn nog tal van mogelijkheden, waarvan voornamelijk de extreme gevallen belangrijk zijn om de kans op bugs te minimaliseren. Immers, gebruikers van onze Periode klasse kunnen onbewust null mee doorgeven, waardoor de methode onverwachte waardes teruggeeft.

De testen compileren niet, omdat de methode overlaptMet() nog niet bestaat. Voordat we overschakelen naar het schrijven van de implementatie willen we eerst de testen zien ROOD kleuren, waarbij wel de bestaande code nog compileert:

public class Periode {
    private Date startDatum;
    private Date eindDatum; 
    public boolean overlaptMet(Periode anderePeriode) {
        throw new UnsupportedOperationException();
    }
}

De aanwezigheid van het skelet van de methode zorgt er voor dat de testen compileren. De inhoud, die een UnsupportedOperationException gooit, dient nog aangevuld te worden in stap 2. Op dit punt falen alle testen (met hopelijk als oorzaak de voorgaande exception).

2. Schrijf Implementatie

Pas nadat er minstens 4 verschillende testen werden voorzien (standaard gevallen, edge cases, null cases, …), kan met een gerust hart aan de implementatie worden gewerkt:

public boolean overlaptMet(Periode anderePeriode) {
    return startDatum.after(anderePeriode.startDatum) && 
        eindDatum.before(anderePeriode.eindDatum);
}

3. Voer Testen uit

Deze eerste aanzet verandert de deprimerende rode kleur van minstens één test naar GROEN. Echter, lang niet alle testen zijn in orde. Voer de testen uit na elke wijziging in implementatie totdat alles in orde is. Het is mogelijk om terug naar stap 1 te gaan en extra test gevallen toe te voegen.

4. Pas code aan (en herbegin)

De cyclus is compleet: red, green, refactor, red, green, refactor, …

Wat is ‘refactoring’?

Structuur veranderen, zonder de inhoud te wijzigen.

Als de overlaptMet() methode veel conditionele checks bevat is de kans groot dat bij elke groene test de inhoud stelselmatig ingewikkelder wordt, door bijvoorbeeld het veelvuldig gebruik van if statements. In dat geval is het verbeteren van de code, zonder de functionaliteit te wijzigen, een refactor stap. Na elke refactor stap verifiëer je de wijziging door middel van de testen.

Voel jij je veilig genoeg om grote wijzigingen door te voeren zonder te kunnen vertrouwen op een vangnet van testen? Wij als docenten alvast niet.

Naamgeving van testen en projectstructuur

Om testen op een correcte manier uit te voeren wordt aan een bepaalde structuur vastgehouden.

Note

We maken gebruik van CamelCase en snake_case om alle testen de vorm te geven van gegevenDit_wanneerDezeMethodeEropToegepastWordt_danMoetDitDeUitkomstZijn.

Unit Tests

Wat is Unit Testing

Unit testen zijn stukjes code die productie code verifiëren op verschillende niveau’s. Het resultaat van een test is GROEN (geslaagd) of ROOD (gefaald met een bepaalde reden). Een collectie van testen geeft ontwikkelaars het zelfvertrouwen om stukken van de applicatie te wijzigen met de zekerheid dat de aanwezige testen rapporteren wat nog werkt, en wat niet. Het uitvoeren van deze testen gebeurt meestal in een IDE zoals IntelliJ voor Java, of Visual Studio voor C#, zoals deze screenshot:

Elke validatieregel wordt apart opgelijst in één test. Als de validate() methode 4 regels test, zijn er minstens 4 testen geimplementeerd. In de praktijk is dat meestal meer omdat edge cases - uitzonderingsgevallen zoals null checks - ook aanzien worden als een apart testgeval.

Eigenschappen van een goede test

Elke unit test is F.I.R.S.T.:

  1. Fast. Elk nieuw stukje functionaliteit vereist nieuwe testen, waarbij de bestaande testen ook worden uitgevoerd. In de praktijk zijn er duizenden testen die per compile worden overlopen. Als elke test één seconde duurt, wordt dit wel erg lang wachten…
  2. Isolated. Elke test bevat zijn eigen test scenario dat géén invloed heeft op een andere test. Vermijd ten allen tijden het gebruik van het keyword static, en kuis tijdelijk aangemaakte data op, om te vermijden dat andere testen worden beïnvloed.
  3. Repeatable. Elke test dient hetzelfde resultaat te tonen, of die nu éénmalig wordt uitgevoerd, of honderden keren achter elkaar. State kan hier typisch roet in het eten gooien.
  4. Self-Validating. Geen manuele inspectie is vereist om te controleren wat de status van de test is. Een falende foutboodschap is een duidelijke foutboodschap.
  5. Thorough. Testen moeten alle scenarios dekken: denk terug aan edge cases, randgevallen, het gebruik van null, het opvangen van mogelijke Exceptions, …

Het Raamwerk van een test

Bij het aanmaken van het project met Gradle, heeft Gradle je al een heel stuk geholpen om het testraamwerk op te stellen. Testen over een bepaalde klasse bundel je namelijk in een file onder de test-directory. Zorg er ook voor dat de testfile zich in dezelfde package onder de testdiretory bevindt. De conventie is dat we de testfile dezelfde naam geven als de klasse die we willen testen met Test achter. In principe is dit ook gewoon een javaklasse die we op een speciale manier gaan gebruiken. Bij het aanmaken van je project voorziet Gradle zelfs al een test. De structuur van de app/src-directory van je project ziet er dus als volgt uit:

./app/src
├── main
│   ├── java
│   │   └── be
│   │       └── ses
│   │           └── App.java
│   └── resources
└── test
    ├── java
    │   └── be
    │       └── ses
    │           └── AppTest.java
    └── resources

Test Libraries bestaande uit twee componenten

Een test framework, zoals JUnit voor Java, MSUnit/NUnit voor C#, of Jasmine voor Javascript, bevat twee delen:

1. Het Test Harnas

Een ‘harnas’ is het concept waar alle testen aan worden opgehangen. Het harnas identificeert en verzamelt testen, en het harnas stelt het mogelijk om bepaalde of alle testen uit te voeren. De ingebouwde Test UI in VSCode fungeert hier als visueel harnas. Elke test methode, een public void methode geannoteerd met @Test, wordt herkent als mogelijke test.

Gradle en het JUnit harnas verzamelen data van testen in de vorm van HTML rapporten.

Hiervoor dient dus de dependency testImplementation libs.junit in onze gradle.build-file.

2. Het Assertion Framework

Naast het harnas, dat zorgt voor het uitvoeren van testen, hebben we ook een verificatie framework nodig, dat fouten genereert wanneer nodig, om te bepalen of een test al dan niet geslaagd is. Dit gebeurt typisch met assertions, die vereisten dat een argument een bepaalde waarde heeft. Is dit niet het geval, wordt er een AssertionError exception gegooid, die door het harnas herkent wordt, met als resultaat een falende test.

Assertions zijn er in alle kleuren en gewichten, waarbij in de oefeningen de statische methode assertThat() wordt gebruikt, die leeft in org.assertj.core.api.Assertions. AssertJ is een plugin library die ons in staat stelt om een fluent API te gebruiken in plaats van moeilijk leesbare assertions:

import static org.junit.Assert.*;
@Test
public void testWithDefaultAssertions() {
    var result = doStuff();
    AssertEquals(result, 3);    // arg1: expected, arg2: actual
}
import static org.assertj.core.api.Assertions.*;
@Test
public void testWithAssertJMatchers() {
    var result = doStuff();
    assertThat(result).isEqualTo(3);
}

Het tweede voorbeeld leest als een vloeiende zin, terwijl de eerste AssertEquals() vereist dat als eerste argument de expected value wordt meegegeven - dit is vaak het omgekeerde van wat wij verwachten!

AssertJ core API Documentation

Je kan simpelweg de dependency testImplementation "org.assertj:assertj-core:3.11.1" in de gradle.build-file toevoegen. En de import van import static org.junit.Assert.*; in de test file veranderen naar import static org.assertj.core.api.Assertions.*;

Een populair alternatief voor AssertJ is bijvoorbeeld Hamcrest. De keuze is aan jou: alle frameworks bieden ongeveer dezelfde fluent API aan met ongeveer dezelfde features.

Messages meegeven aan Testresultaten

Je kan ook extra messages meegeven aan testresultaten die afhankelijk kunnen zijn van het resultaat, bekijk hiervoor de documentatie van AssertJ.

Testen op Exceptions
Info

Je kan testen met AssertJ op exceptions op de volgende manier:

@Test
public void myTest(){
    // when
    Throwable thrown = catchThrowable(() -> {
        // ...
    });

    // then
    assertThat(thrown)
    .isInstanceOf(Exception.class)
    .hasMessageContaining("/ by zero");
}

@Test
public void myTest(){
    assertThatExceptionOfType(Exception.class)
        .isThrownBy(() -> {
            // ...
        }).withMessageContaining("Substring in message");
}

Arrange, Act, Assert

De body van een test bestaat typisch uit drie delen:

@Test
public void givenArranged_whenActing_thenSomeExpectedResult() {
    // 1. Arrange 
    var instance = new ClassToTest(arg1, arg2);

    // 2. Act
    var result = instance.callStuff();

    // 3. Assert
    assertThat(result).isEqualTo(true);
}
  1. Arrange. Het klaarzetten van data, nodig om te testen, zoals een instantie van een klasse die wordt getest, met nodige parameters/DB waardes/…
  2. Act. Het uitvoeren van de methode die wordt getest, en opvangen van het resultaat.
  3. Assert. Het verifiëren van het resultaat van de methode.

Setup, Execute, Teardown

Wanneer de Arrange stap dezelfde is voor een serie van testen, kunnen we dit veralgemenen naar een @Before methode, die voor het uitvoeren van bepaalde of alle testen wordt uitgevoerd. Op dezelfde manier kan data worden opgekuist na elke test met een @After methode - dit noemt men de teardown stap.

JUnit 4 en JUnit 5 verschillen hierin op niveau van gebruik. Vanaf JUnit 5 werkt men met @BeforeEach/@BeforeAll. Raadpleeg de documentatie voor meer informatie over het verschil tussen each/all en tussen v4/v5.

Demo: Calculator app unit testen

  1. Om testen op de correcte manier uit te kunnen voeren gaan we starten met de juiste dependencies in te stellen in onze build.gradle file:
dependencies {
    // Use JUnit 4 for testing.
    testImplementation libs.junit

    testImplementation "org.assertj:assertj-core:3.11.1"

    // This dependency is used by the application.
    implementation libs.guava
}
  1. Vervolgens maken we een Calculator klasse aan in ons gradle project in het package be.ses.
package be.ses;

public class Calculator {

}
  1. Omdat we goede developers zijn maken we ook meteen een CalculatorTest klasse aan in de overeenkomstige package in de app/src/test directory. En voegen hier al de nodige imports aan toe. hier zullen al onze testen in geschreven worden als public void methodes:
package be.ses;

import org.junit.Test;
import static org.assertj.core.api.Assertions.*;

public class CalculatorTest {
    
}

We willen aan onze calculator een divide-methode toevoegen die 2 parameters meekrijgt teller en noemer.

  1. We gaan volgens de correcte principes dus EERST testen schrijven die de onze method gaan testen voordat we de implementatie ervan gaan uitschrijven. (En aangezien het kan zijn dat er door 0 gedeeld wordt, gaan we dit ook testen):
package be.ses;

import org.junit.Test;
import static org.assertj.core.api.Assertions.*;

public class CalculatorTest {

  @Test
  public void gegevenTeller2Noemer1_wanneerDivide_danResult2() {
    // 1. Arrange
    Calculator calculator = new Calculator();

    // 2. Act
    Float result = calculator.divide(2, 1);
    System.out.println(result);

    // 3. Assert
    assertThat(result).isEqualTo(2);
  }

  @Test
  public void gegevenTeller2Noemer4_wanneerDivide_danResult0point5() {
    // 1. Arrange
    Calculator calculator = new Calculator();

    // 2. Act
    Float result = calculator.divide(2, 4);
    System.out.println(result);

    // 3. Assert
    assertThat(result).isEqualTo(0.5f);
  }

  @Test
  public void gegevenTellerXNoemer0_wanneerDivide_danDivideByZeroException() {
    // when
    Throwable thrown = catchThrowable(() -> {
      // 1. Arrange
      Calculator calculator = new Calculator();

      // 2. Act
      Float result = calculator.divide(2, 0);
    });

    // 3. Assert
    assertThat(thrown)
        .isInstanceOf(ArithmeticException.class)
        .hasMessageContaining("/ by zero");
  }
}
  1. Nu kunnen we aan onze implementatie werken en proberen alle testen te laten slagen:
package be.ses;

public class Calculator {

  public Calculator() {

  }

  public float divide(float teller, float noemer) {
    if (noemer == 0) {
      throw new ArithmeticException("/ by zero");
    }
    return teller / noemer;
  }
}

Oefening

  • Breid je Calculator klasse uit om ook add, subtract en multiply te doen. En schrijf natuurlijk eerst enkel tests.
  • Voeg eigen messages toe aan je testen om nog beter te kunnen kijken wat eventueel misloopt.

Integration testen

Test Doubles

Stel dat we nu een andere klasse hebben Doubler die een methode heeft doubleCalculator. Die methode neemt 3 parameters: operation,x,y en voert dus de gekozen operatie uit met de Calculator klasse en verdubbeld gewoon het resultaat.

Hoe testen we de doubleCalculator() methode, zonder de effectieve implementatie van Calculator te moeten gebruiken? Want testen moeten geïsoleerd zijn.

Door middel van test doubles.

Zoals Arnie in zijn films bij gevaarlijke scenes een stuntman lookalike gebruikt, zo gaan wij in onze code een Calculator lookalike gebruiken, zodat de Doubler-klasse dénkt dat hij Calculator-methoden aanroept, terwijl dit in werkelijkheid niet zo is. Daarvoor gaan we Mocks gebruiken. (Je kan ook een interface CalculatorInterface voorzien zodat je overal waar je Calculator wil gebruiken ook een eigen CalculatorMock-klasse kan gebruiken met dezelfde methodes maar waar je een aantal testscenarios gewoon hardcode, dit gaan we hier niet voordoen.)

Mocking

Meer info rond faking vs. mocking vindt je hier:

Fakes are objects that have working implementations. On the other hand, mocks are objects that have predefined behavior. Lastly, stubs are objects that return predefined values. When choosing a test double, we should use the simplest test double to get the job done.

Mockito is verreweg het meest populaire Unit Test Framework dat bovenop JUnit wordt gebruikt om heel snel Test Doubles en integratietesten op te bouwen.

Mockito logo Mockito logo

Op https://site.mockito.org kan je lezen hoe je het framework moet gebruiken. (Volledige javadoc)

Het ideale gedrag dat we met zo een mock willen bekomen is dat overal waar we een andere klasse gebruiken met new in onze testen dat we dit kunnen onderscheppen en in de plaats onze “Test Double” kunnen meegeven. Dit kon vroeger gedaan worden met PowerMocks. Dit kan lukt echter niet meer in de nieuwere versies van de Java JVM en met goede reden.

Wanneer je binnen een bepaald klasse een andere klasse wil gebruiken, maak je best gebruik van Dependency Injection, wat wil zeggen dat je niet in een methode een instantie aanmaakt van een andere klasse, maar op voorhand een object aanmaakt van die klasse en dan als parameter aan de methode of datamember van de klasse meegeeft. Op die manier kunnen we ook veel simpeler testen. Als we nu de methode oproepen kunnen we als parameter simpelweg onze Test Double meegeven in plaats van een echt object.

We gaan hiervoor dus Mockito gebruiken om in onze testen. Hiervoor heb je volgende dependency nodig:

testImplementation 'org.mockito:mockito-core:3.12.4'

Hieronder vind je een implementatie in verband met het voorbeeld dat hierboven werd aangehaald:

De Doubler klasse ziet er als volgt uit, waarbij we rekening houden met het principe van dependency injection:

package be.ses;

public class Doubler {

  public float doubleCalculator(Calculator calculator, String operation, float x, float y) {
    if (operation == "divide") {
      return calculator.divide(x, y) * 2;
    } else {
      throw new UnsupportedOperationException("Wrong calculator operation selected");
    }
  }

}

De test klasse ziet er dan als volgt uit:

package be.ses;

import org.junit.Test;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

public class DoublerTest {
  @Test
  public void gegevenOperationDivideX2Y1_wanneerDoubleCalculator_danResultIs4() {
    Calculator calculatorMock = mock(Calculator.class);
    when(calculatorMock.divide(2f, 1f)).thenReturn(2.0f);

    Doubler doubler = new Doubler();
    float result = doubler.doubleCalculator(calculatorMock, "divide", 2, 1);

    assertThat(result).isEqualTo(4.0f).withFailMessage("result was " + result + " but expected 4.0.");
    verify(calculatorMock).divide(2f, 1f);
  }
}

Merk op dat we met de laatste regel nog even dubbel checken dat wel zeker de Test Double (calculatorMock) gebruikt werd met de juiste methode en parameters.

Oefening

  • Breid de Doubler klasse uit om ook add, subtract en multiply te doen. En schrijf natuurlijk eerst enkele tests en gebruik correct Test Doubles/Mocks.

End-to-end testen

Zie TDD-pagina

Testing met Gradle

Als je nu de Gradle task test gebruikt om je testen uit te voeren, wordt er door Gradle automatisch een interactief verslag in de vorm van een webpagina gegenereerd in de build/reports/test/test-directory. Je vind hier een file index.html. Deze file openen in de browser geeft bijvoorbeeld volgend verslag:

Gradle test report example Gradle test report example

Extra oude Opgaven: geen verplichting

Klik hier om de opgaven te bekijken🔽

Opgave 1

De Artisanale Bakkers Associatie vertrouwt op uw technische bekwaamheid om hun probleem op te lossen. Er wordt veel Hasseltse Speculaas gebakken, maar niemand weet precies wat de beste Speculaas is. Schrijf een methode die speculaas beoordeelt op basis van de ingrediënten. De methode, in de klasse Speculaas, zou er zo uit moeten zien:

    public int beoordeel() {
        // TODO ...
    }

De functie geeft een nummer terug - hoe hoger dit nummer, hoe beter de beoordeling en hoe gelukkiger de bakker. Een speculaas kan de volgende ingrediënten bevatten: kruiden, boter, suiker, eieren, melk, honing, bloem, zout. Elke eigenschap is een nummer dat de hoeveelheid in gram uitdrukt.

Het principe is simpel: hoe meer ingrediënten, hoe beter de beoordeling.

Kijk naar een voorbeeld test hoe de methodes te hanteren. Er zijn al enkele testen voorzien. Die kan je uitvoeren met IntelliJ door op het groen pijltje te drukken, of met Gralde: ./gradlew test. Dit genereert een test rapport HTML bestand in de build/test map.

We zijn dus geïnteresseerd in edge cases. Probeer alle mogelijkheden te controleren. Denk bij het testen aan de volgende zaken:

  • Hoe zit het met een industriële speculaas, zonder kruiden of boter?
  • Wat doet de functie beoordeel als het argument null is?
  • Wat als een speculaas wordt meegegeven zonder ingrediënten?

Opgave 2

Clone of fork het GitHub project https://github.com/KULeuven-Diepenbeek/ses-tdd-exercise-2-template

A. Mislukte login pogingen

Er is een foutje geslopen in de login module, waardoor Abigail altijd kan inloggen, maar jos soms wel en soms niet. De senior programmeur in ons team heeft de bug geïdentificeerd en beweert dat het in een stukje oude code zit, maar hij heeft geen tijd om dit op te lossen. Nu is het aan jou. De logins.json file bevat alle geldige login namen die mogen inloggen. Er kan kunnen geen twee gebruikers met dezelfde voornaam zijn. (Andere namen die moeten kunnen inloggen zijn “James”, “Emma”, “Isabella” …) (Andere namen die niet mogen kunnen inloggen zijn “Arne”, “Kris”, “Markske” …)

public class LoginChecker {
    public static boolean control(String username) {
        ArrayList<String> loginList = new ArrayList<>();
        try {
            Gson gson = new Gson();
            JsonReader reader = new JsonReader(new FileReader("./logins.json"));
            JsonArray data = gson.fromJson(reader, JsonArray.class);
            for (JsonElement jo : data) {
                String login = gson.fromJson(jo, String.class);
                loginList.add(login);
            }
        }catch(FileNotFoundException fnfe){
            fnfe.printStackTrace();
        }

        boolean found = false;
        for (String naam : loginList) {
            if (naam.equals(username)) {
                found = true;
                break;
            }
        }
        return found;
    }
}

Deze methode geeft true terug als Abigail probeert in te loggen, en false als Jos probeert in te loggen. Hoe komt dit? Schrijf éérst een falende test!

B. URL Verificatie fouten

Een tweede bug wordt gemeld: URL verificatie features werken plots niet meer. Deze methode faalt steeds, ook al zijn er reeds unit testen voorzien. Het probleem is dat HTTPS URLs met een SSL certificaat niet werken. Je onderzocht de URL verificatie code en vond de volgende verdachte regels:

import java.util.regex.Pattern;
import static java.util.regex.Pattern.CASE_INSENSITIVE;

public static boolean verifyUrl(String url) {
    Pattern pattern = Pattern.compile("http:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)", CASE_INSENSITIVE);
    return pattern.matcher(url).matches();
}

De code blijkt reeds unit testen te hebben, dus schrijf éérst een falende test (in VerifierTests).

Opgave 3

Info

Dit is een vervolgopgave van de code van Opgave 1. Werk verder op dat bestaand project, in diezelfde repository!

Een verkoopster werkt in een (goede) speculaasfabriek. De verkoopster wilt graag 2 EUR aanrekenen per speculaas die de fabriek produceert. Echter, als de klant meer dan 5 stuks verkoopt, mag er een korting van 10% worden aangerekend. In dit voorbeeld gaan we ervan uit dat een fabriek een willekeurig aantal speculaas per dag maakt en dat de klant steeds alle speculazen koopt. De verkoop gebeurt in de Verkoopsterklasse en het bakken van de speculazen gebeurt in de SpeculaasFabriek. Als we nu willen testen of onze verkoop methode uit de Verkoopster-klasse werkt, dan willen we dit isolated doen. We willen dus de onzekerheid van de Fabriek weghalen door specifieke gevallen aan te halen. Dit kan echter niet via de standaard SpeculaasFabriek. Daarom gaan we een test double gebruiken. Hiervoor gaan we deze keer een mock gebruiken zoals verder duidelijk wordt.

    public double verkoop() {
        var gebakken = speculaasFabriek.bak();
        // TODO ...
    }

Je ziet aan bovenstaande code dat de speculaasfabriek instantie wordt gebruikt. We hebben dus eigenlijk geen controle op de hoeveelheid speculaas die deze fabriek produceert.

Unit testen

Hoe kunnen we dan toch nog testen wat we willen testen? Mogelijke scenario’s:

  1. De fabriek produceert niets. De klant betaalt niets.
  2. De fabriek produceert minder dan 5 speculaasjes. De klant betaalt per stuk, 2 EUR.
  3. De fabriek produceert meer dan 5 stuks. De klant krijgt 10% korting op zijn totaal.

TDD in Python

Unit tests

Je zou simpelweg de ingebouwde assert functie in Python kunnen gebruiken om testen te schrijven zoals hieronder is weergegeven. Je kan de testfile dan runnen met python3 calculator_test.py en wanneer een assert niet klopt zal je een AssertionError krijgen.

  • calculator.py:
def divide(teller, noemer):
    if (noemer == 0):
        raise ZeroDivisionError
    return teller/noemer
  • calculator_test.py:
from calculator import divide

def gegevenTeller2Noemer1_wanneerDivide_danResult2():
    assert divide(2,1) == 1

if __name__ == "__main__":
    gegevenTeller2Noemer1_wanneerDivide_danResult2()

Dit geeft echter niet zo een mooie output zoals we in Java met Junit hebben gezien en Python komt standaard met een testmodule genaamd unittest. We kunnen onze testfile dan uitvoeren met python3 -m unittest calculator_test.py. Unittest beschouwd onze functie echter nog niet als een test, hiervoor moeten we een aantal dingen wijzigen aan onze testfile:

  1. Een testklasse aanmaken genaamd die erft van de klasse unittest.TestCase: TestCalculator(unittest.TestCase)
  2. De testen als klasse methodes definiëren
  3. De methoden moeten beginnen met test_
  4. Je moet asserten met self.assert... (self verwijst hierbij naar de klasse zelf)
  5. De main methode van unittest oproepen: unittest.main()
import unittest
from calculator import divide

class TestCalculator(unittest.TestCase):
    def test_gegevenTeller2Noemer1_wanneerDivide_danResult2(self):
        result = divide(2, 1)
        self.assertEqual(result, 1)  # Let op: 2/1 = 2, dus deze test zal falen!

if __name__ == "__main__":
    unittest.main()

Nu krijgen we bij het runnen van deze testfile een mooiere output:

arne@LT3210121:~/ses/tddpythondemo/src$ python3 -m unittest calculator_test.py 
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

We kunnen onze testfile nu uitbreiden net zoals we dat in Java gedaan hebben:

import unittest
from calculator import divide

class TestCalculator(unittest.TestCase):
    def test_gegevenTeller2Noemer1_wanneerDivide_danResult2(self):
        result = divide(2, 1)
        self.assertEqual(result, 2) 
    
    def test_gegevenTeller2Noemer4_wanneerDivide_danResult0point5(self):
        result = divide(2, 4)
        self.assertEqual(result, 0.5)
    
    def test_gegevenTellerXNoemer0_wanneerDivide_danZeroDivisionError(self):
        with self.assertRaises(ZeroDivisionError):
            divide(2, 0)

    def test_voorbeeld_falende_test(self):
        result = divide(2, 1)
        self.assertEqual(result, 4)
    

if __name__ == "__main__":
    unittest.main()
Info

Er bestaan ook nog andere modules om testen uit te voeren in Python. Een zeer populaire module is Pytest. Deze module biedt onder andere een iets mooiere output met groene en rode highlights voor respectievelijk geslaagde en gefaalde testen.

Setting up VSCode for Python testing

Je kan de built-in VSCode tool gebruiken voor debugging (gecombineerd met de juiste extensies) om een mooie Gui interface te hebben voor de testen. Hiervoor klik je op onderstaande view panel en configureer je het juiste Testing framework, de plaats waar VSCode moet zoeken naar de test files en hoe de test files noemen.

Test suite for VSCode

Nadat je alles correct geconfigureerd hebt zouden je testen er als volgt moeten uitzien:

Voorbeeld testoutput Python

Integration tests

Net zoals in Java kunnen we Mocks hiervoor gebruiken om Test Doubles aan te maken. Gelukkig is ‘mocken’ een functionaliteit die rechtstreeks in unittest is ingebouwd. De doubler.py-file en doubler_test.py-file analoog aan het voorbeeld in integration testen in Java:

  • doubler.py: We geven nu een referentie naar de functie mee als parameter om te voldoen aan het principe van Dependency Injection.
from calculator import divide

def double_calculator(operation, x, y):
    result = operation(x, y)
    return result * 2
  • doubler_test.py:
import unittest
from unittest.mock import patch
from doubler import double_calculator

class TestCalculator(unittest.TestCase):
    @patch("doubler.divide")  # Mock de geïmporteerde `divide` functie in `doubler.py`
    def test_gegevenOperationDivideX2Y1_wanneerDoubleCalculator_danResultIs4(self, mock_divide):
        # Arrange
        # De mock werd meegegeven als parameter en je stelt nu de return value in
        mock_divide.return_value = 2.0
        
        # Act
        result = double_calculator(mock_divide, 2, 1)
        
        # Assert
        self.assertEqual(result, 4.0)
        
        # Verifieer dat `divide` werd aangeroepen met (2, 1)
        mock_divide.assert_called_once_with(2, 1)

if __name__ == "__main__":
    unittest.main()

End-to-end testen

Zie TDD-pagina

Opgaven: oefenen op het gebruik van een debugger

Onderstaande Python file bevat een aantal functies die getest worden met een testfile. Er zijn echter een heel deel testen die nog falen. Verbeter nu de python functies zodat alle testen slagen:

  • Python file: functions.py
def max_in_list(lst):
    max_val = lst[0]
    for num in lst:
        if num < max_val:  
            max_val = num
    return max_val

def factorial(n):
    if n == 0:
        return 1
    result = 1
    for i in range(1, n):  
        result *= i
    return result

def is_even(num):
    return num % 2 == 1  

def count_positive(numbers):
    count = 0
    for num in numbers:
        if num >= 0:  
            count += 1
    return count

def is_prime(n):
    if n <= 1:
        return False
    for i in range(2, int(n**0.5)): 
        if n % i == 0:
            return False
    return True
  • Python test file: functions_test.py
import unittest
from functions import max_in_list, factorial, is_even, count_positive, is_prime

class TestFunctions(unittest.TestCase):
    def test_gegeven3en5en2_wanneerMaxInList_dan5(self):
        self.assertEqual(max_in_list([3, 5, 2]), 5)
    
    def test_gegevenMin1enMin5enMin3_wanneerMaxInList_danMin1(self):
        self.assertEqual(max_in_list([-1, -5, -3]), -1)

    def test_gegeven0_wanneerFactorial_dan1(self):
        self.assertEqual(factorial(0), 1)
        
    def test_gegeven5_wanneerFactorial_dan120(self):
        self.assertEqual(factorial(5), 120)

    def test_gegeven4_wanneerIsEven_danTrue(self):
        self.assertTrue(is_even(4))
    
    def test_gegeven5_wanneerIsEven_danFalse(self):
        self.assertFalse(is_even(5))

    def test_gegevenMin1en0en5_wanneerCountPositive_dan1(self):
        self.assertEqual(count_positive([-1, 0, 5]), 1)
    
    def test_gegevenMin2enMin3_wanneerCountPositive_dan0(self):
        self.assertEqual(count_positive([-2, -3]), 0)

    def test_gegeven4en5en6_wanneerCountPositive_dan3(self):
        self.assertEqual(count_positive([4, 5, 6]), 3)

    def test_gegeven1_wanneerIsPrime_danFalse(self):
        self.assertFalse(is_prime(1))

    def test_gegeven2_wanneerIsPrime_danTrue(self):
        self.assertTrue(is_prime(2))

    def test_gegeven3_wanneerIsPrime_danTrue(self):
        self.assertTrue(is_prime(3))

    def test_gegeven4_wanneerIsPrime_danFalse(self):
        self.assertFalse(is_prime(4))

    def test_gegeven9_wanneerIsPrime_danFalse(self):
        self.assertFalse(is_prime(9))

    def test_gegeven11_wanneerIsPrime_danTrue(self):
        self.assertTrue(is_prime(11))

if __name__ == '__main__':
    unittest.main()

TDD in C

TDD in C

Dit is een stuk complexer en wordt ook net iets minder gebruikt dan in de higher level languages zoals Python en Java, maar nog altijd zeer handig. Je moet echter de juiste instellingen voor je compiler en dergelijke instellen. Om die reden laten we dit als zelfstudie voor de student die hierin geïnteresseerd is en refereren naar een video die gebruik maakt van CMake en Gtests om TDD in C mogelijk te maken.

Warning

Hoewel dat de tutorial gemaakt is rond C++ files, maakt dit niet uit omdat je hier ook gewoon C functies kan gebruiken. Het is echter wel waar dat de testen zelf WEL in een .cpp-file (C++) geschreven moeten zijn! In de oude cursus Besturingssystemen & C kan je ook nog eens de nodige info terugvinden indien gewenst.