Kapitel 4. Testen

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

Die meisten Tests innerhalb von Anwendungen bestehen sowohl aus Unit- als auch aus Funktionstests. Mit SQLAlchemy kann es jedoch viel Arbeit sein, eine Abfrageanweisung oder ein Modell für Unit-Tests korrekt zu mocken. Diese Arbeit führt oft nicht zu einem wirklichen Gewinn gegenüber dem Testen gegen eine Datenbank während des Funktionstests. Das führt dazu, dass die Leute Wrapper-Funktionen für ihre Abfragen erstellen, die sie während der Unit-Tests einfach abbilden können, oder dass sie sowohl in den Unit- als auch in den Funktionstests nur gegen eine Datenbank testen. Ich persönlich verwende gerne kleine Wrapper-Funktionen, wenn es möglich ist, oder - wenn das aus irgendeinem Grund keinen Sinn macht oder ich mich in veraltetem Code befinde - Mockout-Funktionen.

In diesem Kapitel erfährst du, wie du funktionale Tests mit einer Datenbank durchführst und wie du SQLAlchemy-Abfragen und -Verbindungen mockst.

Testen mit einer Testdatenbank

Für unsere Beispielanwendung benötigen wir eine app.py-Datei, die unsere Anwendungslogik enthält, und eine db.py-Datei, die unsere Datenbanktabellen und -verbindungen enthält. Diese Dateien findest du im Ordner CH05/ des Beispielcodes.

Wie eine Anwendung strukturiert ist, ist ein Implementierungsdetail, das einen großen Einfluss darauf haben kann, wie du deine Tests durchführen musst. In db.py kannst du sehen, dass unsere Datenbank über die Klasse DataAccessLayer eingerichtet wird. Wir verwenden diese Datenzugriffsklasse, um ein Datenbankschema zu initialisieren und es mit einer Engine zu verbinden, wann immer wir wollen. Dieses Muster wird häufig in Web-Frameworks in Verbindung mit SQLAlchemy verwendet. Die Klasse DataAccessLayer wird ohne eine Engine und eine Verbindung in der Variable dal initialisiert. Beispiel 4-1 zeigt einen Ausschnitt aus unserer db.py-Datei.

Beispiel 4-1. DataAccessLayer-Klasse
from datetime import datetime
from sqlalchemy import (MetaData, Table, Column, Integer, Numeric, String,
                        DateTime, ForeignKey, Boolean, create_engine)


class DataAccessLayer:
    connection = None
    engine = None
    conn_string = None
    metadata = MetaData()
    cookies = Table('cookies',
                    metadata,
                    Column('cookie_id', Integer(), primary_key=True),
                    Column('cookie_name', String(50), index=True),
                    Column('cookie_recipe_url', String(255)),
                    Column('cookie_sku', String(55)),
                    Column('quantity', Integer()),
                    Column('unit_cost', Numeric(12, 2))
              ) 1

    def db_init(self, conn_string): 2
        self.engine = create_engine(conn_string or self.conn_string)
        self.metadata.create_all(self.engine)
        self.connection = self.engine.connect()

dal = DataAccessLayer() 3
1

In der vollständigen Datei erstellen wir alle Tabellen, die wir seit Kapitel 1 verwendet haben, nicht nur die Cookies.

2

Damit kannst du eine Verbindung mit einem bestimmten Verbindungsstring wie eine Fabrik initialisieren.

3

So entsteht eine Instanz der Klasse DataAccessLayer, die in unserer gesamten Anwendung importiert werden kann.

Wir werden Tests für die Funktion get_orders_by_customer schreiben, die wir in Kapitel 2 gebaut haben und die in der Datei app.py zu finden ist (siehe Beispiel 4-2).

Beispiel 4-2. app.py zum Testen
from db import dal 1
from sqlalchemy.sql import select




