Über unsMediaKontaktImpressum
Alina Dallmann, Florence Lopez & Lena Trautmann 10. Januar 2023

Einführung in Unittesting mit Python für Data Scientists

Was sicherlich (fast) jedem Data Scientist mal passiert ist: Eine entwickelte Funktion hat einen Fehler, der jedoch nicht direkt auffällt. Um solchen Situationen bestmöglich vorzubeugen, ist Unittesting in der Softwareentwicklung bereits weit verbreitet. In der Data Science sind Unittests ebenso sinnvoll und können dabei helfen, Fehler zu vermeiden und die Qualität einer Anwendung sicherzustellen. Als Nebeneffekt kann das Schreiben von Unittests zu einer besseren Codestruktur führen, denn zu große Funktionen/Klassen sind oft nicht sinnvoll testbar.

Dieser Artikel ist eine Einführung in das Unittesten mit Python mit einem besonderen Fokus auf Data-Science-Anwendungen und ihre Besonderheiten.

Was ist ein Unittest und warum und wann sollte man ihn schreiben?

Bei einem Unittest geht es darum, den Code der kleinsten Einheit, wie z. B. einer einzelnen Funktion, zu testen und die Funktionsweise sicherzustellen. Dabei testet man nur die eigene Implementierung und keine externen Abhängigkeiten. Im Vergleich dazu sollen Integrationstests das Zusammenspiel von verschiedenen Komponenten testen – beispielsweise von verschiedenen Prozessteilen. 

Die Arbeit als Data Scientist beginnt normalerweise explorativ, sodass Code auch oft wieder verworfen wird. Daher ist es Ansichtssache, zu welchem Zeitpunkt man Unittests schreibt. Mögliche Empfehlungen für den richtigen Zeitpunkt sind:

  • sobald Code zur besseren Wiederverwendung als Funktion verpackt wird oder
  • zur Validierung, ob ein gewünschtes Verhalten eintritt (wenn man z. B. zahlreiche print-statements einbaut, um das Ergebnis zu überprüfen)

Wenn der richtige Zeitpunkt für Unittests gekommen ist, empfiehlt es sich, verschiedene Szenarien zu definieren, wie sich die Code-Einheit verhalten soll. Hierbei sind insbesondere Grenzfälle interessant.

Best Practices für Unittests

