Ga naar hoofdinhoud

Pytest gebruiken voor unit tests

Ontdek wat Pytest is en waar het voor wordt gebruikt, en vergelijk het met andere softwaretestmethoden.
· 13 min lezen

Verken met AI

Testen is een enorm onderwerp binnen softwareontwikkeling. Voordat een softwareproduct in de handen van een eindgebruiker komt, heeft het waarschijnlijk meerdere tests doorlopen, zoals integratietests, systeemtests en acceptatietests. Het idee achter zulke grondige tests is om te zorgen dat de applicatie zich gedraagt zoals verwacht vanuit het perspectief van de eindgebruiker. Deze aanpak van testen staat bekend als behavior-driven development (BDD).   

De laatste tijd is de interesse in test-driven development (TDD) onder ontwikkelaars flink toegenomen. Diep erop ingaan is te complex voor dit artikel, maar in grote lijnen komt het erop neer dat het traditionele proces van ontwikkelen en testen wordt omgedraaid: je schrijft eerst je unit tests en implementeert daarna codewijzigingen totdat de tests slagen. 

In dit artikel richten we ons op unit tests en specifiek op hoe je die uitvoert met een populair Python-testframework: Pytest. 

Wat zijn unit tests?

Unit tests zijn een vorm van geautomatiseerde tests — dat betekent simpelweg dat het testplan door een script wordt uitgevoerd in plaats van handmatig door een mens. Ze vormen het eerste niveau van softwaretesten en worden meestal geschreven als functies die het gedrag van verschillende functionaliteiten binnen een softwareprogramma valideren. 

The levels of software testing

De niveaus van softwaretesten

Het idee achter deze tests is dat ontwikkelaars de kleinste logische code-eenheid kunnen isoleren en testen of die zich gedraagt zoals verwacht. Met andere woorden: unit tests valideren dat één component van een softwareprogramma werkt zoals de ontwikkelaars hebben bedoeld. 

Ideaal gezien zijn deze tests vrij klein — hoe kleiner, hoe beter. Een reden om kleinere tests te maken is dat de test efficiënter is, omdat het testen van kleinere units ervoor zorgt dat de testcode veel sneller kan worden uitgevoerd. Een andere reden om kleinere componenten te testen is dat je beter inzicht krijgt in hoe de fijnmazige code zich gedraagt wanneer die wordt samengevoegd. 

Waarom hebben we unit tests nodig? 

De algemene reden waarom unit tests essentieel zijn, is dat ontwikkelaars moeten garanderen dat de code die ze schrijven aan de kwaliteitsnormen voldoet voordat die in een productieomgeving terechtkomt. Er zijn echter meerdere factoren die bijdragen aan de noodzaak van unit tests. Laten we een paar van die redenen uitdiepen.

Spaart middelen 

Het uitvoeren van unit tests helpt ontwikkelaars bugs te vinden tijdens de bouwfase van software, waardoor ze niet verder in de ontwikkelcyclus doordringen. Dit bespaart middelen, omdat ontwikkelaars later in het traject niet de kosten hoeven te betalen voor het oplossen van bugs — en het betekent ook dat eindgebruikers minder snel met buggy code te maken krijgen.  

Extra documentatie 

Nog een goede reden om unit tests uit te voeren, is dat ze dienen als een extra laag levende documentatie voor je softwareproduct. Ontwikkelaars kunnen simpelweg naar de unit tests kijken voor een holistisch begrip van het totale systeem, omdat ze laten zien hoe de kleinere componenten zich zouden moeten gedragen. 

Vertrouwensboost

Het is extreem eenvoudig om subtiele fouten in je code te maken terwijl je functionaliteit schrijft. De meeste ontwikkelaars zullen het er echter over eens zijn dat het veel beter is om de breekpunten binnen een codebase te identificeren voordat die naar productie gaat: unit tests bieden die mogelijkheid. 

Het is fair om te zeggen dat “code die door unit tests wordt gedekt, betrouwbaarder kan worden beschouwd dan code die dat niet is.” Toekomstige breuken in de code worden veel sneller ontdekt dan bij code zonder testdekking, wat tijd en geld bespaart. Ontwikkelaars profiteren ook van extra documentatie, zodat ze de codebase sneller begrijpen, en er is de extra zekerheid dat fouten in hun code door de unit tests worden opgevangen in plaats van door een eindgebruiker. 

Python-testframeworks