def get_orders_by_customer(cust_name, shipped=None, details=False):
    columns = [dal.orders.c.order_id, dal.users.c.username, dal.users.c.phone]
    joins = dal.users.join(dal.orders) 2

    if details:
        columns.extend([dal.cookies.c.cookie_name,
                        dal.line_items.c.quantity,
                        dal.line_items.c.extended_cost])
        joins = joins.join(dal.line_items).join(dal.cookies)

    cust_orders = select(columns)
    cust_orders = cust_orders.select_from(joins).where(
        dal.users.c.username == cust_name)

    if shipped is not None:
        cust_orders = cust_orders.where(dal.orders.c.shipped == shipped)

    return dal.connection.execute(cust_orders).fetchall()
1

Dies ist unsere DataAccessLayer Instanz aus der Datei db.py .

2

Da sich unsere Tabellen innerhalb des dal Objekts befinden, greifen wir von dort aus auf sie zu.

Schauen wir uns alle Möglichkeiten an, wie die Funktion get_orders_by_customer verwendet werden kann. Für diese Übung gehen wir davon aus, dass wir bereits überprüft haben, dass die Eingaben für die Funktion vom richtigen Typ sind. Bei deinen Tests solltest du jedoch darauf achten, dass du mit Daten testest, die korrekt funktionieren, und mit Daten, die Fehler verursachen könnten. Hier ist eine Liste der Variablen, die unsere Funktion akzeptieren kann, und ihrer möglichen Werte:

  • cust_name kann leer sein, eine Zeichenkette, die den Namen eines gültigen Kunden enthält, oder eine Zeichenkette, die nicht den Namen eines gültigen Kunden enthält.

  • shipped kann None, True, oder False sein.

  • details kann True oder False sein.

Wenn wir alle möglichen Kombinationen testen wollen, brauchen wir 12 (also 3 * 3 * 2) Tests, um diese Funktion vollständig zu testen.

Hinweis

Es ist wichtig, nicht Dinge zu testen, die nur Teil der Grundfunktionalität von SQLAlchemy sind, da SQLAlchemy bereits über eine große Sammlung gut geschriebener Tests verfügt. Wir wollen zum Beispiel keine einfachen Insert-, Select-, Delete- oder Update-Anweisungen testen, da diese bereits im SQLAlchemy-Projekt selbst getestet werden. Stattdessen solltest du Dinge testen, die dein Code verändert und die sich auf die Ausführung der SQLAlchemy-Anweisung oder die Ergebnisse auswirken könnten, die sie liefert.

Für dieses Testbeispiel auf verwenden wir das integrierte Modul unittest. Mach dir keine Sorgen, wenn du mit diesem Modul nicht vertraut bist; wir werden dir die wichtigsten Punkte erklären. Zuerst müssen wir die Testklasse einrichten und die Verbindung von dalinitialisieren, wie in Beispiel 4-3 gezeigt, indem wir eine neue Datei namens test_app.py erstellen.

Beispiel 4-3. Einrichten der Tests
import unittest

class TestApp(unittest.TestCase): 1

    @classmethod
    def setUpClass(cls): 2
        dal.db_init('sqlite:///:memory:') 3
1

unittest erfordert Testklassen, die von unittest.TestCase geerbt werden.

2

Die Methode setUpClass wird einmal für die gesamte Testklasse ausgeführt.

3

Diese Zeile initialisiert eine Verbindung zu einer In-Memory-Datenbank für Tests.

Jetzt müssen wir einige Daten laden, die wir für unsere Tests verwenden wollen. Ich werde hier nicht den vollständigen Code einfügen, da es sich um dieselben Einfügungen handelt, mit denen wir in Kapitel 2 gearbeitet haben, nur modifiziert, um DataAccessLayer zu verwenden; er ist in der Beispieldatei db.py verfügbar. Nachdem wir unsere Daten geladen haben, können wir nun einige Tests schreiben. Wir fügen diese Tests als Funktionen in die Klasse TestApp ein, wie in Beispiel 4-4 gezeigt.

