juergen.rocks

Datengetriebene (data-driven) parametrisierte Tests mit Pytest (pytest-csv-params)

Pytest ist ein sogleich extrem mächtiges, als auch ein wunderbar erweiterbares Testframework. Ich benutze es fast in allen Python-Projekten. Doch war eine Sache immer irgendwie umständlich: Datengetriebe Tests, die aus CSV-Dateien parametrisiert wurden.

TL;DR: Hier geht es zum Plugin:

Nicht falsch verstehen: Auch für diesen Zweck ist Pytest alleine mit seinen Bordwerkzeugen sehr gut geeignet. So gibt es die Möglichkeit, mit pytest_generate_tests (siehe Pytest-Doku dieser Methode und Beispiele für Parametrisierung) beliebig Tests über ein Fixture zu parametrisieren, doch bei einer Vielzahl von Tests wird das schnell unübersichtlich.

Da ich viel mit datengetriebenen Tests arbeite, musste eine andere Lösung her, und ich habe für eigene Zwecke pytest-csv-params gebaut.

pytest-csv-params

Dieses kleine Plugin erlaubt es, Tests direkt mit CSV-Dateien zu parametrisieren. Der Vorteil: Wenig Setup-Aufwand und klare Trennung zwischen Daten und der Teststruktur.

Download und Installation

Die Installation geht am einfachsten über PyPI:

pip install pytest-csv-params

Natürlich geht es auch per Poetry:

poetry add --dev pytest-csv-params

Als alternative Installationsquelle steht das Package Repository auf meinem eigenen Git-Server zur Verfügung:

pip install --extra-index-url https://git.codebau.dev/api/packages/pytest-plugins/pypi/simple pytest-csv-params

Unser Szenario

Nehmen wir an, wir müssen überprüfen, dass ein System zur Bestellung von Containern die Volumina korrekt berechnet und ausreichend große Container, aber auch nicht zu große Container bestellt. Dieses System gibt seine Bestellungen in einer CSV-Datei aus. So kommen wir zu der folgenden CSV-Datei. Der Einfachheit halber verzichten wir hier auf tiefergehende Betrachtungen von Zwischenräumen und Leerraumnutzung. Worauf wir jedoch nicht verzichten, ist eine nicht ganz unkomplizierter Aufbau der Daten, so dass man schon einige Funktionen des Plugins sehen kann.

"Order-Ref #", "Anz. Schrauben-Päck.", "Dim. Schrauben-Päck.", "Anz. Scheiben-Päck.", "Dim. Scheiben-Päck.", "Volumen Container"
"221-12-A-24", "670", "30 x 50 x 70 mm", "150", "40 x 50 x 70 mm", "1 m³"
"281-13-C-15", "5000", "30 x 50 x 70 mm", "10000", "40 x 50 x 70 mm", "5 m³"
"281-13-C-76", "50000", "35 x 55 x 75 mm", "5000", "50 x 60 x 90 mm", "10 m³"

Passen die Container? Gehen wir davon aus, dass es nur Container in den Größen 1, 5 und 10 m³ gibt. Dann könnte so unser Test aussehen:

def test_does_it_fit(
    anz_schrauben: int, vol_schrauben: int, anz_scheiben: int, vol_scheiben: int, vol_container: int
) -> None:

    available_container_sizes = map(lambda x: x * 1_000_000_000, [1, 5, 10])

    size_required = (anz_schrauben * vol_schrauben) + (anz_scheiben * vol_scheiben)

    # Smallest possible Container ordered?
    smallest_possible_container = min(filter(lambda x: x >= size_required, available_container_sizes))
    assert vol_container == smallest_possible_container

Wie lassen sich nun die entsprechenden Parameter (anz_schrauben, vol_schrauben, anz_scheiben, vol_scheiben, vol_container) ausfüllen? Genau hier kommt nun das Plugin zum Einsatz. Mit ihm wird der Test dekoriert:

from os.path import dirname, join
from pytest_csv_params.decorator import csv_params                      # (1)


@csv_params(
    data_file=join(dirname(__file__), "assets", "blog-example.csv"),    # (2)
    id_col="Order-Ref #",                                               # (3)
    header_renames={                                                    # (4)
        "Anz. Schrauben-Päck.": "anz_schrauben",
        "Dim. Schrauben-Päck.": "vol_schrauben",
        "Anz. Scheiben-Päck.": "anz_scheiben",
        "Dim. Scheiben-Päck.": "vol_scheiben",
        "Volumen Container": "vol_container",
    },
    data_casts={                                                        # (5)
        "anz_schrauben": int,                                           # (6)
        "anz_scheiben": int,
        "vol_schrauben": get_volume,                                    # (7)
        "vol_scheiben": get_volume,
        "vol_container": get_container_volume,                          # (8)
    },
)
def test_does_it_fit(
    anz_schrauben: int, vol_schrauben: int, anz_scheiben: int, vol_scheiben: int, vol_container: int
) -> None:
    ...

