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.
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
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.
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.
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.