Beispiel 4-4. Die ersten sechs Tests für leere Benutzernamen
    def test_orders_by_customer_blank(self): 1
        results = get_orders_by_customer('')
        self.assertEqual(results, []) 2

    def test_orders_by_customer_blank_shipped(self):
        results = get_orders_by_customer('', True)
        self.assertEqual(results, [])

    def test_orders_by_customer_blank_notshipped(self):
        results = get_orders_by_customer('', False)
        self.assertEqual(results, [])

    def test_orders_by_customer_blank_details(self):
        results = get_orders_by_customer('', details=True)
        self.assertEqual(results, [])

    def test_orders_by_customer_blank_shipped_details(self):
        results = get_orders_by_customer('', True, True)
        self.assertEqual(results, [])

    def test_orders_by_customer_blank_notshipped_details(self):
        results = get_orders_by_customer('', False, True)
        self.assertEqual(results, [])
1

unittest erwartet, dass jeder Test mit den Buchstaben test beginnt.

2

unittest verwendet assertEqual, um zu überprüfen, ob das Ergebnis mit dem übereinstimmt, was du erwartest. Da ein Benutzer nicht gefunden wird, solltest du eine leere Liste zurückbekommen.

Speichere die Testdatei als test_app.py und führe die Unit-Tests mit dem folgenden Befehl aus:

# python -m unittest test_app
......

Ran 6 tests in 0.018s
Hinweis

Es kann sein, dass du eine Warnung über SQLite und Dezimaltypen erhältst; ignoriere sie einfach, da sie für unsere Beispiele normal ist. Sie erscheint, weil SQLite keinen echten Dezimaltyp hat und SQLAlchemy dich darauf hinweisen will, dass es bei der Konvertierung vom SQLite-Float-Typ zu einigen Merkwürdigkeiten kommen könnte. Es ist immer ratsam, diese Meldungen zu untersuchen, denn im Produktionscode weisen sie dich normalerweise auf den richtigen Weg, den du einschlagen solltest. Wir lösen diese Warnung hier absichtlich aus, damit du siehst, wie sie aussieht.

Jetzt müssen wir ein paar Daten laden und sicherstellen, dass unsere Tests noch funktionieren. Wir wiederholen die Arbeit aus Kapitel 2 und fügen die gleichen Benutzer, Aufträge und Positionen ein. Dieses Mal werden wir die Daten jedoch in eine Funktion namens db_prep einfügen. So können wir diese Daten vor einem Test mit einem einfachen Funktionsaufruf einfügen. Der Einfachheit halber habe ich diese Funktion in die Datei db.py eingefügt (siehe Beispiel 4-5); in der Praxis wird sie jedoch oft in einer Testfixture oder einer Dienstprogrammdatei zu finden sein.

Beispiel 4-5. Einfügen einiger Testdaten
def prep_db():
    ins = dal.cookies.insert()
    dal.connection.execute(ins, cookie_name='dark chocolate chip',
            cookie_recipe_url='http://some.aweso.me/cookie/recipe_dark.html',
            cookie_sku='CC02',
            quantity='1',
            unit_cost='0.75')
    inventory_list = [
        {
            'cookie_name': 'peanut butter',
            'cookie_recipe_url': 'http://some.aweso.me/cookie/peanut.html',
            'cookie_sku': 'PB01',
            'quantity': '24',
            'unit_cost': '0.25'
        },
        {
            'cookie_name': 'oatmeal raisin',
            'cookie_recipe_url': 'http://some.okay.me/cookie/raisin.html',
            'cookie_sku': 'EWW01',
            'quantity': '100',
            'unit_cost': '1.00'
        }
    ]
    dal.connection.execute(ins, inventory_list)

    customer_list = [
        {
            'username': "cookiemon",
            'email_address': "mon@cookie.com",
            'phone': "111-111-1111",
            'password': "password"
        },
        {
            'username': "cakeeater",
            'email_address': "cakeeater@cake.com",
            'phone': "222-222-2222",
            'password': "password"
        },
        {
            'username': "pieguy",
            'email_address': "guy@pie.com",
            'phone': "333-333-3333",
            'password': "password"
        }
    ]
    ins = dal.users.insert()
    dal.connection.execute(ins, customer_list)
    ins = insert(dal.orders).values(user_id=1, order_id='wlk001')
    dal.connection.execute(ins)
    ins = insert(dal.line_items)
    order_items = [
        {
            'order_id': 'wlk001',
            'cookie_id': 1,
            'quantity': 2,
            'extended_cost': 1.00
        },
        {
            'order_id': 'wlk001',
            'cookie_id': 3,
            'quantity': 12,
            'extended_cost': 3.00
        }
    ]
    dal.connection.execute(ins, order_items)
    ins = insert(dal.orders).values(user_id=2, order_id='ol001')
    dal.connection.execute(ins)
    ins = insert(dal.line_items)
    order_items = [
        {
            'order_id': 'ol001',
            'cookie_id': 1,
            'quantity': 24,
            'extended_cost': 12.00
        },
        {
            'order_id': 'ol001',
            'cookie_id': 4,
            'quantity': 6,
            'extended_cost': 6.00
        }
    ]
    dal.connection.execute(ins, order_items)

