Testen des datenintensiven Codes mit Go, Teil 1

Überblick

Viele nicht triviale Systeme sind auch datenintensiv oder datengesteuert. Das Testen der Teile der Systeme, die datenintensiv sind, unterscheidet sich sehr von dem Testen von codeintensiven Systemen. Erstens kann die Datenschicht selbst sehr komplex sein, z. B. hybride Datenspeicher, Zwischenspeicherung, Sicherung und Redundanz.

Alle diese Maschinen haben nichts mit der Anwendung selbst zu tun, sondern müssen getestet werden. Zweitens kann der Code sehr generisch sein, und um ihn zu testen, müssen Sie Daten generieren, die auf bestimmte Weise strukturiert sind. In dieser Serie von fünf Tutorials werde ich auf all diese Aspekte eingehen, verschiedene Strategien für das Entwerfen testbarer datenintensiver Systeme mit Go untersuchen und in konkrete Beispiele eintauchen. 

In Teil 1 gehe ich auf das Design einer abstrakten Datenschicht ein, die ein ordnungsgemäßes Testen ermöglicht, wie die Fehlerbehandlung in der Datenschicht durchgeführt wird, wie der Datenzugriffscode gemockert wird und wie mit einer abstrakten Datenschicht getestet wird. 

Testen gegen eine Datenschicht

Der Umgang mit echten Datenspeichern und ihren Feinheiten ist kompliziert und hängt nicht mit der Geschäftslogik zusammen. Das Konzept einer Datenschicht ermöglicht Ihnen, Ihren Daten eine übersichtliche Schnittstelle zu bieten und die bloßen Details der genauen Speicherung der Daten und des Zugriffs darauf zu verbergen. Ich verwende eine Beispielanwendung namens "Songify" für die persönliche Musikverwaltung, um die Konzepte mit echtem Code zu veranschaulichen.

Entwerfen einer abstrakten Datenschicht

Sehen wir uns die Domain für die persönliche Musikverwaltung an - Benutzer können Songs hinzufügen und benennen - und überlegen, welche Daten wir speichern müssen und wie auf sie zugegriffen werden kann. Die Objekte in unserer Domäne sind Benutzer, Lieder und Labels. Es gibt zwei Kategorien von Vorgängen, die Sie für beliebige Daten ausführen möchten: Abfragen (schreibgeschützt) und Statusänderungen (Erstellen, Aktualisieren, Löschen). Hier ist eine grundlegende Schnittstelle für die Datenschicht:

Paket abstract_data_layer import "time" -Typ Song-Struktur Url-Zeichenfolge Name Zeichenfolge Beschreibung Zeichenfolge Typ Beschreibungsstruktur Name-Zeichenfolge Typ Benutzerstruktur Name Zeichenfolge E-Mail-Zeichenfolge RegisteredAt Zeit.Zeit LastLogin Zeit.Zeit Typ DataLayer-Schnittstelle // Abfragen (lesen -only) GetUsers () ([] Benutzer, Fehler) GetUserByEmail (E-Mail-String) (Benutzer, Fehler) GetLabels () ([] Label, Fehler) GetSongs () ([] Song, Fehler) GetSongsByUser (Benutzer Benutzer) ([ ] Song, Fehler) GetSongsByLabel (Labelzeichenfolge) ([] Song, Fehler) // Statusänderungsoperationen CreateUser (Benutzer User) Fehler ChangeUserName (Benutzer User, Namensstring) Fehler AddLabel (Labelstring) Fehler AddSong (Benutzer User, Song Song , labels [] Label) error 

Beachten Sie, dass der Zweck dieses Domänenmodells darin besteht, eine einfache, aber nicht vollständig triviale Datenschicht darzustellen, um die Testaspekte zu demonstrieren. Natürlich gibt es in einer echten Anwendung mehr Objekte wie Alben, Genres, Interpreten und viele weitere Informationen zu jedem Song. Wenn Sie sich durchsetzen, können Sie jederzeit beliebige Informationen zu einem Song in seiner Beschreibung speichern und beliebig viele Labels anhängen.

In der Praxis möchten Sie möglicherweise Ihre Datenschicht in mehrere Schnittstellen aufteilen. Einige der Strukturen können weitere Attribute haben, und die Methoden erfordern möglicherweise mehr Argumente (z. B. alle GetXXX ()Methoden erfordern wahrscheinlich einige Paging-Argumente). Sie benötigen möglicherweise andere Datenzugriffsschnittstellen und -methoden für Wartungsvorgänge wie Massenladen, Sicherungen und Migrationen. Es ist manchmal sinnvoll, anstelle oder zusätzlich zur synchronen Schnittstelle eine asynchrone Datenzugriffsschnittstelle bereitzustellen.

