Testen des datenintensiven Codes mit Go, Teil 5

Überblick

Dies ist Teil fünf von fünf einer Tutorialserie zum Testen von datenintensivem Code. In Teil vier habe ich Remote-Datenspeicher behandelt, gemeinsame Testdatenbanken verwendet, Produktionsdaten-Snapshots verwendet und eigene Testdaten generiert. In diesem Lernprogramm gehe ich die Fuzz-Tests durch, teste den Cache, die Datenintegrität testen, die Idempotenz testen und fehlende Daten.

Fuzz-Test

Die Idee des Fuzz-Tests besteht darin, das System mit einer Menge zufälliger Eingaben zu überfordern. Anstatt sich Gedanken über einen Input zu machen, der alle Fälle abdeckt, die schwierig und / oder sehr arbeitsintensiv sein können, überlassen Sie es dem Zufall für Sie. Es ist konzeptionell ähnlich wie die Erzeugung von Zufallsdaten, aber hier sollen nicht zufällige Daten, sondern zufällige oder halb zufällige Eingaben generiert werden.

Wann ist Fuzz Testing nützlich??

Fuzz-Tests eignen sich insbesondere zum Auffinden von Sicherheits- und Leistungsproblemen, wenn unerwartete Eingaben Abstürze oder Speicherverluste verursachen. Es kann jedoch auch dazu beitragen, dass alle ungültigen Eingaben frühzeitig erkannt und vom System ordnungsgemäß zurückgewiesen werden.

Betrachten Sie zum Beispiel Eingaben, die in Form tief verschachtelter JSON-Dokumente (sehr häufig in Web-APIs) vorliegen. Der Versuch, eine umfassende Liste von Testfällen manuell zu erstellen, ist sowohl fehleranfällig als auch mit viel Arbeit verbunden. Aber Fuzz-Tests sind die perfekte Technik.

Fuzz-Tests verwenden 

Es gibt mehrere Bibliotheken, die Sie zum Fuzz-Testen verwenden können. Mein Favorit ist gofuzz ​​von Google. Hier ist ein einfaches Beispiel, das automatisch 200 eindeutige Objekte einer Struktur mit mehreren Feldern generiert, einschließlich einer verschachtelten Struktur.  

import ("fmt" "github.com/google/gofuzz") func SimpleFuzzing () type SomeType struct Zeichenfolge B Zeichenfolge C int D struct E float64 f: = fuzz.New () object: = SomeType   uniqueObjects: = map [SomeType] int  für i: = 0; ich < 200; i++  f.Fuzz(&object) uniqueObjects[object]++  fmt.Printf("Got %v unique objects.\n", len(uniqueObjects)) // Output: // Got 200 unique objects.  

Cache testen

So ziemlich jedes komplexe System, das viele Daten verarbeitet, hat einen Cache oder wahrscheinlich mehrere Ebenen hierarchischer Caches. Wie das Sprichwort sagt, gibt es in der Informatik nur zwei schwierige Dinge: Benennen von Dingen, Leeren des Cache-Speichers und Fehlversuchen.

Abgesehen von Witzen kann die Verwaltung Ihrer Caching-Strategie und -Implementierung den Datenzugriff erschweren, hat jedoch enorme Auswirkungen auf die Datenzugriffskosten und -leistung. Sie können den Cache nicht von außen testen, da Ihre Schnittstelle den Ursprung der Daten verdeckt und der Cache-Mechanismus ein Implementierungsdetail ist.

Sehen wir uns an, wie Sie das Cache-Verhalten der Songify-Hybriddatenschicht testen können.

Cache-Treffer und Misses

Caches leben und sterben durch ihre Hit / Miss-Performance. Die grundlegende Funktionalität eines Caches besteht darin, dass, wenn angeforderte Daten im Cache verfügbar sind (ein Treffer), diese aus dem Cache abgerufen werden und nicht aus dem primären Datenspeicher. Im ursprünglichen Design des HybridDataLayer, Der Cache-Zugriff erfolgte über private Methoden.

Go-Sichtbarkeitsregeln machen es unmöglich, sie direkt anzurufen oder aus einem anderen Paket zu ersetzen. Um den Cache-Test zu ermöglichen, ändere ich diese Methoden in öffentliche Funktionen. Dies ist in Ordnung, da der eigentliche Anwendungscode über die DataLayer Schnittstelle, die diese Methoden nicht verfügbar macht.

Der Testcode kann diese öffentlichen Funktionen jedoch nach Bedarf ersetzen. Fügen wir zunächst eine Methode hinzu, um Zugriff auf den Redis-Client zu erhalten, damit wir den Cache bearbeiten können:

func (m * HybridDataLayer) GetRedis () * redis.Client return m.redis 

Als nächstes werde ich das ändern getSongByUser_DB () Methoden zu einer öffentlichen Funktionsvariablen. Jetzt kann ich im Test das ersetzen GetSongsByUser_DB () Variable mit einer Funktion, die nachverfolgt, wie oft er aufgerufen wurde, und dann an die ursprüngliche Funktion weiterleitet. Das ermöglicht uns zu überprüfen, ob ein Anruf an GetSongsByUser () holte die Songs aus dem Cache oder aus der DB. 

Lassen Sie uns das Stück für Stück abbauen. Zuerst erhalten wir die Datenschicht (die auch die Datenbank löscht und neu erstellt), erstellen einen Benutzer und fügen einen Song hinzu. Das AddSong () Methode bevölkert auch Redis. 

func TestGetSongsByUser_Cache (t * testing.T) now: = time.Now () u: = Benutzer Name: "Gigi", E-Mail: "[email protected]", RegisteredAt: now, LastLogin: now dl, err : = getDataLayer () if err! = nil t.Error ("Fehler beim Erstellen der hybriden Datenschicht") err = dl.CreateUser (u) wenn err! = nil t.Error ("Benutzer konnten nicht erstellt werden")  lm, err: = NewSongManager (u, dl) wenn err! = nil t.Error ("NewSongManager () hat 'nil' zurückgegeben") err = lm.AddSong (testSong, nil), wenn err! = nil t .Error ("AddSong () fehlgeschlagen") 

Das ist der coole Teil. Ich behalte die ursprüngliche Funktion und definiere eine neue instrumentierte Funktion, die die lokale Funktion erhöht callCount Variable (es ist alles in einer Schließung) und ruft die ursprüngliche Funktion auf. Dann weise ich die instrumentierte Funktion der Variablen zu GetSongsByUser_DB. Ab jetzt kann jeder Aufruf von der hybriden Datenschicht zu GetSongsByUser_DB () wechselt zur instrumentierten Funktion.     

 callCount: = 0 originalFunc: = GetSongsByUser_DB instrumentedFunc: = func (m * HybridDataLayer, E-Mail-String, Songs * [] Song) (Fehler) callCount + = 1 return originalFunc (m, E-Mail, Songs) GetSongsByUser_DB = instrumentedFunc 

An dieser Stelle können wir den Cache-Vorgang tatsächlich testen. Zuerst ruft der Test die GetSongsByUser () des SongManager das leitet es an die hybride Datenschicht weiter. Der Cache sollte für den gerade hinzugefügten Benutzer gefüllt sein. Das erwartete Ergebnis ist also, dass unsere instrumentierte Funktion nicht aufgerufen wird und die callCount bleibt bei null.

 _, err = lm.GetSongsByUser (u) if err! = nil t.Error ("GetSongsByUser () failed") // Vergewissern Sie sich, dass nicht auf die Datenbank zugegriffen wurde, weil // der Cache bei AufrufCount mit AddSong () gefüllt werden soll > 0 t.Error ('GetSongsByUser_DB () aufgerufen, wenn es nicht sein sollte') 

Der letzte Testfall besteht darin, sicherzustellen, dass die Daten des Benutzers nicht ordnungsgemäß im Cache abgerufen werden und ordnungsgemäß aus der Datenbank abgerufen werden. Der Test führt dies durch, indem er Redis löscht (alle Daten löscht) und einen weiteren Anruf tätigt GetSongsByUser (). Diesmal wird die instrumentierte Funktion aufgerufen und der Test überprüft, dass die callCount ist gleich 1. Zum Schluss das Original GetSongsByUser_DB () Funktion wird wiederhergestellt.

 // Löschen Sie den Cache dl.GetRedis (). FlushDB () // Holen Sie sich die Titel erneut, jetzt sollte sie zur DB // gehen, da der Cache leer ist _, err = lm.GetSongsByUser (u), wenn err! = Nil t.Error ("GetSongsByUser () ist fehlgeschlagen") // Überprüfen Sie, ob auf den DB zugegriffen wurde, weil der Cache leer ist, wenn callCount! = 1 t.Error ('GetSongsByUser_DB () wurde nicht einmal aufgerufen, wie es sein sollte')  GetSongsByUser_DB = originalFunc

Cache-Invalidierung