Da wir nun eine prep_db Funktion haben, können wir diese in unserer test_app.py setUpClass Methode verwenden, um Daten in die Datenbank zu laden, bevor wir unsere Tests durchführen. Unsere setUpClass Methode sieht jetzt wie folgt aus:

@classmethod
def setUpClass(cls):
    dal.db_init('sqlite:///:memory:')
    prep_db()

Wir können diese Testdaten verwenden, um sicherzustellen, dass unsere Funktion das Richtige tut, wenn wir einen gültigen Benutzernamen erhalten. Diese Tests werden als neue Funktionen in unsere TestApp-Klasse eingefügt, wie Beispiel 4-6 zeigt.

Beispiel 4-6. Tests für einen gültigen Benutzer
    def test_orders_by_customer(self):
        expected_results = [(u'wlk001', u'cookiemon', u'111-111-1111')]
        results = get_orders_by_customer('cookiemon')
        self.assertEqual(results, expected_results)

    def test_orders_by_customer_shipped_only(self):
        results = get_orders_by_customer('cookiemon', True)
        self.assertEqual(results, [])

    def test_orders_by_customer_unshipped_only(self):
        expected_results = [(u'wlk001', u'cookiemon', u'111-111-1111')]
        results = get_orders_by_customer('cookiemon', False)
        self.assertEqual(results, expected_results)

    def test_orders_by_customer_with_details(self):
        expected_results = [
            (u'wlk001', u'cookiemon', u'111-111-1111', u'dark chocolate chip',
             2, Decimal('1.00')),
            (u'wlk001', u'cookiemon', u'111-111-1111', u'oatmeal raisin',
             12, Decimal('3.00'))
        ]
        results = get_orders_by_customer('cookiemon', details=True)
        self.assertEqual(results, expected_results)

    def test_orders_by_customer_shipped_only_with_details(self):
        results = get_orders_by_customer('cookiemon', True, True)
        self.assertEqual(results, [])

    def test_orders_by_customer_unshipped_only_details(self):
        expected_results = [
            (u'wlk001', u'cookiemon', u'111-111-1111', u'dark chocolate chip',
             2, Decimal('1.00')),
            (u'wlk001', u'cookiemon', u'111-111-1111', u'oatmeal raisin',
             12, Decimal('3.00'))
        ]
        results = get_orders_by_customer('cookiemon', False, True)
        self.assertEqual(results, expected_results)

Kannst du anhand der Tests in Beispiel 4-6 herausfinden, was mit einem anderen Benutzer passiert, z. B. cakeeater? Was ist mit den Tests für einen Benutzernamen, der noch nicht im System existiert? Oder wie sieht das Ergebnis aus, wenn wir als Benutzernamen eine ganze Zahl statt eines Strings erhalten? Vergleiche deine Tests mit denen im mitgelieferten Beispielcode, wenn du fertig bist, um zu sehen, ob deine Tests den in diesem Buch verwendeten ähnlich sind.

