Je code testen

Inleiding

Programmeren is niet alleen het schrijven van je programma, maar er ook voor zorgen dat je dit met vertrouwen doet. Het testen van je code helpt hierbij. Elke keer dat je je code uitvoert om te kijken of het werkt, test je handmatig je code. Alhoewel nuttig tijdens het ontwikkelen, is het niet erg compleet en efficiënt.

Je kunt je code ook geautomatiseerd testen. Voordelen daarvan zijn dat je snel allerlei scenario’s kunt testen. Ook kun je de testen gebruiken om ervoor te zorgen dat wanneer je je code aanpast, het nog steeds het gewenste resultaat oplevert.

In dit hoofdstuk leer je werken met de module unittest uit de standaard bibliotheek om je code geautomatiseerd te testen.

Leerdoelen

Aan het einde van dit hoofdstuk:

  • Begrijp je wat een unittest is en waar het toe dient

  • Begrijp je hoe je een unittest schrijft

  • Begrijp je hoe je met testdata werkt

Geautomatiseerd testen

In deze paragraaf leer je enkele concepten behorende bij het geautomatiseerd testen: de verschillende niveaus van testen, wat fixtures zijn en wat assertions zijn.

Niveaus van testen

In het programmeren worden over het algemeen vier niveaus van testen onderscheiden.

Unittest

Een unittest richt zich op het testen van individuele componenten van je code, zoals functies en methoden. Heb je bijvoorbeeld een functie kwadraat(), dan is een mogelijke unittest de test of het resultaat 4 is als je als argument 2 opgeeft. Je weet zo dat je functie het juist resultaat levert.

Unittests zul je veel gebruiken tijdens het schrijven van je code. Elke keer als je een functie of methode maakt, test je dit. In de praktijk zul je zelfs unittests gebruiken om je functie vorm te geven. Er bestaan zelfs aanhangers van Test Driven Development (TDD): je gebruikt testen om de gewenste implementatie te bereiken.

Integratietest

Integratietesten testen hoe verschillende onderdelen van je software met elkaar samenwerken. Dit niveau test het resultaat wanneer je verschillende onderdelen in je code combineert.

Je testen focussen zich dus niet op één functie of methode, maar op het samenspel ertussen. Om een bepaald resultaat te bereiken heb je vaak meerdere functies nodig. Met een integratietest test je de hele flow.

Systeemtest

Deze testen richten zich op het valideren van het volledige systeem of de applicatie als geheel. Ze testen of het systeem aan de gestelde eisen voldoet en correct werkt in de beoogde omgeving.

Acceptatietest

Acceptatietesten worden uitgevoerd om te bepalen of het systeem voldoet aan de vereisten van de eindgebruiker. Dit kan worden gedaan door zowel de opdrachtgever als de gebruikers om ervoor te zorgen dat de software aan hun verwachtingen voldoet voordat deze in productie wordt genomen.

In dit hoofdstuk leer je werken met het kleinste blokje: de unittest. Dit omdat je als programmeur het meest zult gebruiken en vrij eenvoudig is uit te breiden naar integratietesten.

De systeemtesten en acceptatietesten zijn vaak de verantwoordelijkheid van andere collega’s.

image$testniveaus

Fixtures

Wanneer je gaat testen wil je zeker weten dat elke keer dat je de test herhaalt, je hetzelfde test. Dit kan betekenen dat bepaalde instellingen gedaan moeten zijn, dat een bepaald bestand aanwezig is of dat een bepaalde waarde in je database bestaat.

Fixtures zijn vooraf zijn vooraf ingestelde situaties die voor of na elke test worden uitgevoerd om de gewenste situatie te bereiken. In het werken met de module unittest in de volgende paragraaf zul je leren hoe je dit doet met de methodes setUp() en tearDown().

Assertions

Het laatste belangrijke concept bij testen is de assertion. In Python zijn dit statements die je gebruikt om beweringen te controleren. Je kunt hiervoor het ingebouwde assert gebruiken. Voer je onderstaande code uit, dan gebeurt er niets:

x = 5
assert x == 5

x is inderdaad 5, dus het statement x == 5 is waar en assert geeft geen melding. Verander je de code zodat het statement onwaar wordt, dan krijg je een AssertionError.

x = 6
assert x == 5
Traceback (most recent call last):
  File "/home/erwin/programmeren-met-python/main.py", line 2, in <module>
    assert x == 5
AssertionError

In de module unittest zul je meestal niet direct assert gebruiken, maar ingebouwde methodes als assertEqual (test of twee waarden gelijk zijn) of assertTrue (test of iets waar is). Dit zijn schillen om assert heen die je testen kunnen verduidelijken.