Python is door de jaren heen enorm in populariteit gegroeid. Als onderdeel daarvan is ook het aantal testframeworks toegenomen, met als resultaat een rijkdom aan tools om je Python-code te testen. De details van elk hulpmiddel uitpluizen valt buiten de scope van dit artikel, maar we stippen enkele van de meest gangbare Python-testframeworks aan. 

unittest

Unittest is een ingebouwd Python-framework voor unit testing. Het is geïnspireerd op het unit testframework JUnit uit de programmeertaal Java. Omdat het standaard bij Python wordt geleverd, hoef je geen extra modules te installeren en gebruiken de meeste ontwikkelaars het om te beginnen met testen.  

Pytest

Pytest is mogelijk het meest gebruikte Python-testframework — het heeft dus een grote community die je kan helpen wanneer je vastloopt. Het is een open-source framework waarmee ontwikkelaars eenvoudige, compacte testsuites kunnen schrijven, met ondersteuning voor unit testing, functioneel testen en API-testen.

doctest

Het doctest-framework combineert twee kernonderdelen van software-engineering: documentatie en testen. Deze functionaliteit zorgt ervoor dat alle softwareprogramma’s grondig worden gedocumenteerd en getest, zodat ze draaien zoals het hoort. doctest maakt deel uit van de standaardbibliotheek van Python en is vrij eenvoudig te leren. 

nose2

Nose2, de opvolger van het nose-regiment, is in essentie unittest met plug-ins. Vaak wordt nose2 “uitgebreide unit tests” of “unit tests met een plug-in” genoemd vanwege de nauwe banden met het ingebouwde unittest-framework van Python. Omdat het praktisch een extensie van unittest is, is nose2 bijzonder eenvoudig te adopteren voor wie unittest al kent.

Testify

Testify, een Python-framework voor unit-, integratie- en systeemtesten, staat bekend als het framework dat ontworpen is om unittest en nose te vervangen. Het framework zit vol met uitgebreide plug-ins en heeft een vrij vlakke leercurve als je unittest al gewend bent.

Hypothesis

Hypothesis stelt ontwikkelaars in staat unit tests te maken die eenvoudiger te schrijven zijn en krachtig bij uitvoering. Omdat het framework is gebouwd met datasienceprojecten in gedachten, helpt het randgevallen te vinden die niet zo voor de hand liggen tijdens het schrijven van je tests, door voorbeelden van inputs te genereren die passen bij specifieke eigenschappen die je definieert.

Voor onze tutorial gebruiken we pytest. Bekijk de volgende sectie om te zien waarom je Pytest zou verkiezen boven de andere die we hebben genoemd. 

Waarom Pytest gebruiken? 

Naast de grote, helpende community heeft pytest meerdere eigenschappen die het tot een van de beste tools maken om je geautomatiseerde testsuite in Python uit te voeren. De filosofie en functies van Pytest zijn erop gericht om softwaretesten een veel betere ontwikkelaarservaring te maken. Een manier waarop de makers van Pytest dit doel bereikten, is door de hoeveelheid code die nodig is voor veelvoorkomende taken aanzienlijk te verminderen en het mogelijk te maken geavanceerde taken uit te voeren met uitgebreide commando’s en plug-ins. 

Andere redenen om Pytest te gebruiken zijn onder meer: 

Makkelijk te leren 

Pytest is ontzettend makkelijk te leren: als je begrijpt hoe het assert-trefwoord van Python werkt, ben je al een heel eind op weg om het framework onder de knie te krijgen. Tests met pytest zijn Python-functies met “test_” ervoor of “_test” erachter in de functienaam — al kun je ook een class gebruiken om meerdere tests te groeperen. Al met al is de leercurve van pytest veel vlakker dan die van unittest, omdat je geen nieuwe constructies hoeft te leren. 

Testfiltering

Je wilt mogelijk niet bij elke uitvoering al je tests draaien — zeker niet naarmate je testsuite groeit. Soms wil je een paar tests rond een nieuwe feature isoleren om tijdens het ontwikkelen snel feedback te krijgen, en daarna de volledige suite draaien zodra je vertrouwen hebt dat alles werkt zoals gepland. Pytest heeft drie manieren om tests te isoleren: 1) naamgebaseerde filtering, waarbij je pytest opdraagt alleen de tests te draaien waarvan de namen overeenkomen met het opgegeven patroon 2) directory-scoping, een standaardinstelling die pytest vertelt alleen tests te draaien die zich in of onder de huidige directory bevinden en 3) testcategorisatie, waarmee je categorieën kunt definiëren voor tests die pytest moet opnemen of uitsluiten. 

Parameterization