Wir haben gelernt, wie wir SQLAlchemy in funktionalen Tests einsetzen können, um festzustellen, ob sich eine Funktion bei einem bestimmten Datensatz wie erwartet verhält. Außerdem haben wir uns angesehen, wie wir eine unittest Datei einrichtet und wie wir die Datenbank für unsere Tests vorbereiten. Als Nächstes werden wir uns mit Tests beschäftigen, ohne die Datenbank anzugreifen.

Mocks verwenden

Diese Technik kann ein mächtiges Werkzeug sein, wenn du eine Testumgebung hast, in der das Erstellen einer Testdatenbank keinen Sinn macht oder einfach nicht machbar ist. Wenn du eine große Menge an Logik hast, die mit dem Ergebnis der Abfrage arbeitet, kann es nützlich sein, den SQLAlchemy-Code so zu mocken, dass er die gewünschten Werte zurückgibt, damit du nur die umgebende Logik testen kannst. Wenn ich einen Teil der Abfrage ausspiegele, erstelle ich normalerweise die In-Memory-Datenbank, lade aber keine Daten in sie und spiegele die Datenbankverbindung selbst aus. So kann ich kontrollieren, was von den Methoden execute und fetch zurückgegeben wird. Wie das geht, werden wir in diesem Abschnitt untersuchen.

Um zu lernen, wie wir Mocks in unseren Tests verwenden können, werden wir einen einzigen Test für einen gültigen Benutzer durchführen. Dieses Mal werden wir die leistungsstarke Python Mock-Bibliothek verwenden, um zu kontrollieren, was von der Verbindung zurückgegeben wird. Mock ist Teil des Moduls unittest in Python 3. Wenn du jedoch Python 2 verwendest, musst du die Mock-Bibliothek mit pip installieren, um die neuesten Mock-Funktionen zu erhalten. Führe dazu den folgenden Befehl aus:

pip install mock

Jetzt, wo wir Mock installiert haben, können wir es in unseren Tests verwenden. Mock verfügt über eine Patch-Funktion, mit der wir ein bestimmtes Objekt in einer Python-Datei durch ein MagicMock ersetzen können, das wir von unserem Test aus steuern können. Ein MagicMock ist eine besondere Art von Python-Objekt, das verfolgt, wie es verwendet wird, und mit dem wir festlegen können, wie es sich verhält, je nachdem, wie es verwendet wird.

Zuerst müssen wir die Mock-Bibliothek importieren. In Python 2 müssen wir Folgendes tun:

import mock

In Python 3 müssen wir Folgendes tun:

from unittest import mock

Nachdem wir mock importiert haben, werden wir die Methode patch als Dekorator verwenden, um den Verbindungsteil des dal Objekts zu ersetzen. Ein Dekorator ist eine Funktion, die eine andere Funktion umhüllt und das Verhalten der umhüllten Funktion ändert. Da das Objekt dal namentlich in die Datei app.py importiert wird, müssen wir es im Modul app einfügen. Es wird als Argument an die Testfunktion übergeben. Jetzt, da wir ein Mock-Objekt haben, können wir einen Rückgabewert für die Methode execute festlegen, die in diesem Fall nichts anderes als eine verkettete Methode fetchall sein sollte, deren Rückgabewert die Daten sind, mit denen wir testen wollen. Beispiel 4-7 zeigt den Code, der benötigt wird, um das Mock anstelle des dal Objekts zu verwenden.

Beispiel 4-7. Gespielter Verbindungstest
import unittest
from decimal import Decimal

import mock

from db import dal, prep_db
from app import get_orders_by_customer