Was haben wir aus dieser abstrakten Datenschicht gewonnen??

  • One-Stop-Shop für Datenzugriff.
  • Klare Sicht auf die Datenverwaltungsanforderungen unserer Anwendungen in Bezug auf die Domäne.
  • Möglichkeit, die Implementierung der konkreten Datenschicht beliebig zu ändern.
  • Fähigkeit, die Domänen- / Geschäftslogikschicht frühzeitig anhand der Schnittstelle zu entwickeln, bevor die konkrete Datenschicht vollständig oder stabil ist.
  • Nicht zuletzt die Möglichkeit, die Datenschicht zu simulieren, um die Domäne / Geschäftslogik schnell und flexibel testen zu können.

Fehler und Fehlerbehandlung in der Datenschicht

Die Daten können in mehreren verteilten Datenspeichern in mehreren Clustern an verschiedenen geografischen Standorten in einer Kombination aus lokalen Rechenzentren und der Cloud gespeichert werden. 

Es wird Fehler geben, und diese Fehler müssen behandelt werden. Idealerweise kann die Fehlerbehandlungslogik (Wiederholungsversuche, Zeitüberschreitungen, Benachrichtigung bei katastrophalen Ausfällen) von der konkreten Datenschicht behandelt werden. Der Domänenlogikcode sollte nur die Daten oder einen generischen Fehler zurückgeben, wenn die Daten nicht erreichbar sind. 

In einigen Fällen möchte die Domänenlogik möglicherweise einen detaillierteren Zugriff auf die Daten und wählt in bestimmten Situationen eine Fallback-Strategie aus (z. B. sind nur Teildaten verfügbar, weil ein Teil des Clusters nicht verfügbar ist oder die Daten veraltet sind, weil der Cache nicht aktualisiert wurde ). Diese Aspekte haben Auswirkungen auf das Design Ihrer Datenschicht und deren Test. 

Beim Testen sollten Sie Ihre eigenen Fehler zurückgeben, die in der abstrakten Datenschicht definiert sind, und alle konkreten Fehlermeldungen Ihren eigenen Fehlertypen zuordnen oder auf sehr allgemeine Fehlermeldungen zurückgreifen.   

Verspotteter Datenzugriffscode

Machen wir uns über unsere Datenschicht lustig. Der Zweck des Mock besteht darin, die tatsächliche Datenschicht während der Tests zu ersetzen. Dies erfordert, dass die Mock-Datenschicht dieselbe Schnittstelle verfügbar macht und auf jede Sequenz von Methoden mit einer vorgefertigten (oder berechneten) Antwort reagieren kann. 

Darüber hinaus ist es nützlich zu verfolgen, wie oft jede Methode aufgerufen wurde. Ich werde es hier nicht zeigen, aber es ist sogar möglich, die Reihenfolge der Aufrufe verschiedener Methoden zu verfolgen und welche Argumente an jede Methode übergeben wurden, um eine bestimmte Aufrufkette sicherzustellen. 

Hier ist die Mock-Datenschichtstruktur.

package_contact_contact_contact_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_DE_LAUF_AB__LAUF_SATZ Song GetSongsByUserResponses [] [] Song GetSongsByLabelResponses [] [] Song-Indizes [] int func NewMockDataLayer () MockDataLayer return MockDataLayer Indizes: [] int 0, 0, 0, 0, 0, 0, 0  

Das const Anweisung listet alle unterstützten Operationen und die Fehler auf. Jede Operation hat einen eigenen Index im Indizes Scheibe. Der Index für jede Operation gibt an, wie oft die entsprechende Methode aufgerufen wurde und wie die nächste Antwort und der nächste Fehler sein sollten. 

Für jede Methode, die zusätzlich zu einem Fehler einen Rückgabewert enthält, gibt es ein Teil der Antworten. Wenn die Scheinmethode aufgerufen wird, werden die entsprechende Antwort und der entsprechende Fehler (basierend auf dem Index für diese Methode) zurückgegeben. Für Methoden, die keinen Rückgabewert außer einem Fehler haben, muss a nicht definiert werden XXXResponses Scheibe. 