Hier sind nun verschiedene Dinge passiert:

  1. Der Decorator csv_params wird importiert.
  2. Der Parameter data_file wird mit dem Pfad zur CSV-Datei bestückt.
  3. Der Parameter id_col benennt den Namen der Spalte, die für die Bildung der Testfall-ID herangezogen wird. Testfall-IDs machen die Identifikation fehlgeschlagener Testfälle deutlich einfacher.
  4. Der Parammeter header_renames enthält ein Dictionary, welches die Namen der anderen Spalten auf gute Variablen-Namen mappt. Nur so funktionieren sie als Parameter für die Test-Funktion.
  5. Der Parameter data_casts enthält ein Dictionary, welches auf Methoden verweist, mit welcher die jeweiligen Daten aus den Spalten "vorbehandelt" werden müssen, bevor sie im Test verarbeitet werden können. Oft sind das einfache Format-Umwandlungen, z. B. int oder float. Manchmal wird es aber auch komplexer.
  6. Einfache Formatumwandlung von str (alles aus der CSV-Datei ist ein String) nach int.
  7. Hier wird auf eine Methode verwiesen, die aus dem String 30 x 50 x 70 mm ein Volumen berechnet.
  8. Hier wird auf eine Methode verwiesen, die das Container-Volumen in der passenden Einheit liefert.

Bis auf den Parameter data_file sind alle Angaben optional. Mehr Infos dazu in der Dokumentation des Plugins.

Zur Vervollständigung des Beispiel-Codes hier noch die beiden Umwandlungsmethoden aus Punkt 7 und 8:

import re


def get_volume(size_data: str) -> int:
    """
    Get the volume from size data, return it as mm³
    """
    matcher = re.compile(r"^\D*(?P<l>\d+)\D+(?P<d>\d+)\D+(?P<h>\d+)\D+$").match(size_data)
    if matcher is None:
        raise ValueError("Bad Test Data") from None
    return int(matcher.group("l")) * int(matcher.group("d")) * int(matcher.group("h"))


def get_container_volume(container_size: str) -> int:
    """
    Get the container size (remove the unit, as mm³)
    """

    matcher = re.compile(r"^\D*(?P<size>\d+)\D+$").match(container_size)
    if matcher is None:
        raise ValueError("Bad Test Data") from None
    return int(matcher.group("size")) * 1_000_000_000

Führt man nun den Testlauf durch, erscheinen für alle Zeilen der CSV-Datei Tests mit den zugewiesenen IDs:

tests/test_blog_example.py::test_does_it_fit[221-12-A-24] PASSED   [ 33%] 
tests/test_blog_example.py::test_does_it_fit[281-13-C-15] PASSED   [ 67%]
tests/test_blog_example.py::test_does_it_fit[281-13-C-76] PASSED   [100%]

Somit haben wir mit wenig Aufwand einen datengetriebenen Test mit Daten aus einer CSV-Datei versorgt.

Weitere interessante Funktionen

Zentrale Ablage der CSV-Files

Man stelle sich vor, dass man alle CSV-Dateien in einem zentralen Ort hat. Dieser Ort unterscheidet sich, je nach dem, ob man sich auf einem Entwickler-Rechner oder der CI befindet. Eine relative Angabe fällt weg, weil sie nicht abbildbar ist, z. B. weil das Arbeitsverzeichnis auf der CI nicht vorhersagbar ist.

Den Test baut man dann so auf:

@csv_params(data_file="order_cases/container_case_1.csv")
def test_container_case_1(param_1: str, param_2: str, param_3: str):
    ...

Bei der Durchführung der Tests verwendet man dann zusätzlich den Kommandozeilen-Parameter --csv-params-base-dir, um das Basis-Verzeichnis anzugeben:

pytest --csv-params-base-dir /data/test/data-directory

So ist eine einfache Konfigurationsunterscheidung zwischen verschiedenen Umgebungen möglich.

Automatische Header-Umwandlung

Das Plugin wandelt die Angaben aus der Kopfzeile der CSV-Datei (die Spaltennamen) mit Ausnahme einer ID-Spalte (sofern angegeben) nach festen Regeln in Variablen-Namen um. So werden für Variablen-Namen nicht erlaubte Zeichen durch Unterstriche _ ersetzt. Auch hier hält die Dokumentation Infos über die Regeln bereit.

Tauglichkeit in der Praxis

Zum Zeitpunkt der Erstellung dieses Blog-Beitrags ist das Plugin erst wenige Tage alt und sicherlich nur bei mir im Einsatz. Es arbeitet sehr gut und tut genau das, was ich von ihm erwarte. Ich habe noch viele Ideen, es zu erweitern, möchte aber auch nicht die Einfachheit der Nutzung verschlechtern.

Feedback, Bugs, Pull-Requests

Aufgrund der aktuellen Diskussion um GitHub habe ich entschieden, den Code zumindest vorerst auf meinem eigenen Sourcecode-Server zu hosten. Das erschwert allerdings die Arbeit mit Bug-Reports und Pull-Requests, da man sich auf dem Sourcecode-Server nicht registrieren kann.

Dennoch würde ich mich sehr über Input freuen. Bitte einfach eine E-Mail an die in der Doku genannten Adresse schreiben, wir finden einen Weg.

Autor:

Themen:

Veröffentlicht:
17.08.2022 13:56

Zuletzt aktualisiert:
17.08.2022 14:07



Bisher keine Kommentare.


Copyright © 2022, juergen.rocks.