Pytest heeft een ingebouwde decorator genaamd parametrize die het mogelijk maakt argumenten voor een testfunctie te parametriseren. Dus als de functies die je test data verwerken of een generieke transformatie uitvoeren, hoef je niet meerdere soortgelijke tests te schrijven. Later in het artikel gaan we dieper in op parametrisatie. 

We laten het hier even bij, maar de lijst met redenen waarom pytest een geweldige tool is voor je geautomatiseerde testsuite gaat nog wel even door. 

Pytest vs unittest

Ondanks alle bovenstaande redenen kun je nog steeds twijfelen over het gebruik van pytest om de simpele reden dat het een third-party framework is — “waarom een framework installeren als er al eentje ingebouwd is?” Dat is een valide punt, maar om ons te wapenen in die discussie geven we je een paar overwegingen mee. 

Let op: Ben je al overtuigd van pytest, ga dan door naar de volgende sectie waarin we praktisch aan de slag gaan met het framework. 

Minder boilerplate

Unittest vereist dat ontwikkelaars classes maken die zijn afgeleid van de TestCase-module en vervolgens de testgevallen als methoden in de class definiëren. 

"""
An example test case with unittest.
See: https://docs.python.org/3/library/unittest.html
"""
import unittest

class TestStringMethods(unittest.TestCase):

    def test_upper(self):
        self.assertEqual('foo'.upper(), 'FOO')

    def test_isupper(self):
        self.assertTrue('FOO'.isupper())
        self.assertFalse('Foo'.isupper())

    def test_split(self):
        s = 'hello world'
        self.assertEqual(s.split(), ['hello', 'world'])
        # check that s.split fails when the separator is not a string
        with self.assertRaises(TypeError):
            s.split(2)

Pytest daarentegen vereist alleen dat je een functie definieert met “test_” ervoor en daarin assert-voorwaarden gebruikt. 

"""
An example test case with pytest.
See: https://docs.pytest.org/en/6.2.x/index.html
"""
# content of test_sample.py
def inc(x):
    return x + 1


def test_answer():
    assert inc(3) == 5

Let op het verschil in de hoeveelheid benodigde code; unittest heeft substantieel veel boilerplate nodig die als minimumvereiste geldt voor elke test die je wil uitvoeren. Daardoor is de kans groot dat je dezelfde code meerdere keren schrijft. Pytest heeft daarentegen rijke ingebouwde features die deze workflow vereenvoudigen door de hoeveelheid code die nodig is voor testcases terug te dringen. 

Output

De output van beide frameworks is erg verschillend. Hier is een voorbeeld van een pytest-uitvoering: 

"""
See: https://docs.pytest.org/en/6.2.x/index.html
"""
$ pytest
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-1.x.y
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collected 1 item

test_sample.py F                                                     [100%]

================================= FAILURES =================================
_______________________________ test_answer ________________________________

    def test_answer():
>       assert inc(3) == 5
E       assert 4 == 5
E        +  where 4 = inc(3)

test_sample.py:6: AssertionError
========================= short test summary info ==========================
FAILED test_sample.py::test_answer - assert 4 == 5
============================ 1 failed in 0.12s =============================

De test hierboven is gefaald, maar let op hoe gedetailleerd de uiteenzetting van de fout is. Dit maakt het makkelijker voor ontwikkelaars om te vinden waar de bugs in hun code zitten — superhandig bij het debuggen. Als extraatje is er ook een algemene statusrapportage van de testsuite die aangeeft hoeveel tests zijn gefaald en hoe lang het duurde. 

Bekijk nu een voorbeeld van een gefaalde test met unittest.

import unittest

def square(n):
    return n*n

def cube(n):
    return n*n*n

class TestCase(unittest.TestCase):
    def test_square(self):
        self.asserEquals(square(4), 16)

    def test_cube(self):
        self.assertEquals(cube(4), 16)

Als we het script draaien, krijgen we de volgende output: 

---------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (errors=1)

De output hierboven vertelt ons dat er twee tests zijn gedraaid in 0,001 s en dat er één faalde, maar verder niet veel. Al met al geeft pytest veel informatiever feedback, wat goed van pas komt wanneer je moet debuggen. 

Uiteindelijk zijn zowel pytest als unittest prima tools voor geautomatiseerd testen in Python. Veel Python-ontwikkelaars neigen echter naar pytest vanwege de compactheid en efficiëntie. Het is ook extreem eenvoudig te adopteren en er zijn allerlei features waarmee je een effectieve testsuite kunt bouwen. 