Beachten Sie, dass die Fehler von allen Methoden gemeinsam genutzt werden. Das heißt, wenn Sie eine Folge von Anrufen testen möchten, müssen Sie die richtige Anzahl von Fehlern in der richtigen Reihenfolge eingeben. Ein alternatives Design würde für jede Antwort ein Paar verwenden, das aus dem Rückgabewert und dem Fehler besteht. Das NewMockDataLayer () Die Funktion gibt eine neue Mock-Datenschichtstruktur zurück, bei der alle Indizes auf Null gesetzt sind.

Hier ist die Umsetzung der GetUsers () Methode, die diese Konzepte veranschaulicht. 

func (m * MockDataLayer) GetUsers () (Benutzer [] Benutzer, Fehler) i: = m.Indices [GET_USERS] Benutzer = m.GetUsersResponses [i] wenn len (m.Errors)> 0 err = m. Fehler [m.Indices [ERRORS]] m.Indices [ERRORS] ++ m.Indices [GET_USERS] ++ return 

Die erste Zeile erhält den aktuellen Index der GET_USERS Betrieb (wird anfänglich 0 sein). 

Die zweite Zeile erhält die Antwort für den aktuellen Index. 

Die dritte bis fünfte Zeile weist den Fehler des aktuellen Indexes zu, wenn die Fehler Das Feld wurde ausgefüllt und der Fehlerindex wird erhöht. Beim Testen des glücklichen Pfads ist der Fehler gleich Null. Um die Verwendung zu vereinfachen, können Sie die Initialisierung des Betriebssystems einfach vermeiden Fehler Feld und dann gibt jede Methode Null für den Fehler zurück.

Die nächste Zeile erhöht den Index, sodass der nächste Aufruf die richtige Antwort erhält.

Die letzte Zeile kehrt einfach zurück. Die genannten Rückgabewerte für Benutzer und Fehler sind bereits ausgefüllt (oder standardmäßig Null für Fehler)..

Hier ist eine andere Methode, GetLabels (), das folgt dem gleichen Muster. Der einzige Unterschied besteht darin, welcher Index verwendet wird und welche Sammlung von vorgefertigten Antworten verwendet wird.

func (m * MockDataLayer) GetLabels () (labels [] Label, Fehler) i: = m.Indices [GET_LABELS] labels = m.GetLabelsResponses [i] wenn len (m.Errors)> 0 err = m. Fehler [m.Indices [ERRORS]] m.Indices [ERRORS] ++ m.Indices [GET_LABELS] ++ return 

Dies ist ein Paradebeispiel für einen Anwendungsfall, bei dem Generika eine speichern könnten Menge des Boilerplate-Codes. Es ist möglich, Reflektionen zu nutzen, um den gleichen Effekt zu erzielen. Dies ist jedoch nicht Gegenstand dieses Tutorials. Der Hauptvorteil hier ist, dass die Mock-Datenschicht einem allgemeinen Muster folgen und jedes Testszenario unterstützen kann, wie Sie bald sehen werden.

Wie wäre es mit einigen Methoden, die nur einen Fehler zurückgeben? Besuche die CreateUser () Methode. Es ist sogar noch einfacher, da es nur Fehler behandelt und die eingemachten Antworten nicht verwaltet werden muss.

func (m * MockDataLayer) CreateUser (Benutzer Benutzer) (Fehler) wenn len (m.Errors)> 0 i: = m.Indices [CREATE_USER] err = m.Errors [m.Indices [ERRORS]] m. Indizes [ERRORS] ++ return 

Diese Mock-Datenschicht ist nur ein Beispiel dafür, was für das Mocking einer Schnittstelle erforderlich ist und einige nützliche Dienste zum Testen bereitstellt. Sie können Ihre eigene Mock-Implementierung entwickeln oder verfügbare Mock-Bibliotheken verwenden. Es gibt sogar ein Standard-GoMock-Framework. 

Ich persönlich finde, dass Mock-Frameworks leicht zu implementieren sind und ziehen es vor, meine eigenen zu rollen (sie werden oft automatisch generiert), da ich die meiste Zeit meiner Entwicklungsarbeit damit verbringe, Tests zu schreiben und Abhängigkeiten zu simulieren. YMMV.

Testen gegen einen abstrakten Datenschicht