Auch für Unittests existieren gewisse Best Practices aus der Softwareentwicklung. Tests sollen leicht lesbar sein, Code-Redundanzen vermieden (DRY = Don't Repeat Yourself) sowie Funktionen und Variablen aussagekräftig benannt werden. Die Namen von Testfunktionen werden selten geschrieben, aber häufig gelesen. Sie dürfen daher (deutlich) länger als gewöhnliche Funktionsnamen sein und sich dafür lesen wie ein verkürzter Satz, der alle relevanten Informationen enthält.

Testspezifische Best Practices sind die Unabhängigkeit von Tests und das Testen der API. Unter Unabhängigkeit der Tests untereinander versteht man, dass die Tests in beliebiger Reihenfolge ausgeführt werden können und trotzdem erfolgreich durchlaufen. Zu vermeiden sind daher Testabhängigkeiten, bei denen mehrere Tests nacheinander den Zustand eines Objekts verändern und darauf aufbauen, dass diese Zustandsänderung erfolgreich war. Das Plugin pytest-randomly kann hier Abhilfe schaffen, indem es die Testreihenfolge bei jedem Aufruf verändert [1]. Testen der API bedeutet, die Schnittstelle einer Methode oder Klasse zu testen und nicht die konkrete Implementierung, d. h. bei Klassen werden üblicherweise lediglich die öffentlichen Methoden getestet.

Trotz aller Best Practices gibt es auch gute Gründe, gegen einzelne Empfehlungen zu verstoßen. Es sind Empfehlungen, um Fehler zu vermeiden und die Entwicklungsgeschwindigkeit hoch zu halten, keine strikten Regeln, die immer und überall eingehalten werden müssen. Es ist jedoch hilfreich, derartige "Verstöße" mit einem Kommentar zu versehen, der die Gründe erläutert. Dadurch ist es leichter, die Tests im Falle von veränderten Anforderungen anzupassen.

pytest, um Unittests zu schreiben

Mit Kenntnis der wesentlichen Best Practices wollen wir nun diskutieren, wie man selbst Unittests schreibt. Die Bibliothek unittest ist zwar in Python integriert, das externe Package pytest bietet jedoch viele zusätzliche, hilfreiche Features an. Darüber hinaus ermöglicht pytest kürzere Schreibweisen. Das bedeutet weniger Boilerplate-Code und leichtere Lesbarkeit.

Mit dem Kommandozeilenaufruf pytest werden alle Tests im aktuellen Projektordner gesucht und ausgeführt. Damit Tests automatisch gefunden werden, muss der Dateiname test_*.py oder *_test.py enthalten und die Testfunktion bzw. -klasse mit test bzw. Test im Namen beginnen. Mithilfe von verschiedenen Optionen können beim Aufruf auch Teile der Tests ausgewählt werden [2]. Im Standardfall werden in der Kommandozeile für jede Testdatei grüne Punkte je erfolgreichem Test und rote Fs je fehlgeschlagenem Test angezeigt. Bei fehlerhaften Tests zeigt der Output zusätzlich die aufgetretenen Fehler an. Darüber hinaus gibt es eine Zusammenfassung, wie viele Tests jeweils erfolgreich waren bzw. fehlgeschlagen sind. Für IDEs wie VSCode gibt es Plugins, die das Debuggen von Testcode erleichtern; z. B. mit der Python Extension [3].

Codebeispiel

In den folgenden Abschnitten werden Beispieltests verwendet, um die vorgestellten Funktionalitäten von pytest näher zu erläutern. Die Tests nutzen zunächst die folgende Beispielfunktion:

# data_transformation.py
from datetime import timedelta

import pandas as pd
import numpy as np


def calculate_time_difference_in_months(
    df: pd.DataFrame,
    identifier: str = "id",
    start: str = "start",
    stop: str = "stop",
    dt_format: str = "%d.%m.%Y",
) -> pd.DataFrame:
    """
    Calculates the number of months between two dates

    Args:
        df (pd.DataFrame): Input DataFrame
        identifier (str): Name of column with unique identifiers
        start (str): Name of column containing start date
        stop (str): Name of column with stop date
        dt_format (str): Format of datetimes

    Returns: New DataFrame with identifier and time_span_months column

    >>> data = [
    ...     {"id": 1, "start": "01.02.2022", "stop": "28.02.2022"},
    ...     {"id": 2, "start": "01.01.2022", "stop": "31.03.2022"},
    ...     {"id": 3, "start": "05.04.2022", "stop": "04.04.2023"},
    ... ]
    >>> calculate_time_difference_in_months(pd.DataFrame(data))
       id time_span_months
    0   1                0
    1   2                2
    2   3               11
    """
    start = pd.to_datetime(df[start], format=dt_format)
    stop = pd.to_datetime(df[stop], format=dt_format)

    time_span_months = pd.Series(
        (stop-start) / np.timedelta64(1, "M"),
        name="time_span_months"
    ).astype(int)
    return pd.concat([df[[identifier]], time_span_months], axis=1)

In dieser Funktion wird die Anzahl von Monaten zwischen allen Start- und Enddaten berechnet und in einer neuen Spalte abgespeichert. Um sicherzustellen, dass die Datumsangaben das richtige Format haben, werden sie zuvor in ein pandas-Datumsformat umgewandelt.

Exkurs: Wie vergleiche ich Series, DataFrames und Indizes?

In der Data Science arbeitet man viel mit DataFrames, dementsprechend ist es wichtig, sie bei Tests gut vergleichen zu können. Besonders relevant ist dabei, die Vergleichsgenauigkeit der Werte festlegen zu können, sowie eine verständliche Übersicht der Unterschiede zu bekommen.

Die Testfunktionen von pandas und numpy bieten diese Funktionalitäten und finden daher viel Anwendung in der Praxis [4]: z. B. pd.testing.assert_frame_equal(), das mit pd.DataFrames arbeitet, pd.testing.assert_series_equal(), das mit pd.Series arbeitet, oder auch np.testing.assert_all_close() für array-ähnliche Strukturen.

Doctests für getestete Mini-Beispiele in der Dokumentation

Neben dem Schreiben eigener Testfunktionen ist es möglich, Codebeispiele in Docstrings zu schreiben. So können andere Entwickler:innen sofort in der Dokumentation sehen, wie der Code verwendet werden soll. Es ist allerdings unpraktisch, wenn diese Codebeispiele veraltet oder nicht funktionstüchtig sind. Mit doctest kann man sie ausführen und testen, ob sie funktionieren [5].

Doctests für eine einzelne Datei, z. B. für die vorgestellte Beispielfunktion, können mittels python -m doctest data_transformation.py ausgeführt werden. Ist die Datei Teil eines Pakets, empfiehlt es sich, die Doctests in einen Unittest umzuwandeln:

# test_data_transformation.py
import doctest

import data_transformation


def test_docstring():
   doctest_results = doctest.testmod(m=data_transformation)
   assert doctest_results.failed == 0

Alternativ kann pytest die Doctests mit pytest --doctest-modules ausführen [6]. Eine weitere Möglichkeit ist, das Ausführen der Doctests in einer pytest.ini-Datei festzulegen [7]:

addopts = --doctest-modules

Pytest-Parametrize für DRY-Code

Nun zum ersten pytest-Feature: der Decorator pytest.mark.parametrize ermöglicht, Tests mehrfach mit unterschiedlichen Variablen auszuführen [8]. Dieser nimmt zwei Listen als Argumente:

  1. die Variablennamen (1), die in der Testsignatur (3) verwendet werden müssen, und
  2. die einzelnen Testszenarien (2).

Die Testszenarien können entweder alle als Tupel definiert werden oder mit pytest.param() (s. Beispiel), sodass eine ID vergeben werden kann, die im Verbose-Modus (pytest -v) angezeigt wird. Ein parametrisierter Test für die oben beschriebene Funktion kann wie folgt aussehen:

# test_data_transformation.py
import pandas as pd
import pytest

from data_transformation import calculate_time_difference_in_months


@pytest.mark.parametrize(
    ["input_data", "expected_data"],  # (1)
    [  # (2)
        pytest.param(
            [{"id": 1, "start": "01.02.2022", "stop": "28.02.2022"}],
            [{"id": 1, "time_span_months": 0}],
            id="Normal DataFrame: same month",
        ),
        pytest.param(
            [{"id": 2, "start": "01.01.2022", "stop": "30.03.2022"}],
            [{"id": 2, "time_span_months": 2}],
            id="Normal DataFrame: 2 Months",
        ),
        pytest.param(
            [{"id": 4, "start": "01.01.2022", "stop": "31.12.2021"}],
            [{"id": 4, "time_span_months": 0}],
            id="Stop before start",
        ),
    ],
)
def test_time_difference_in_months(input_data, expected_data):  # (3)
    df = pd.DataFrame(input_data)
    expected = pd.DataFrame(expected_data)
    actual = calculate_time_difference_in_months(df)
    pd.testing.assert_frame_equal(expected, actual)

In diesem Beispiel wird die zu testende Funktion für jedes der drei parametrisierten DataFrames ausgeführt. Sollte einer der Fälle fehlschlagen, wird dies zusammen mit der ID direkt ausgegeben. Außerdem muss im Fall von Änderungen am Funktionsaufruf nur eine Stelle angepasst werden.

Das Testergebnis für diese Testfunktion sieht in der Kommandozeile wie folgt aus. In diesem Fall sind alle definierten Tests durchgelaufen.

pytest -v
========================= test session starts =========================
[...platform and version information....]
collected 3 items

tests/test_data_transformation.py::test_time_difference_in_months
    [Normal DataFrame: same month] PASSED                         [ 33%]
tests/test_data_transformation.py::test_time_difference_in_months
    [Normal DataFrame: 2 Months] PASSED                           [ 66%]
tests/test_data_transformation.py::test_time_difference_in_months
    [Stop before start] PASSED                                    [100%]

Pytest-Fixtures für DRY-Code und einen eindeutigen Testzustand

Fixtures (pytest.fixtures) sind eine weitere Möglichkeit, um den Testcode möglichst DRY zu halten. Fixtures sind beispielsweise sinnvoll zum Testen von Methoden einer Klasse, die immer gleich instanziiert wird. Dadurch muss der Instanziierungs-Code nicht in jedem Test wiederholt werden. Alternativ können Fixtures Daten für verschiedene Tests zur Verfügung stellen.

Ein Fixture ist eine Funktion, die vor einem Test ausgeführt wird. Diese kann Rückgabewerte haben, wie zum Beispiel ein Klassenobjekt, oder mittels yield Setup- und Teardown-Code enthalten [9]. Fixtures werden mit dem entsprechenden Decorator definiert und dem Namen der Funktion referenziert. pytest prüft beim Ausführen einer Testfunktion, ob es für die angegebenen Eingangsparameter Fixtures mit exakt diesem Namen gibt.

Im folgenden Beispiel wird zuerst das Fixture data definiert (1) und anschließend in der Testfunktion verwendet (2). Das gleiche Fixture kann für weitere Tests verwendet werden.

# conftest.py
import pandas as pd
import pytest


@pytest.fixture(scope="function")
def data():  # (1)
    return pd.DataFrame([
        {"id": 1, "start": "01.02.2022", "stop": "28.02.2022"},
        {"id": 2, "start": "01.01.2022", "stop": "31.03.2022"},
        {"id": 3, "start": "05.04.2022", "stop": "04.04.2023"},
    ])
# test_data_transformation.py
def test_time_difference_in_months(data):  # (2)
    expected = pd.DataFrame(
        [
            {"id": 1, "time_span_months": 0},
            {"id": 2, "time_span_months": 2},
            {"id": 3, "time_span_months": 11},
        ]
    )
    actual = calculate_time_difference_in_months(data)
    pd.testing.assert_frame_equal(expected, actual)

Der scope eines Fixtures definiert, wie häufig diese Funktion ausgeführt wird. So bedeutet der Defaultwert scope = "function", dass das Fixture vor jeder einzelnen Testfunktion ausgeführt werden soll. Alternativen sind class, module, package und session. Der Scope session bezieht sich auf die gesamte Testsession, sodass dabei erzeugte Objekte für alle Tests wiederverwendet werden. Dies ergibt besonders dann Sinn, wenn der auszuführende Fixture-Code zeitaufwändig ist.

conftest.py, um Fixtures über Test-Files hinweg wiederzuverwenden

Vor allem bei größeren Data-Science-Projekten mit viel Code in mehreren Dateien ist es sinnvoll über den Einsatz einer conftest.py nachzudenken. Das Ziel der conftest.py ist, Fixtures über Python-Dateien hinweg zugänglich zu machen. Dieses Feature ist in der Data Science vor allem hilfreich, um z. B. Testdaten zu erstellen, die man über verschiedene Testdateien hinweg nutzen kann. In den Testdateien ist dabei kein expliziter Import der Fixtures nötig. Dies übernimmt pytest im Hintergrund. Damit die conftest.py für alle Test-Dateien im Projekt nutzbar ist, sollte die Datei-Hierarchie folgendermaßen aussehen:

project
│   data_transformation.py
│   pipeline.py    
│
└───tests
│   │   conftest.py
│   │   test_data_transformation.py
│   │   test_pipeline.py

Es ist auch möglich, eine conftest.py in Unterordnern abzulegen. Dann gelten die dort definierten Fixtures allerdings nur für die Tests in den jeweiligen Unterordnern.

Exkurs: Testdaten in menschenlesbarem Format, nah an den Tests

Da in der Data Science viel mit DataFrames hantiert wird, sind auch bei den Tests fast immer Daten notwendig. Um Tests schnell verstehen zu können, ist es daher hilfreich, wenn die verwendeten Daten in einem menschenlesbaren Format sind und in der Nähe der Tests liegen. Das bedeutet, dass die Testdaten entweder direkt in einer Python-Datei definiert sind oder in lesbaren Formaten wie .csv oder .json (im Gegensatz zu parquet) abgespeichert sind.
Daten liegen in der Nähe der Tests, wenn sie im Projektordner liegen, sodass die Tests immer ausgeführt werden können und nicht von externen Datenquellen abhängen.

pytest-Raises zur Überprüfung des Verhaltens im Fehlerfall

Tests überprüfen das erwartete Verhalten. Verhält sich die getestete Einheit anders, so schlägt der Test fehl; verhält sie sich wie erwartet, läuft der Test durch. Wird also eine Fehlermeldung erwartet, soll der Test durchlaufen, obwohl die aufgerufene Funktion einen Fehler wirft. Dies wird mit dem Kontextmanager pytest.raises() oder dem Decorator pytest.mark.raises aus der Extension pytest-raises erreicht [10]:

# test_data_transformation.py
def test_time_difference_in_months_with_invalid_date_format_should_raise_error():
    df_invalid_date_format = pd.DataFrame(
        [{"id": 1, "start": "01.02.22", "stop": "28.02.2022"}]
    )
    with pytest.raises(ValueError) as excinfo:  # (1)
        calculate_time_difference_in_months(df_invalid_date_format)
    assert (
        "time data '01.02.22' does not match format '%d.%m.%Y' (match)"
        in str(excinfo.value)
    )

In diesem Fall wird einer der built-in-Fehlertypen – ein ValueError – erwartet (1) und nur wenn calculate_time_difference_in_months diesen wirft, läuft die Testfunktion fehlerfrei durch [11]. Außerdem wird in diesem Test überprüft, ob die ausgegebene Fehlermeldung korrekt ist. Eine Überprüfung davon ist nicht zwingend sinnvoll, sondern in diesem Fall eher ein Beispiel dafür, was und wie es möglich ist.

Mit dem oben genannten Decorator sieht der gleiche Test wie folgt aus:

# test_data_transformation.py

@pytest.mark.raises(
    exception=ValueError, match="time data '01.02.22' does not match format"
)
def test_time_difference_in_months_with_invalid_date_format_should_raise_error():
    df_invalid_date_format = pd.DataFrame(
        [{"id": 1, "start": "01.02.22", "stop": "28.02.2022"}]
    )
    calculate_time_difference_in_months(df_invalid_date_format)

Der Nachteil an dieser Variante ist, dass man nicht genau definieren kann, welche Codezeile der Testfunktion den Fehler werfen darf.

Lösen der Abhängigkeit von anderen Objekten in Tests

Die Funktion im bisher vorgestellten Beispiel hat keine Abhängigkeiten und kann somit direkt getestet werden. Gibt es im Code Abhängigkeiten von anderen Objekten oder Funktionen, wird es schwieriger, die eigentliche Funktion zu testen. Abhängigkeiten entstehen durch Funktions- oder Methodenaufrufe in unserem zu testenden Code. Dabei wird der zu testende Code "SUT" (System under test = System, das getestet wird) und die andere Komponente "DOC" (Depended-on component = Komponente, von der das SUT abhängt) genannt. Man unterscheidet bei den Abhängigkeiten zwischen Eingabe- und Ausgabeabhängigkeiten:

  • Bei Eingabeabhängigkeiten weist das SUT je nach Rückgabewert/Verhalten der DOC ein anderes Verhalten auf. Sie können daran erkannt werden, dass eine Abzweigung im Code nicht getestet ist.
  • Bei Ausgabeabhängigkeiten ist das Ergebnis unseres SUT ein Funktionsaufruf/eine Zustandsänderung der DOC. Sie können zu unerwünschten Nebeneffekten in Tests führen, wie z. B. Veränderungen in der Datenbank.

Im Folgenden ist ein Codebeispiel für derartige Abhängigkeiten:

# pipeline.py
from google.api_core.exceptions import GoogleAPIError
import pandas as pd
from pydantic import BaseSettings

from data_transformation import calculate_time_difference_in_months


class BQConfig(BaseSettings):  # (1)
    """Config defining a BigQuery table."""
    project_id: str
    dataset_id: str
    table_name: str


class BQDatabase:
    """Class to handle data in Google's data warehouse BigQuery."""
    
    def load(self, config: BQConfig) -> pd.DataFrame:  # (2)
        """Loads data.
        
        Args:
            config (BQConfig): Defines the table to load from.
        
        Returns: Data as DataFrame.
        
        Raises:
            IOError: If a `GoogleAPIError` occurs.
        """
        SQL = f"""
            SELECT id, start, stop
            FROM {config.project_id}.{config.dataset_id}.{config.table_name}
        """
        try:
            return pd.read_gbq(SQL)  # (3)
        except GoogleAPIError as e:  # (4)
            print(e)
            raise IOError("Could not load data from BigQuery")
    
    def save(df: pd.DataFrame, config: BQConfig):   # (5)
        """Saves data.
        
        Args:
            df (pd.DataFrame): The data to save.
            config (BQConfig): Defines the table to save to.
        """
        # Implementation


def preprocess(database: BQDatabase, config_in: BQConfig,
               config_out: BQConfig):  # (6)
    """Examplary preprocessing step.
    
    Args:
        database (Database): The database holding the data.
        config_in (BQConfig): Config for the input-table.
        config_out (BQConfig): Config for the output-table.
    """
    df = database.load(config_in)
    features = calculate_time_difference_in_months(df)
    database.save(features, config_out)  # (7)

Eine Konfigurationsklasse (1) wird verwendet, damit die Signaturen der Methoden load (2), save (5) und preprocess (6) lesbarer und wartbarer sind. Die load-Funktion hat eine Eingabeabhängigkeit beim Lesen der Daten aus BigQuery (3): Je nach Verhalten von pd.read_gbq wird der except-Pfad ausgeführt oder nicht.  Die preprocess-Methode hat eine Ausgabeabhängigkeit beim Speichern der Daten (7): Sie verändert den Zustand der Datenbank.

Bei derartigen Abhängigkeiten helfen sogenannte Test-Doppelgänger, die die DOC ersetzen. Durch Doppelgänger kann der Zustand der DOC zu Beginn des Tests kontrolliert oder auch Nebeneffekte verhindert werden. Außerdem kann die Testgeschwindigkeit verbessert werden, indem zeitaufwändige Vorgänge, wie Input-/Output-Operationen ersetzt werden.

Bei der Wahl des konkreten Test-Doppelgängers scheiden sich die Geister. Es gibt zwei verschiedene Ansätze, Code zu testen:

  • Beim "klassischen Stil" (auch Detroit-TDD) wird bevorzugt mit Fakes gearbeitet. Fakes sind komplett funktionstüchtig implementiert, jedoch stark vereinfacht und können daher nicht direkt in Produktionscode verwendet werden. Tests vergleichen hier vor allem Rückgabewerte und Zustände.
  • Beim "mockist-Stil" (auch London-School-TDD) werden Mocks favorisiert. Mocks speichern alle Funktionsaufrufe ab und können mit Rückgabewerten und Nebeneffekten versehen werden. Hier werden Funktionsaufrufe, also das Verhalten von Objekten, überprüft.

Vergleicht man die beiden Ansätze, so wird am "klassischen Stil" kritisiert, dass man Fakes implementieren muss und dadurch zusätzlicher Code zum Warten entsteht. Am "mockist-Stil" wird beanstandet, dass man mit den Tests bis ins kleinste Detail festlegt, wie die Implementierung aussieht; verändert man Kleinigkeiten am Code, brechen die Tests und man muss sie anpassen. Martin Fowler diskutiert unter anderem die Vor- und Nachteile der Stile ausführlich [12]. Neben Fakes und Mocks gibt es weitere Test-Doppelgänger, die jeweils für unterschiedliche Zwecke verwendet werden. Für interessierte Leser:innen liefert Gerard Meszaros eine detaillierte Beschreibung [13].

Es müssen also einige Entscheidungen getroffen werden, um den zuvor beschriebenen Code testen zu können. Edwin Jung hat diese in einem Vortrag auf den Punkt gebracht [14]:

  1. Welche Test-Doppelgänger?
  2. Mockist or klassischer Stil?
  3. Patchen oder Dependency Injection?

Die verschiedenen Optionen dieser Auflistung können beliebig kombiniert werden, wodurch es mehrere Varianten gibt. Um die Unterschiede anhand von Code zu zeigen, wird es ein Beispiel für Patching und Mocks und ein zweites für Fake und Dependency Injection geben. Man hätte aber auch andere Kombinationen wählen können.

Patching und Dependency Injection

Um die nachfolgenden Beispiele verstehen zu können, müssen die Begriffe Patchen und Dependency Injection eingeführt werden. Beides wird verwendet, um in einem Test ausschließlich die Funktionalität des eigenen Codes zu testen.

Beim Patching wird vorübergehend das Objekt verändert, auf das ein Funktions- oder Methodenname zeigt. Dadurch wird beim Aufrufen des gepatchten Codes anderer Code ausgeführt. Patching wird benötigt, wenn bei einem Funktionsaufruf kein Objekt übergeben wird, sondern in einer Methode verkapselt eine Abhängigkeit aufgerufen wird, wie oben in der load-Funktion der Aufruf von pd.read_sql.

Im Gegensatz dazu wird bei der Dependency Injection die Abhängigkeit als Parameter der Funktion definiert, sodass sie beim Aufrufen der Funktion explizit übergeben wird. Wendet man dies auf die load-Funktion an, würde sie folgendermaßen aussehen, wobei in (1) der sql_reader als Parameter definiert und in (2) aufgerufen wird:

from collections.abc import Callable
# other imports as above

class Database:
    """Class to handle our data."""

    def load(self, config: BQConfig, sql_reader: Callable[[str],
             pd.DataFrame]) -> pd.DataFrame:  # (1)
        """Load the data.
        
        Args:
            config (BQConfig): Defines the table to load.
            sql_reader (Callable): Function reading data according to
            an SQL query.
        
        Returns: The data as DataFrame.
        
        Raises:
            IOError: If a `GoogleAPIError` occurs.
        """
        SQL = f"""
            SELECT id, start, stop
            FROM {config.project_id}.{config.dataset_id}.{config.table_name}
        """
        try:
            return sql_reader(SQL)  # (2)
        except GoogleAPIError as e:
            print(e)
            raise IOError("Could not load data from BigQuery")

Eine kurze Einführung in unittest.mock.Mock

Des Weiteren ist ein grobes Verständnis von Mocks wichtig für die Beispiele. Mit unittest.mock.Mock wird eine Implementierung von Mocks direkt mit Python mitgeliefert. Diesen Mock-Objekten kann man Verhalten zuweisen, indem man Rückgabewerte oder Nebeneffekte definiert (über die Attribute return_value bzw. side_effect). Außerdem haben Mock-Objekte verschiedene weitere Attribute (z. B. called, call_count, call_args) und Funktionen (assert_not_called(), assert_called_once()), mit denen – meist am Ende eines Tests – das Verhalten überprüft werden kann. Weitere Details liefert die Dokumentation [15].

Lösen der Eingabeabhängigkeit mit Hilfe von Patching und Mocks aus pytest-mock

Zunächst sollen die Eingabeabhängigkeiten aus obigem Beispiel aufgelöst werden. Um die load-Funktion vollständig testen zu können, werden zwei Testszenarien benötigt. Im ersten (2) wird überprüft, ob die load-Funktion das DataFrame lädt und unverändert zurückgibt und im zweiten (7), ob load im Falle eines GoogleAPIErrors einen IOError wirft. Dafür muss der Rückgabewert von pd.read_sql kontrolliert werden. Dies kann mit einem Mock-Objekt aus dem Package pytest-mock umgesetzt werden [16]. pytest-mock verwendet im Hintergrund unittest.mock.

Zurück zum Beispiel: Da load im Code von pd.read_sql abhängt und diese Funktion nicht als Argument oder Attribut gesetzt werden kann, muss dieser Funktionsaufruf gepatcht werden:

# test_pipeline.py
from google.api_core.exceptions import GoogleAPIError
import pandas as pd
import pytest
from unittest.mock import patch

from pipeline import BQConfig, BQDatabase


@pytest.fixture(scope="module")
def config() -> BQConfig:  # (1)
    return BQConfig(
        project_id="test-project",
        dataset_id="test_dataset",
        table_name="test_table",
    )


# (2)
def test_load_should_only_load(config, data, mocker):
    mock_read_gbq = mocker.patch(
        "pandas.read_gbq", return_value=data, autospec=True
    )  # (3)
    actual = BQDatabase().load(config)  # (4)
    pd.testing.assert_frame_equal(data, actual)  # (5)
    mock_read_gbq.assert_called_once()  # (6)


# (7)
def test_load_should_fail_on_BQ_errors(config, mocker):
    mocker.patch(
        "pandas.read_gbq", side_effect=GoogleAPIError, autospec=True
    )  # (8)

    with pytest.raises(IOError) as excinfo:  # (9)
        BQDatabase().load(config)
    assert "Could not load data from BigQuery" in str(
        excinfo.value
    )  # (10)


# (11)
@pytest.mark.raises(
    exception=IOError, match="Could not load data from BigQuery"
)
@patch("pandas.read_gbq", side_effect=GoogleAPIError)
def test_load_should_fail_with_decorators(mock_read, config):
    BQDatabase().load(config)

Zuerst wird die Klasse BQConfig (1) als Fixture definiert, sodass sie in allen Tests verfügbar ist. Das erste Testszenario (2) verwendet die Fixtures config, data (definiert in der Fixture-Einführung) und mocker (aus pytest-mock). Mit mocker.patch (3) wird pandas.read_gbq in der Testfunktion durch ein Mock-Objekt ersetzt. Außerdem wird das data-Fixture als Rückgabewert dieses Mock-Objekts definiert. Wird nun pd.read_gbq aufgerufen, so wird der DataFrame data zurückgegeben. Anschließend wird der eigentliche Test ausgeführt: Die load-Funktion zum Datenladen wird aufgerufen (4). Nun kann man überprüfen, ob der DataFrame vom Funktionsaufruf identisch ist zu dem des Fixtures (5) und ob die gepatchte Funktion einmal aufgerufen wurde (6). Durch die Überprüfung auf dem Mock-Objekt wird das Verhalten, also der konkrete Funktionsaufruf, der load_data-Funktion getestet.

Um sicherzustellen, dass die load-Funktion im Fall eines GoogleAPIErrors einen IOError wirft, benötigt das zweite Testszenario (7) nur das config und ein Mock-Objekt. Der Download von BigQuery schlägt im Test fehl, indem die read_gbq-Funktion erneut gepatcht und als Nebeneffekt ein GoogleAPIError zugewiesen wird (8). Im nächsten Schritt (9) wird der erwartete Fehler (IOError) in einem Kontextmanager definiert. Abschließend kann die Funktion aufgerufen und die Nachricht der Fehlermeldung kontrolliert werden (10). Wird durch den Aufruf von load kein IOError geworfen, so schlägt der Test fehl.

Der letzte Test (11) ist identisch zum zweiten (7); jedoch komplett mit Decoratoren geschrieben.

Lösen der Ausgabeabhängigkeit mit Hilfe von Dependency Injection und Fake

Nun soll noch die Ausgabeabhängigkeit der oben genannten preprocess-Funktion getestet werden. Im bisherigen Design der Funktion kann die Datenbank – die Abhängigkeit – bereits als Parameter übergeben werden. Insofern ist die Dependeny Injection bereits vorhanden. Um die Datenbank im Test ersetzen zu können, wird daher noch eine Fake-Datenbank benötigt:

# test_pipeline.py
class InMemoryDatabase:
    """Class to handle our data in Memory."""

    def __init__(self):
        self.database = {}
    
    def load(self, config: BQConfig) -> pd.DataFrame:
        try:
            return self.database[config.project_id][config.dataset_id][config.table_name]
        except KeyError:
            return pd.DataFrame()
    
    def save(self, df: pd.DataFrame, config: BQConfig) -> None:
        dataset = self.database.setdefault(config.project_id, {}).setdefault(
            config.dataset_id, {}
        )
        dataset[config.table_name] = df

Diese InMemoryDatabase speichert die Ergebnisse in einem Dictionary und funktioniert daher für kleinere DataFrames in Tests einwandfrei, ist jedoch für den produktiven Einsatz nicht geeignet.

Der zugehörige Test sieht dann folgendermaßen aus:

# test_pipeline.py
from pipeline import preprocess

def test_preprocess_should_write_to_database(data):
    # Arrange
    database = InMemoryDatabase()  # (1)
    config_in = BQConfig(
        project_id="test-project",
        dataset_id="test_dataset",
        table_name="test_table_input",
    )
    config_out = BQConfig(
        project_id="test-project",
        dataset_id="test_dataset",
        table_name="test_table_output",
    )
    database.save(data, config_in)  # (2)
    expected = pd.DataFrame(  # (3)
        [
            {"id": 1, "time_span_months": 0},
            {"id": 2, "time_span_months": 2},
            {"id": 3, "time_span_months": 11},
        ]
    )

    # Act
    preprocess(database, config_in, config_out)  # (4)

    # Assert
    pd.testing.assert_frame_equal(
        database.load(config_out), expected
    )  # (5)

Im Arrange-Schritt werden eine Fake-Datenbank erzeugt (1) und die Input-Daten abgespeichert (2). Außerdem wird das erwartete Ergebnis definiert (3). Im nächsten Schritt wird die eigentliche Funktion aufgerufen (4), sodass abschließend der Zustand der Datenbank überprüft werden kann (5).

Diese beiden Beispiele sollen verdeutlichen, wie Patching und Dependency Injection sowie Mocks und Fakes dabei helfen, Abhängigkeiten im Code zu lösen. Für welche der Varianten man sich entscheidet, ist Geschmackssache.

Tests in Machine-Learning-Projekten

Dieser Artikel hat verschiedene Arten von klassischen Unittests vorgestellt. Doch für welche Zwecke kann man diese im Rahmen von Machine-Learning-Projekten verwenden? Beispielhafte Bereiche für Tests sind:

  • Tests, die jegliche Schritte der Datenvorverarbeitung testen (Transformation und Reinigung von Daten),
  • Tests, die den Code eines Modells testen, z. B. inwiefern die Gewichte eines neuronalen Netzes nach einer Trainingsiteration angepasst werden,
  • Tests, die eigens implementierte Evaluationsfunktionen oder Metriken testen,
  • Tests, die Postprocessing-Schritte des Modells testen, z. B. ob Grenzwerte richtig auf den Modell-Output angewendet werden
  • u. v. m.

Auch für diese Tests gelten alle oben aufgeführten "Best Practices" – sie unterscheiden sich lediglich in ihrem Anwendungsgebiet von klassischen Unittests in der Softwareentwicklung.

Exkurs: Inhaltliche Modell-Tests

Während es bei den zuvor vorgestellten Tests darum geht, die implementierte Logik im Zusammenhang mit dem Modell zu testen, geht es bei inhaltlichen Modell-Tests darum, das Verhalten des Modells zu überprüfen. Das Ziel inhaltlicher Tests ist, das Modell-Verhalten in gewissen (Rand-)Situationen sowie die verwendeten Daten zu testen. Beispiele dafür sind:

  • Testen des Modell-Verhaltens bei Extremwerten in den Daten,
  • Überwachen von Metriken in der Validierungsphase,
  • Testen der Modell-Robustheit in verschiedenen Randsituationen, wie z. B. fehlende Daten,
  • Überprüfen, wie sich Modell-Vorhersagen bei einem Concept Drift, also der Änderung der statistischen Eigenschaften der Zielvariable, verändern,
  • Testen der Modell-Performance, während ein Modell in Produktion läuft,
  • u. v. m.

Da Modell-Tests noch ein sehr junges und wenig verbreitetes Thema sind, gibt es in diesem Bereich auch noch keine eindeutigen "Best Practices". Klar ist, dass Modell-Tests zu jedem in Produktion laufenden Modell dazugehören sollten. Bezüglich der Tiefe und des Detailgrads dieser Tests werden aktuell noch Erfahrungswerte gesammelt. Das Testen und Validieren von verwendeten Daten ist ebenfalls wichtig, aber ein Thema für einen eigenen Artikel. Beliebte Tools sind beispielsweise:

  • evidently, um die Modellperformance von der Validierung bis hin zum produktiven Einsatz zu testen [17], oder
  • pydantic, Great Expectations und pandera zum Testen und Validieren der verwendeten Daten [18].

Zusammenfassung

Wie der Artikel zeigen konnte, ist es sinnvoll, auch in der Data Science Unittests einzusetzen, um unnötigen Fehlern vorzubeugen und Code auf eine gewünschte Funktionalität hin zu testen. Es wurden verschiedene Anwendungsfälle und Implementierungsdetails für Unittests vorgestellt. Welche Vorgehensweise in welchem Fall die beste ist, muss individuell und teilweise nach eigenen Vorstellungen entschieden werden.

Unserer Meinung nach ist es in der Data Science nicht zielführend, eine vollständige Testabdeckung anzustreben, sondern Unittests an sinnvollen Punkten einzusetzen. Dabei sollte neben technischen Details, wie Datentransformationen und eigenen Evaluationsfunktionen, auch die inhaltliche Korrektheit von Machine-Learning-Modellen getestet werden.

Autorinnen

Alina Dallmann

Alina Dallmann absolvierte nach einem dualen Bachelorstudium der Wirtschaftsinformatik in der Energiewirtschaft einen Informatik-Master an der TU Darmstadt.
>> Weiterlesen

Florence Lopez

Florence Lopez arbeitet als Data Scientist bei scieneers in Karlsruhe und wirkte dort sowohl an einem Non-Profit-Projekt über Citizen Science mit, als auch in verschiedenen Kundenprojekten.
>> Weiterlesen

Lena Trautmann

Lena Trautmann arbeitet als Data Scientist bei scieneers an verschiedenen Projekten, z.B. mit NLP-Bezug oder im Bereich e-Commerce.
>> Weiterlesen
Das könnte Sie auch interessieren
Kommentare (0)

Neuen Kommentar schreiben