Unser Cache ist sehr einfach und macht keine Ungültigkeitserklärung. Dies funktioniert ziemlich gut, solange alle Songs über die aufgenommen werden AddSong () Methode, die für die Aktualisierung von Redis sorgt. Wenn Sie weitere Vorgänge hinzufügen, z. B. das Entfernen von Titeln oder das Löschen von Benutzern, sollten diese Vorgänge dafür sorgen, dass Redis entsprechend aktualisiert wird.

Dieser sehr einfache Cache funktioniert auch dann, wenn wir über ein verteiltes System verfügen, auf dem mehrere unabhängige Maschinen unseren Songify-Dienst ausführen können, sofern alle Instanzen mit derselben DB- und Redis-Instanz arbeiten.

Wenn jedoch die Datenbank und der Cache aufgrund von Wartungsvorgängen oder anderen Tools und Anwendungen, die unsere Daten ändern, nicht mehr synchron sind, müssen wir eine Ungültigmachungs- und Aktualisierungsrichtlinie für den Cache festlegen. Es kann mit denselben Techniken getestet werden, um Zielfunktionen zu ersetzen oder direkt auf die DB und Redis in Ihrem Test zuzugreifen, um den Status zu überprüfen.

LRU-Caches

Normalerweise lässt sich der Cache nicht unendlich vergrößern. Ein allgemeines Schema, um die nützlichsten Daten im Cache zu halten, sind LRU-Caches (am wenigsten kürzlich verwendet). Die ältesten Daten werden aus dem Cache entfernt, wenn sie die Kapazität erreichen.

Beim Testen wird die Kapazität während des Tests auf eine relativ kleine Zahl gesetzt, die Kapazität wird überschritten, und es wird sichergestellt, dass sich die ältesten Daten nicht mehr im Cache befinden, und der Zugriff darauf erfordert Datenbankzugriff. 

Testen Sie Ihre Datenintegrität

Ihr System ist nur so gut wie Ihre Datenintegrität. Wenn Sie Daten beschädigt haben oder Daten fehlen, befinden Sie sich in einem schlechten Zustand. In realen Systemen ist es schwierig, eine perfekte Datenintegrität aufrechtzuerhalten. Schema und Formate ändern sich, Daten werden über Kanäle aufgenommen, die möglicherweise nicht alle Einschränkungen überprüfen, Fehler in fehlerhaften Daten zulassen, Administratoren versuchen, manuelle Korrekturen durchzuführen, Sicherungen und Wiederherstellungen sind möglicherweise unzuverlässig.

Angesichts dieser harten Realität sollten Sie die Datenintegrität Ihres Systems testen. Das Testen der Datenintegrität unterscheidet sich von regulären automatisierten Tests nach jeder Codeänderung. Der Grund ist, dass Daten schlecht werden können, auch wenn sich der Code nicht geändert hat. Auf jeden Fall möchten Sie Datenintegritätsprüfungen nach Codeänderungen durchführen, die die Datenspeicherung oder -darstellung ändern können, sie aber auch regelmäßig ausführen.

Einschränkungen testen

Einschränkungen sind die Grundlage Ihrer Datenmodellierung. Wenn Sie eine relationale Datenbank verwenden, können Sie einige Einschränkungen auf SQL-Ebene definieren und von der Datenbank durchsetzen lassen. Nullheit, Länge von Textfeldern, Eindeutigkeit und 1-N-Beziehungen können leicht definiert werden. SQL kann jedoch nicht alle Einschränkungen prüfen.

In Desongcious gibt es beispielsweise eine N-N-Beziehung zwischen Benutzern und Liedern. Jeder Song muss mit mindestens einem Benutzer verknüpft sein. Es gibt keine gute Möglichkeit, dies in SQL zu erzwingen (gut, Sie können einen Fremdschlüssel von Song zu Benutzer haben und den Song auf einen der damit verknüpften Benutzer verweisen). Eine weitere Einschränkung kann sein, dass jeder Benutzer höchstens 500 Songs haben kann. Es gibt auch keine Möglichkeit, es in SQL darzustellen. Wenn Sie NoSQL-Datenspeicher verwenden, wird das Deklarieren und Überprüfen von Einschränkungen auf Datenspeicherebene normalerweise noch weniger unterstützt.

Damit haben Sie mehrere Möglichkeiten:

  • Stellen Sie sicher, dass der Zugriff auf Daten nur über überprüfte Schnittstellen und Tools erfolgt, die alle Einschränkungen durchsetzen.
  • Scannen Sie regelmäßig Ihre Daten, suchen Sie nach Verstößen gegen Beschränkungen und beheben Sie sie.    

Idempotenz testen