class TestApp(unittest.TestCase):
    cookie_orders = [(u'wlk001', u'cookiemon', u'111-111-1111')]
    cookie_details = [
        (u'wlk001', u'cookiemon', u'111-111-1111',
         u'dark chocolate chip', 2, Decimal('1.00')),
        (u'wlk001', u'cookiemon', u'111-111-1111',
         u'oatmeal raisin', 12, Decimal('3.00'))
    ]

    @mock.patch('app.dal.connection') 1
    def test_orders_by_customer(self, mock_conn): 2
        mock_conn.execute.return_value.fetchall.return_value = self.cookie_orders 3
        results = get_orders_by_customer('cookiemon') 4
        self.assertEqual(results, self.cookie_orders)
1

Patching dal.connection im app Modul mit einem Mock.

2

Dieser Mock wird als mock_conn an die Testfunktion übergeben.

3

Wir setzen den Rückgabewert der Methode execute auf den verketteten Rückgabewert der Methode fetchall, den wir auf self.cookie_order setzen.

4

Jetzt rufen wir die Testfunktion auf, in der die dal.connection gespottet wird und den Wert zurückgibt, den wir im vorherigen Schritt festgelegt haben.

Du siehst, dass eine komplizierte Abfrage oder ResultProxy wie in Beispiel 4-7 schnell mühsam werden kann, wenn du versuchst, die komplette Abfrage oder Verbindung zu simulieren. Scheue dich aber nicht vor der Arbeit; sie kann sehr nützlich sein, um obskure Fehler zu finden.

Wenn du die Abfrage mockieren möchtest, würdest du nach dem gleichen Muster vorgehen, indem du den mock.patch Dekorator verwendest und das select Objekt im app Modul mockst. Versuchen wir das mal mit einer der leeren Testabfragen. Wir müssen alle Rückgabewerte der verketteten Abfrageelemente, die in dieser Abfrage die Klauseln select, select_from und where sind, ausspotten. Beispiel 4-8 zeigt, wie man das macht.

Beispiel 4-8. Die Abfrage auch als Mocking-out
@mock.patch('app.select') 1
@mock.patch('app.dal.connection')
def test_orders_by_customer_blank(self, mock_conn, mock_select): 2
    mock_select.return_value.select_from.return_value.\
        where.return_value = '' 3
    mock_conn.execute.return_value.fetchall.return_value = [] 4
    results = get_orders_by_customer('')
    self.assertEqual(results, [])
1

Mocking out der select Methode, da sie die Abfragekette startet.

2

Die Dekoratoren werden der Reihe nach an die Funktion übergeben. Wenn wir von der Funktion aus den Stapel der Dekoratoren nach oben wandern, werden die Argumente auf der linken Seite hinzugefügt.

3

Wir müssen den Rückgabewert für alle Teile der verketteten Abfrage verspotten.

4

Wir müssen die Verbindung noch nachbilden, sonst würde der Code des app Moduls SQLAlchemy versuchen, die Abfrage zu stellen.

Als Übung solltest du den Rest der Tests, die wir mit der In-Memory-Datenbank erstellt haben, mit den gespotteten Testtypen durchführen. Ich empfehle dir, sowohl die Abfrage als auch die Verbindung zu mocken, um dich mit dem Mocking-Prozess vertraut zu machen.

Du solltest jetzt wissen, wie du eine Funktion testen kannst, die SQLAlchemy-Funktionen enthält. Du solltest auch wissen, wie du Daten in die Testdatenbank einträgst, um sie in deinem Test zu verwenden. Schließlich solltest du wissen, wie du die Abfrage- und Verbindungsobjekte als Spotting einsetzen kannst. Während in diesem Kapitel ein einfaches Beispiel verwendet wurde, werden wir in Kapitel 14, in dem wir uns mit Flask, Pyramid und pytest beschäftigen, tiefer in das Testen eintauchen.

Als Nächstes schauen wir uns an, wie wir eine bestehende Datenbank mit SQLAlchemy verwalten können, ohne das gesamte Schema in Python über reflection neu erstellen zu müssen.

Get Essential SQLAlchemy, 2. Auflage now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.