Jetzt, da wir eine Scheindatenschicht haben, schreiben wir einige Tests dagegen. Es ist wichtig zu wissen, dass wir hier die Datenschicht nicht selbst testen. Wir werden die Datenschicht selbst später mit anderen Methoden testen. Der Zweck hier ist, die Logik des Codes zu testen, der von der abstrakten Datenschicht abhängt.

Angenommen, ein Benutzer möchte einen Song hinzufügen, aber wir haben eine Quote von 100 Songs pro Benutzer. Das erwartete Verhalten ist, dass der Benutzer hinzugefügt wird, wenn der Benutzer weniger als 100 Songs hat und der hinzugefügte Song neu ist. Wenn der Song bereits existiert, wird ein Fehler "Duplicate Song" ausgegeben. Wenn der Benutzer bereits 100 Titel hat, wird der Fehler "Songquote überschritten" angezeigt.   

Lassen Sie uns einen Test für diese Testfälle schreiben, indem Sie unsere Modelldatenschicht verwenden. Dies ist ein White-Box-Test, dh Sie müssen wissen, welche Methoden der Datenschicht der zu testende Code in welcher Reihenfolge aufrufen wird, damit Sie die Mock-Antworten und Fehler richtig auffüllen können. Der Test-First-Ansatz ist hier also nicht ideal. Schreiben wir zuerst den Code. 

Hier ist der SongManager struct. Dies hängt nur von der abstrakten Datenschicht ab. Auf diese Weise können Sie eine Implementierung einer realen Datenschicht in der Produktion, aber eine nachgebende Datenschicht während des Testens übergeben.

Das SongManager selbst steht der konkreten Umsetzung der DataLayer Schnittstelle. Das SongManager struct akzeptiert auch einen Benutzer, den er speichert. Vermutlich hat jeder aktive Benutzer einen eigenen SongManager So können Benutzer nur Songs für sich selbst hinzufügen. Das NewSongManager ()Funktion sorgt für die Eingabe DataLayer Schnittstelle ist nicht gleich Null.

Paket song_manager import ("errors". "abstract_data_layer") const (MAX_SONGS_PER_USER = 100) type SongManager-Struktur Benutzer Benutzer aus DataLayer -Funktionscode NewSongManager (Benutzer Benutzer, DataLayer) (* SongManager, Fehler) wenn Dal == nil zurück null, errors.New ("DataLayer kann nicht null sein") return & SongManager user, dal, nil 

Lassen Sie uns ein implementieren AddSong () Methode. Die Methode ruft die Datenschicht auf GetSongsByUser () zuerst, und dann durchläuft es mehrere Prüfungen. Wenn alles in Ordnung ist, ruft es die Datenschicht auf AddSong () Methode und gibt das Ergebnis zurück.

func (lm * SongManager) AddSong (neuerSong-Song, Labels [] Label) -Fehler songs, err: = lm.dal.GetSongsByUser (lm.user), wenn err! = nil return nil // Überprüfen Sie, ob der Song ein Duplikat ist für _, song: = range songs wenn song.Url == newSong.Url return errors.New ("Duplicate song") // Überprüfen Sie, ob der Benutzer die maximale Anzahl von Songs hat, wenn len (songs) == MAX_SONGS_PER_USER  return errors.New ("Song-Kontingent überschritten") return lm.dal.AddSong (Benutzer, newSong, Labels) 

Wenn Sie diesen Code betrachten, sehen Sie, dass es zwei andere Testfälle gibt, die wir vernachlässigt haben: die Aufrufe der Methoden der Datenschicht GetSongByUser () und AddSong () kann aus anderen Gründen fehlschlagen. Nun mit der Implementierung von SongManager.AddSong () Vor uns können wir einen umfassenden Test schreiben, der alle Anwendungsfälle abdeckt. Beginnen wir mit dem glücklichen Weg. Das TestAddSong_Success () Die Methode erstellt einen Benutzer mit dem Namen Gigi und eine Modelldatenschicht.

Es bevölkert die GetSongsByUserResponses Feld mit einem Slice, das ein leeres Slice enthält, das zu einem leeren Slice führt, wenn der SongManager aufruft GetSongsByUser () auf der Mock-Datenschicht ohne Fehler. Es ist nicht notwendig, etwas für den Aufruf der Mock-Datenschicht zu tun AddSong () Diese Methode gibt standardmäßig null Fehler zurück. Der Test prüft nur, dass tatsächlich kein Fehler vom übergeordneten Aufruf an den SongManager zurückgegeben wurde AddSong () Methode.   