Dan nu het hoofdonderdeel van dit artikel. We hebben besproken wat unit testing is en waarom pytest een uitstekende tool is voor geautomatiseerd testen in Python. Laten we nu kijken hoe je de tool gebruikt. 

Pytest-tutorial

Kijken we eens hoe dit Python-testframework waar we het steeds over hebben, werkt. 

De eerste stap is het installeren van het pakket, wat kan met een eenvoudige pip-opdracht. 

Let op: de makers van pytest raden aan om venv te gebruiken voor ontwikkeling en pip voor het installeren van je applicatie, dependencies en pytest zelf. 

pip install -U pytest

Controleer daarna of het framework is geïnstalleerd met het volgende commando: 

>>>> pytest --version

pytest 7.1.2

Alles is geïnstalleerd. Je kunt nu tests gaan draaien. 

Een simpele test maken

Een test maken is eenvoudig met Pytest. Om dit te demonstreren hebben we een script gemaakt genaamd calcualte_age.py. Dit script heeft slechts één functie, get_age, die de leeftijd van een gebruiker berekent op basis van zijn of haar geboortedatum. 

import datetime

def get_age(yyyy:int, mm:int, dd:int) -> int:
    dob = datetime.date(yyyy, mm, dd)
    today = datetime.date.today()
    age = round((today - dob).days / 365.25)
    return age

Pytest voert alle Python-bestanden uit waarvan de bestandsnaam begint met test_ of eindigt op _test. Meer specifiek volgt pytest de volgende conventies voor het ontdekken van tests [bron: documentatie]: 

  • Als er geen argumenten zijn opgegeven, begint pytest te verzamelen in testpaths als die zijn geconfigureerd: testpaths zijn een lijst met mappen die pytest doorzoekt wanneer er geen specifieke mappen, bestanden of test-id’s zijn opgegeven. 
  • Pytest doorloopt vervolgens recursief mappen, tenzij je hebt ingesteld dat dit niet moet met norecursedirs; het zoekt naar bestanden die beginnen met test_*.py of eindigen op *_test.py
  • In die bestanden verzamelt pytest test-items in de volgende volgorde:
    • Voorvoegsel-testfuncties of -methoden buiten een class
    • Voorvoegsel-testfuncties of -methoden binnen Test-voorvoegsel testclasses die geen __init__-methode hebben. 

We hebben geen argumenten opgegeven, maar we hebben in dezelfde map een ander script gemaakt genaamd test_calculate_age.py: dus wanneer de mappen recursief worden doorlopen, wordt de test gevonden.  In dit script hebben we één test, test_get_age, om te valideren dat onze functie correct werkt. 

Let op: Je kunt er ook voor kiezen je tests in een aparte map buiten je applicatie te zetten — een goed idee als je meerdere functionele tests hebt of als je testcode en applicatiecode om een andere reden gescheiden wilt houden. 

from calculate_age import get_age

def test_get_age():
    # Given.
    yyyy, mm, dd = map(int, "1996/07/11".split(""))   
    # When.
    age = get_age(yyyy, mm, dd)
    # Then.
    assert age == 26 

Voer de test uit met het volgende commando in de commandoregel: 

py -m pytest 

The output of a successful pytest run

De output van een succesvolle pytest-run.

En dat is alles. 

Maar wat als we data moeten aanleveren om onze tests te laten slagen? Maak kennis met pytest fixtures. 

Pytest Fixtures 

De onschatbare fixtures van Pytest stellen ontwikkelaars in staat om data in de tests te voeren. Het zijn in wezen functies die vóór elke testfunctie draaien om de staat van onze tests te beheren. Stel dat we meerdere tests hebben die allemaal dezelfde data gebruiken; dan kunnen we met een fixture de herhaalde data ophalen via één enkele functie. 

import pytest
from sklearn.model_selection import train_test_split
from fraud_detection_model.config.core import config
from fraud_detection_model.processing.data_manager import (

load_datasets, 

load_interim_data

) 


@pytest.fixture(scope="session")
def pipeline_inputs():
    # import the training dataset
    dataset = load_datasets(transaction=config.app_config.train_transaction, identity=config.app_config.train_identity, )
    # divide train and test
    X_train, X_test, y_train, y_test = train_test_split(

dataset[config.model_config.all_features], dataset[config.model_config.target],
test_size=config.model_config.test_size,
stratify=dataset[config.model_config.target],
random_state=config.model_config.random_state)
    return X_train, X_test, y_train, y_test

In de code hierboven hebben we een fixture gemaakt met de pytest.fixture-decorator. Let op: de scope staat op "session" om pytest te laten weten dat we willen dat de fixture aan het einde van de testsessie wordt vernietigd.

