3 Min. Lesezeit
Tests ohne Mocks
Eine echte, migrierte Datenbank in jedem Test
Teil sieben des Boring Stack. Die Datenbank ist SQLite, und das Schema migriert sich selbst. Das macht die Entscheidung beim Testen einfach.
Ein verbreiteter Weg, einen Service zu testen, ist, seine Datenbank durch einen Mock zu ersetzen: ein falsches Objekt, das zurückgibt, was der Test ihm sagt. Der Test prüft dann, ob der Service das falsche Objekt mit den richtigen Argumenten aufgerufen hat.
Dieser Test beweist, dass der Service eine Funktion aufruft. Er beweist nicht, dass die Abfrage funktioniert, dass die Spalte existiert, dass der Constraint das Einfügen erlaubt oder dass das Ergebnis in der erwarteten Reihenfolge zurückkommt. Genau diese Fehler sollen Tests finden.
Meine Tests haben keine Mocks. Sie laufen gegen eine echte Datenbank.
Eine echte Datenbank in Millisekunden
SQLite kann komplett im Arbeitsspeicher laufen. Jeder Test öffnet seine eigene In-Memory-Datenbank, lässt die echten Migrationen darauf laufen und bekommt ein frisches, leeres Schema, identisch mit der Produktion. Das dauert Millisekunden.
Ein Detail macht man leicht falsch: Ein einfacher :memory:-Verbindungsstring gibt jeder Verbindung eine eigene, separate, leere Datenbank. Der Datenbank-Pool von Go öffnet mehrere Verbindungen, also sähe eine Verbindung die migrierten Tabellen und die nächste nicht. Die Lösung ist eine benannte, geteilte In-Memory-Datenbank pro Test, etwa file:<test-name>?mode=memory&cache=shared, oder ein Pool mit nur einer Verbindung.
Was wie getestet wird
- Repositories und Services werden direkt gegen diese Datenbank getestet. Ein Service-Test legt echte Zeilen an, ruft den Service auf und prüft das echte Ergebnis.
- Handler werden über den ganzen Router getestet, mit der vollständigen Middleware-Kette und Gos
httptest, nicht durch direkten Aufruf der Handler-Funktion. So deckt der Test auch Routing, CSRF-Schutz, Authentifizierung und alles ab, was die Middleware in den Request-Context legt. Eine Route, die nicht registriert ist, lässt den Test scheitern. - Reine Regeln (Preise, Berechtigungen, Zeitpläne) machen keine Ein- und Ausgabe und bekommen die aktuelle Zeit als Parameter. Diese Funktionen bekommen vollständige Tabellentests, inklusive der Grenzfälle um Mitternacht und Monatsende, weil der Test jeden beliebigen Zeitpunkt übergeben kann.
Geschrieben werden die Tests mit testify: require, wenn ein Fehler den Rest des Tests sinnlos macht, assert für den Rest, und Tabellen von Fällen, wo es mehrere gibt.
Die Abbildung auf HTTP-Statuscodes testen
Eine Art von Test hat eine eigene Regel. Services geben definierte Fehler zurück, und Handler machen daraus HTTP-Statuscodes: Ein fehlender Datensatz wird zu 404, eine verbotene Aktion zu 403, alles Unerwartete zu 500. Wird ein Fehler auf dem Weg nach oben verpackt, kann diese Abbildung unbemerkt bei 500 landen, während alle anderen Tests weiter grün sind. Jede Abbildung bekommt ihren eigenen Test.
Was es kostet
- Die Tests sind langsamer als reine Unit-Tests. Ein paar Millisekunden pro Test statt Mikrosekunden. Eine ganze Suite läuft trotzdem in Sekunden.
- Testdaten anzulegen braucht mehr Code. Statt einem Mock zu sagen, was er zurückgeben soll, fügt man echte Zeilen ein. Kleine Hilfsfunktionen halten das kurz, und die Vorbereitung dokumentiert, wovon der Test ausgeht.
- Externe Dienste brauchen trotzdem eine Naht. Zahlungsanbieter oder E-Mail laufen nicht in einem Test. Sie stehen hinter einem kleinen Interface mit einer Testumsetzung, und das ist die einzige Stelle, an der etwas vorgetäuscht wird.
Wann ich anders entscheiden würde
Wäre die Datenbank ein entfernter Server, dessen Einrichtung Sekunden dauert, sähe die Abwägung anders aus. Ein Grund mehr, warum SQLite passt: Damit ist der ehrliche Test der günstige.
Als Nächstes: der Entwicklungsablauf.