Paket song_manager import ("testing". "abstract_data_layer". "concrete_data_layer") func TestAddSong_Success (t * testing.T) u: = Benutzer Name: "Gigi", E-Mail: "[email protected]" mock: = NewMockDataLayer () // Mock-Antworten vorbereiten mock.GetSongsByUserResponses = [] [] Song  lm, err: = NewSongManager (u, & mock), wenn err! = Nil t.Error ("NewSongManager () zurückgegeben 'nil' ") url: = https://www.youtube.com/watch?v=MlW7T0SUH0E" err = lm.AddSong (Song Url: url ", Name:" Chacarron ", nil), wenn err! = nil  t.Fehler ("AddSong () fehlgeschlagen") Go test PASS ok song_manager 0.006s 

Das Prüfen der Fehlerbedingungen ist auch sehr einfach. Sie haben die volle Kontrolle darüber, was die Datenschicht von den Aufrufen an zurückgibt GetSongsByUser () und AddSong (). Hier ist ein Test, um zu überprüfen, ob beim Hinzufügen eines doppelten Songs die richtige Fehlermeldung zurückgegeben wird.

func TestAddSong_Duplicate (t * testing.T) u: = Benutzer Name: "Gigi", E-Mail: "[email protected]" mock: = NewMockDataLayer () // Mock-Antworten vorbereiten mock.GetSongsByUserResponses = [] [] Song testSong lm, err: = NewSongManager (u, & mock), wenn err! = Null t.Error ("NewSongManager () hat 'nil' zurückgegeben") err = lm.AddSong (testSong, nil), wenn err == nil t.Error ("AddSong () sollte fehlgeschlagen sein") wenn err.Error ()! = "Doppelter Song" t.Error ("AddSong () falscher Fehler:" + err.Error ())  

Die folgenden zwei Testfälle testen, ob die korrekte Fehlernachricht zurückgegeben wird, wenn die Datenschicht selbst fehlschlägt. Im ersten Fall ist die Datenschicht GetSongsByUser () gibt einen Fehler zurück.

func TestAddSong_DataLayerFailure_1 (t * testing.T) u: = Benutzer Name: "Gigi", E-Mail: "[email protected]" Mock: = NewMockDataLayer () // Mock-Antworten vorbereiten Mock.GetSongsByUserResponses = [] [] Lied  e: = errors.New ("GetSongsByUser () - Fehler") mock.Errors = [] error e lm, err: = NewSongManager (u, & mock), wenn err! = Nil t.Error ( "NewSongManager () hat 'nil' zurückgegeben") err = lm.AddSong (testSong, nil), wenn err == nil t.Error ("AddSong () hätte fehlgeschlagen sein"), wenn err.Error ()! = " Fehler bei GetSongsByUser () "t.Error (" AddSong () falscher Fehler: "+ err.Error ()) 

Im zweiten Fall handelt es sich um die Datenschicht AddSong () Methode gibt einen Fehler zurück. Seit dem ersten Anruf an GetSongsByUser () sollte gelingen, das Schein. Spiegel Slice enthält zwei Elemente: Null für den ersten Anruf und den Fehler für den zweiten Anruf. 

func TestAddSong_DataLayerFailure_2 (t * testing.T) u: = Benutzer Name: "Gigi", E-Mail: "[email protected]" Mock: = NewMockDataLayer () // Mock-Antworten vorbereiten Mock.GetSongsByUserResponses = [] [] Lied  e: = errors.New ("AddSong () - Fehler") mock.Errors = [] error nil, e lm, err: = NewSongManager (u, & mock), wenn err! = Nil t. Fehler ("NewSongManager () hat 'nil' zurückgegeben")) err = lm.AddSong (testSong, nil), wenn err == nil t.Error ("AddSong () sollte fehlgeschlagen sein"), wenn err.Error ()! = "AddSong () Fehler" t.Error ("AddSong () falscher Fehler:" + err.Error ())

Fazit

In diesem Tutorial haben wir das Konzept einer abstrakten Datenschicht eingeführt. Anschließend haben wir mithilfe der Domäne für persönliche Musikverwaltung demonstriert, wie eine Datenschicht entworfen, eine Scheindatenschicht erstellt und die Scheindatenschicht zum Testen der Anwendung verwendet wird. 

In Teil zwei werden wir uns auf das Testen mit einer echten In-Memory-Datenschicht konzentrieren. Bleib dran.