Werken met de module unittest

In deze paragraaf leer je werken met de ingebouwde module unittest. Maak eerst twee bestanden aan: main.py, die de code die je wilt testen zal bevatten en test.py, die de testen zal bevatten. Plaats de bestanden naast elkaar in dezelfde map.

test.py

Zorg ervoor dat test.py er als volgt uitziet:

import unittest


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

Voer je dit bestand nu uit, dan zul je de volgende output krijgen:

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

En dit klopt, want je hebt nog geen testen geschreven. Een test toevoegen vraagt twee stappen. Ten eerste maak je een klasse aan die overerft van unittest.TestCase. Voeg onderstaande toe aan test.py:

class MijnTest(unittest.TestCase):
    pass

Door een klasse te maken die overerft van TestCase, krijg je een aantal handige methodes tot je beschikking, zoals het werken met fixtures en de assertions. Over het algemeen zul je voor elke functie één testcase aanmaken en daar verschillende subtesten mee uitvoeren.

De daadwerkelijke testen voeg je toe door methodes aan de testcase toe te voegen die beginnen met test_:

class MijnTest(unittest.TestCase):

    def test_mijn_eerste_test(self):
        pass

    def test_mijn_tweede_test(self):
        pass

Voer je test.py nu uit, dan zie je dat er twee testen zijn uitgevoerd. Aangezien ze nog niets doen, slagen ze ook allemaal.

----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

main.py

Voordat je verdergaat met testen, heb je code nodig om te testen. Voeg de volgende code toe aan main.py:

def get_formatted_name(first, last):
    """ Maak een netjes opgemaakte naam. """

    full_name = f"{first} {last}"
    return full_name.title()

Zorg dat je begrijpt wat het doet en voer de functie een paar keer uit met verschillende variaties op namen (met en zonder hoofdletters, bijvoorbeeld).

Je eerste echte test

Pas test.py aan zodat je nu je code kunt testen. Ten eerste importeer je get_formatted_name. Pas ook de naam van de klasse aan naar iets duidelijkers, net als de naam van de eerste test.

import unittest

from main import get_formatted_name


class NamenTest(unittest.TestCase):
    """ Testen voor `get_formatted_name`. """

    def test_first_last_name(self):
        """ Werken namen zoals 'John Doe'? """
        verwacht = "John Doe"
        resultaat = get_formatted_name("john", "doe")
        self.assertEqual(resultaat, verwacht)


    def test_mijn_tweede_test(self):
        pass

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

Vanaf regel 9 zie je de eerste test. De opbouw is hoe je een test vaak aanpakt:

  • Je noteert de verwachte uitkomst, in dit geval "John Doe"

  • Je voert de functie uit en haalt het resultaat op

  • Je controleert of het resultaat overeenkomt met de verwachte uitkomst

Voer je test.py uit, dan zie je dat er weer twee test zijn uitgevoerd en dat het resultaat 'OK' is. De tweede test doet nog niets, maar de eerste test wel. Je test is dus geslaagd!

----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK
Opdracht 1: Kapitale test

Pas de tweede test aan zodat het test of de functie ook goed werkt als je "JOHN" en "DOE" als argumenten opgeeft.

Welke test kun je nog meer verzinnen?

Klik om het antwoord te tonen

Een uitwerking is:

import unittest

from main import get_formatted_name


class NamenTest(unittest.TestCase):
    """ Testen voor `get_formatted_name`. """

    def test_first_last_name(self):
        """ Werken namen zoals 'John Doe'? """
        verwacht = "John Doe"
        resultaat = get_formatted_name("john", "doe")
        self.assertEqual(resultaat, verwacht)

    def test_first_last_name_capital(self):
        """ Werken namen in kapitalen? """
        verwacht = "John Doe"
        resultaat = get_formatted_name("JOHN", "DOE")
        self.assertEqual(resultaat, verwacht)


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

Een mogelijke andere test die je kunt toevoegen is of "John", "Doe" werkt, dus waar de namen al netjes beginnen met een hoofdletter.

Een falende test

Je hebt nu een werkende functie en een aantal testen om te testen of de code inderdaad werkt zoals verwacht. Maar ineens bedenk je: wat als deze functie wordt gebruikt in een ander stuk code, waar de gebruiker wordt gevraagd om zijn naam op te geven. En de gebruiker geeft niet twee namen, maar drie namen op. Werkt de code dan ook? Dit klinkt als een goede test om te schrijven.

Voeg de volgende test toe aan de testcase:

class NamenTest(unittest.TestCase):

    # ...

    def test_three_names(self):
        """ Werken namen zoals 'John Tweedenaam Doe'? """
        verwacht = "John Tweedenaam Doe"
        resultaat = get_formatted_name("john", "tweedenaam", "doe")
        self.assertEqual(resultaat, verwacht)