Idempotenz bedeutet, dass die gleiche Operation mehrmals hintereinander den gleichen Effekt hat wie die einmalige Ausführung. 

Wenn Sie beispielsweise die Variable x auf 5 setzen, ist dies idempotent. Sie können x einmal oder millionenfach auf 5 setzen. Es wird immer noch 5 sein. Das Erhöhen von X um 1 ist jedoch nicht idempotent. Jedes aufeinanderfolgende Inkrement ändert seinen Wert. Idempotenz ist eine sehr wünschenswerte Eigenschaft in verteilten Systemen mit temporären Netzwerkpartitionen und Wiederherstellungsprotokollen, bei denen wiederholt versucht wird, eine Nachricht mehrmals zu senden, wenn keine unmittelbare Antwort vorliegt.

Wenn Sie Idempotenz in Ihren Datenzugriffscode einbauen, sollten Sie ihn testen. Dies ist normalerweise sehr einfach. Für jeden idempotenten Vorgang, den Sie erweitern, wird der Vorgang zweimal oder mehrmals hintereinander ausgeführt, und es wird überprüft, ob keine Fehler vorliegen und der Status gleich bleibt.   

Beachten Sie, dass idempotentes Design manchmal Fehler verbirgt. Erwägen Sie, einen Datensatz aus einer Datenbank zu löschen. Es ist eine idempotente Operation. Nachdem Sie einen Datensatz gelöscht haben, ist der Datensatz nicht mehr im System vorhanden. Wenn Sie ihn erneut löschen, wird er nicht wiederhergestellt. Dies bedeutet, dass der Versuch, einen nicht vorhandenen Datensatz zu löschen, eine gültige Operation ist. Es kann jedoch die Tatsache überdecken, dass der falsche Datensatzschlüssel vom Anrufer übergeben wurde. Wenn Sie eine Fehlermeldung zurückgeben, ist sie nicht idempotent.    

Testen von Datenmigrationen

Datenmigrationen können sehr riskante Vorgänge sein. Manchmal führen Sie ein Skript über alle Ihre Daten oder kritischen Teile Ihrer Daten aus und führen einige ernsthafte Eingriffe durch. Sie sollten mit Plan B bereit sein, falls etwas schief geht (z. B. zu den ursprünglichen Daten zurückkehren und herausfinden, was schiefgegangen ist)..

In vielen Fällen kann die Datenmigration ein langsamer und kostspieliger Vorgang sein, für den zwei Systeme nebeneinander für die Dauer der Migration erforderlich sind. Ich habe an mehreren Datenmigrationen teilgenommen, die mehrere Tage oder sogar Wochen dauerten. Bei einer massiven Datenmigration lohnt es sich, Zeit zu investieren und die Migration selbst an einer kleinen (aber repräsentativen) Teilmenge Ihrer Daten zu testen und dann zu überprüfen, ob die neu migrierten Daten gültig sind und das System damit arbeiten kann. 

Fehlende Daten testen

Fehlende Daten sind ein interessantes Problem. Manchmal verletzen fehlende Daten Ihre Datenintegrität (z. B. ein Titel, dessen Benutzer fehlt), und manchmal fehlen sie (z. B. entfernt jemand einen Benutzer und alle seine Songs)..

Wenn die fehlenden Daten zu einem Datenintegritätsproblem führen, werden Sie dies in Ihren Datenintegritätstests feststellen. Wenn jedoch nur einige Daten fehlen, gibt es keine einfache Möglichkeit, sie zu erkennen. Wenn die Daten nie in den permanenten Speicher aufgenommen wurden, ist in den Protokollen oder anderen temporären Speichern möglicherweise ein Trace vorhanden.

Abhängig davon, wie groß das Risiko ist, dass Daten fehlen, können Sie einige Tests schreiben, die absichtlich einige Daten von Ihrem System entfernen und das System wie erwartet verhalten.

Fazit

Das Testen von datenintensivem Code erfordert eine bewusste Planung und ein Verständnis Ihrer Qualitätsanforderungen. Sie können auf verschiedenen Abstraktionsebenen testen, und Ihre Auswahl beeinflusst, wie gründlich und umfassend Ihre Tests sind, wie viele Aspekte Ihrer tatsächlichen Datenschicht Sie testen, wie schnell Ihre Tests ablaufen und wie einfach es ist, Ihre Tests zu ändern Datenschicht ändert sich.

Es gibt keine richtige Antwort. Sie müssen Ihren Sweetspot entlang des Spektrums finden, von sehr umfassenden, langsamen und arbeitsintensiven Tests bis hin zu schnellen, leichten Tests.