Let op: onze fixtures staan in conftest.py.

De code gebruikt een functie die we uit een andere module hebben geïmporteerd om twee csv-bestanden in het geheugen te laden en samen te voegen tot één dataset. Vervolgens wordt de dataset gesplitst in train- en testsets en geretourneerd vanuit de functie. 

Om deze fixture in onze tests te gebruiken, moeten we die als parameter aan onze testfunctie meegeven. In dit voorbeeld gebruiken we onze fixture in ons testscript test_pipeline.py als volgt: 

import pandas as pd
from pandas.api.types import is_categorical_dtype, is_object_dtype
from fraud_detection_model import pipeline
from fraud_detection_model.config.core import configfrom fraud_detection_model.processing.validation import validate_inputs 


def test_pipeline_most_frequent_imputer(pipeline_inputs):
    # Given
    X_train, _, _, _ = pipeline_inputs assert all( x in X_train.loc[:, X_train.isnull().any()].columns for x in config.model_config.impute_most_freq_cols )
    # When
    X_transformed = pipeline.fraud_detection_pipe[:1].fit_transform(X_train[:50])
    # Then
    assert all( x not in X_transformed.loc[:, X_transformed.isnull().any()].columns for x in config.model_config.impute_most_freq_cols )
def test_pipeline_aggregate_categorical(pipeline_inputs):
    # Given
    X_train, _, _, _ = pipeline_inputs  

    assert X_train["R_emaildomain"].nunique() == 60
    # When
    X_transformed = pipeline.fraud_detection_pipe[:2].fit_transform(X_train[:50])
    # Then
    assert X_transformed["R_emaildomain"].nunique() == 2

Maak je niet te druk om wat de code doet. Het belangrijkste is hoe we de noodzaak om dubbele code te schrijven flink hebben teruggebracht door een fixture te maken die we als parameter doorgeven om data in onze tests te trekken. 

Er zijn echter situaties waarin fixtures wat overdreven zijn. Bijvoorbeeld als de data die in je tests wordt gehaald in elke testcase opnieuw moet worden verwerkt — dat is praktisch gelijk aan je code vervuilen met allerlei losse objecten. Dat gezegd hebbende, fixtures zullen waarschijnlijk een sleutelrol spelen binnen je testsuite, maar bepalen wanneer je ze gebruikt of vermijdt, vergt oefening en nadenken.  

Pytest parametrize

Fixtures zijn geweldig wanneer je meerdere tests met dezelfde inputs hebt. Maar wat als je één functie wilt testen met kleine variaties in de inputs? Een oplossing is om meerdere verschillende tests te schrijven met uiteenlopende cases. 

def test_eval_addition():
    assert eval("2 + 2") == 4

def test_eval_subtraction():
    assert eval("2 - 2") == 0

def test_eval_multiplication():
    assert eval("2 * 2") == 4

def test_eval_division():
    assert eval("2 / 2") == 1.0

Hoewel deze oplossing zeker werkt, is het niet de meest efficiënte: om te beginnen zit er veel boilerplate in. Een betere oplossing is de decorator pytest.mark.parametrize() te gebruiken om argumenten voor een testfunctie te parametriseren. Daarmee kunnen we één enkele testdefinitie maken, waarna pytest de verschillende parameters die we opgeven voor ons test. 

Zo zouden we de bovenstaande code herschrijven met parametrisatie: 

import pytest

@pytest.mark.parametrize("test_input, expected_output", [("2+2", 4), ("2-2", 0), ("2*2", 4), ("2/2", 1.0)])
def test_eval(test_input, expected_output):
    assert eval(test_input) == expected_output

De @parametrize-decorator definieert vier verschillende testinputs en verwachte waarden voor de functie test_eval — dat betekent dat de functie vier keer draait, telkens met één van de sets. 

Output from pytest for parametrize test

De output van pytest voor een parametrized test

In dit artikel hebben we het volgende behandeld: 

  • wat unit tests zijn
  • waarom we unit tests nodig hebben
  • verschillende testframeworks in Python
  • waarom pytest zo nuttig is 
  • hoe je pytest gebruikt en twee van de belangrijkste features (fixtures & parametrisatie)

Je weet nu genoeg om je eigen tests te schrijven met het pytest-framework. We raden je sterk aan dat te doen, zodat alles wat je in dit artikel hebt geleerd, blijft hangen. Bekijk ook onze cursus unit testing for data science in python voor een uitgebreidere uitleg met voorbeelden. 

Onderwerpen

Gerelateerd