Voer het bestand test.py uit en zie het resultaat, wat ongeveer als volgt zal zijn:

..E
======================================================================
ERROR: test_three_names (__main__.NamenTest)
Werken namen zoals 'John Tweedenaam Doe'?
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/erwin/programmeren-met-python/test.py", line 24, in test_three_names
    resultaat = get_formatted_name("john", "tweedenaam", "doe")
TypeError: get_formatted_name() takes 2 positional arguments but 3 were given

----------------------------------------------------------------------
Ran 3 tests in 0.000s

FAILED (errors=1)

Helemaal bovenaan zie je ..E, zodat je direct ziet dat er in dit geval drie testen zijn uitgevoerd waarvan er 1 faalde. Helemaal onderaan zie je ongeveer hetzelfde: er zijn drie testen uitgevoerd, 1 is gefaald met een fout. Dit laatste vertelt je dat er een fout is ontstaan in de code. Had er gestaan FAILURES (failures=1), dan werkte de code wel maar was het resultaat anders dan verwacht.

In dit geval ontstaat er dus een fout in je code. In de traceback lees je wat er fout gaat, en waar: er ontstaat een TypeError omdat er drie argumenten aan de functie zijn meegegeven, terwijl er maar twee geaccepteerd worden.

Opdracht 2: Verbeter de code

Pas de functie get_formatted_name aan zodat het ook werkt als je drie namen - voornaam, tweede naam, achternaam - wilt opmaken. Gebruik de testcase om te controleren of alle situaties (blijven) werken, dus ook als iemand twee namen opgeeft.

Klik om het antwoord te tonen

Een uitwerking is:

def get_formatted_name(first, last, middle=""):
    """ Maak een netjes opgemaakte naam. """

    if middle:
        full_name = f"{first} {middle} {last}"
    else:
        full_name = f"{first} {last}"

    return full_name.title()

Zorg dat de tweede naam, middle in dit geval, optioneel is door het een standaard waarde te geven. Omdat optionele argumenten altijd achter verplichte argumenten moeten komen, plaats je middle aan het eind. In de functie controleer je vervolgens op of middle is ingevuld.

De testcase is ook iets aangepast. Voor de duidelijkheid kun je de functie nu overal aanroepen met sleutelwoord argumenten. De laatste test moet je ook nog aanpassen. In de eerste versie riep je get_formatted_name("john", "middle", "doe") aan. Dit moet worden: get_formatted_name(first="john", last="doe", middle="middle").

De volledige testcase ziet er nu als volgt uit:

import unittest

from main import get_formatted_name


class NamenTest(unittest.TestCase):
    """ Testen voor `get_formatted_name`. """

    def test_first_last_name(self):
        """ Werken namen zoals 'John Doe'? """
        verwacht = "John Doe"
        resultaat = get_formatted_name(first="john", last="doe")
        self.assertEqual(resultaat, verwacht)

    def test_first_last_name_capital(self):
        """ Werken namen in kapitalen? """
        verwacht = "John Doe"
        resultaat = get_formatted_name(first="JOHN", last="DOE")
        self.assertEqual(resultaat, verwacht)

    def test_three_names(self):
        """ Werken namen zoals 'John Middle Doe'? """
        verwacht = "John Middle Doe"
        resultaat = get_formatted_name(first="john", last="doe", middle="middle")
        self.assertEqual(resultaat, verwacht)


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

Uitzonderingen testen

Stel dat iemand de functie get_formatted_name aanroept met iets anders dan een tekst voor één van de namen, bijvoorbeeld een getal. Je kunt er dan vanuit gaan dat dat een fout is, niemand zal "1 Jansen" heten.

Je kunt de functie aanpassen door hier op te controleren en een ValueError op te werpen als één of meer van de namen geen tekst is. Dat ziet er als volgt uit:

def get_formatted_name(first, last, middle=""):
    """ Maak een netjes opgemaakte naam. """

    all_strings = all(
        [isinstance(first, str),
         isinstance(last, str),
         isinstance(middle, str)]
    )

    if not all_strings:
        raise ValueError("Alle namen dienen tekst te zijn")

    if middle:
        full_name = f"{first} {middle} {last}"
    else:
        full_name = f"{first} {last}"

    return full_name.title()

Op regel 4 gebruik je all() om te kijken of alle argumenten van het type str zijn. Als dit zo is, dan zal all_strings waar zijn (True). Is één of meer van de argumenten geen str, dan evalueert all tot False.

Daarna controleer je of all_strings waar is en zo niet, werp je een ValueError met een bericht op. Dit wil je ook testen. Dat doe je als volgt:

class NamenTest(unittest.TestCase):

    # ...

    def test_not_str(self):
        with self.assertRaises(ValueError):
            get_formatted_name(first=1, last="doe", middle="middle")

In de test zorg je er bewust voor dat de ValueError wordt opgeworpen door het eerste argument een int mee te geven. Vervolgens controleer je met with self.assertRaises(ValueError): of de ValueError inderdaad wordt opgeworpen.

Opdracht 3: Meer testgevallen

Je hebt nu een werkende functie met een aantal testen. Maar werken met namen is tricky! Het is altijd goed om na te denken over alle denkbare situaties, inclusief edge cases: situaties die zeer uitzonderlijk zijn maar wel kunnen voorkomen.

Bedenk nog meer zaken die fout zouden kunnen gaan en waar je op kunt testen. Je mag de testen uitschrijven, maar dit hoeft niet.

Klik om het antwoord te tonen

Werken met namen is tricky. Hier zou je allemaal rekening mee kunnen houden:

  • Iemand heeft een dubbele voornaam (geen tweede naam)

  • Iemand heeft een dubbele achternaam

  • Iemand heeft meerdere voornamen en/of tweede namen

  • In veel landen zijn voor- en achternaam omgedraaid, houd je daar rekening mee?

  • Iemand heeft een naam met bijzondere karakters

  • Iemand heeft (nog) geen naam

  • Iemands naam is in kapitalen

  • Iemand naam is in allemaal kleine letters

  • …​

Deze lijst is zeker niet compleet. Namen zijn ingewikkeld, zeker als je namen gaat ontvangen die een herkomst hebben uit andere landen of culturen. De beste oplossing is daarom misschien nog wel: vraag in één veld om een naam en hoe de gebruiker het ook opgeeft, zo geef je het weer.

Werken met fixtures

Elke keer dat je een test uitvoert, wil je wel dat je exact hetzelfde test. Stel dat je een functie hebt dat het aantal gebruikers uit een database ophaalt. Om te testen of dit correct werkt, is het van belang dat je in je test weet hoeveel gebruikers er in je (test)database aanwezig zijn, zodat je self.assertEqual(verwacht, resultaat) kunt uitvoeren. Het is dus zaak dat in dit geval je testdatabase voorafgaande aan elke test precies in dezelfde staat verkeert. Deze gegevens of instellingen die je als uitgangspunt wilt nemen noem je fixtures.

setUp

Met de methode setUp() in je testcase zet je je fixtures klaar. De methode wordt voor elke afzonderlijke test aangeroepen en zorgt zo dus dat je gegevens voor elke test weer opnieuw klaar staan.

import unittest

class ListTestCase(unittest.TestCase):
    def setUp(self):
        self.mijn_list = [1, 2, 3]

    def test_sum(self):
        result = sum(self.mijn_list)
        self.assertEqual(result, 6)

    def test_append(self):
        result = self.mijn_list.append(4)
        self.assertEqual(result, [1, 2, 3, 4])

    def test_length(self):
        result = len(self.mijn_list)
        self.assertEqual(result, 3)

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

In dit voorbeeld begin je elke test met dezelfde lijst: self.mijn_list = [1, 2, 3]. Dit is niet alleen handig omdat je niet steeds een lijst hoeft uit te typen. Het is vooral belangrijk omdat je in test_append je data wijzigt (je voegt iets toe). Test je daarna de lengte van je lijst, dan weet je zeker dat dit op 3 uitkomt, omdat je mijn_list opnieuw hebt ingesteld op [1, 2, 3].

tearDown

Vergelijkbaar met setUp is tearDown. Deze methode wordt echter na elke test uitgevoerd, om eventuele data 'op te ruimen'. Denk aan het verwijderen van bestanden, leegmaken van een testdatabase of het resetten van instellingen.

import unittest
import sqlite3  # Een _package_ om met Sqlite databases te werken

class DatabaseManager:
    """ Een eenvoudige databasemanager. """

    def __init__(self, db_name):
        self.db_name = db_name
        self.conn = None

    def connect(self):
        self.conn = sqlite3.connect(self.db_name)

    def close(self):
        if self.conn:
            self.conn.close()

class MijnTestCase(unittest.TestCase):
    def setUp(self):
        """ Maak een database en connectie voor elke test. """
        self.db_manager = DatabaseManager('test.db')
        self.db_manager.connect()

    def tearDown(self):
        """ Sluit de database connectie na elke test. """
        self.db_manager.close()

    def test_table_creation(self):
        pass


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

In dit voorbeeld begin je elke test met dezelfde database. Na elke test sluit je de verbinding met de database netjes af.