Advertisement
  1. Code
  2. Go

Testen von datenintensivem Code mit Go, Teil 5

Scroll to top
Read Time: 11 min
This post is part of a series called Testing Data-Intensive Code with Go.
Testing Data-Intensive Code With Go, Part 4

German (Deutsch) translation by Tatsiana Bochkareva (you can also view the original English article)

Überblick

Dies ist Teil fünf von fünf in einer Lernserie zum Testen von datenintensivem Code. In Teil 4 habe ich Remote-Datenspeicher behandelt, gemeinsam genutzte Testdatenbanken verwendet, Produktionsdaten-Snapshots verwendet und eigene Testdaten generiert. In diesem Tutorial gehe ich auf Fuzz-Tests, das Testen Ihres Caches, das Testen der Datenintegrität, das Testen der Idempotenz und fehlende Daten ein.

Fuzz-Tests

Die Idee des Fuzz-Testens ist es, das System mit vielen zufälligen Eingaben zu überwältigen. Anstatt zu versuchen, an Eingaben zu denken, die alle Fälle abdecken, die schwierig und/oder sehr arbeitsintensiv sein können, lassen Sie den Zufall dies für Sie tun. Es ähnelt konzeptionell der Erzeugung zufälliger Daten, soll jedoch eher zufällige oder halbzufällige Eingaben als persistente Daten erzeugen.

Wann ist Fuzz-Test sinnvoll?

Fuzz-Tests sind insbesondere nützlich, um Sicherheits- und Leistungsprobleme zu finden, wenn unerwartete Eingaben zu Abstürzen oder Speicherlecks führen. Es kann aber auch dazu beitragen, dass alle ungültigen Eingaben frühzeitig erkannt und vom System ordnungsgemäß zurückgewiesen werden.

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

Verwenden von Fuzz-Tests

Es gibt mehrere Bibliotheken, die Sie für Fuzz-Tests 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.

1
import (
2
    "fmt"
3
	"github.com/google/gofuzz"
4
)
5
6
func SimpleFuzzing() {
7
	type SomeType struct {
8
		A string
9
		B string
10
		C int
11
		D struct {
12
			E float64
13
		}
14
	}
15
16
	f := fuzz.New()
17
	uniqueObject := SomeType{}
18
19
	uniqueObjects := map[SomeType]int{}
20
21
	for i := 0; i < 200; i++ {
22
		f.Fuzz(&object)
23
		uniqueObjects[object]++
24
	}
25
	fmt.Printf("Got %v unique objects.\n", len(uniqueObjects))
26
	// Output:

27
	// Got 200 unique objects.

28
}

Testen Sie Ihren Cache

Nahezu jedes komplexe System, das sich mit vielen Daten befasst, verfügt über einen Cache oder eher über mehrere Ebenen hierarchischer Caches. Wie das Sprichwort sagt, gibt es in der Informatik nur zwei schwierige Dinge: Benennen von Dingen, Ungültigmachen des Caches und Verschieben durch einen Fehler.

Abgesehen von Witzen kann die Verwaltung Ihrer Caching-Strategie und -Implementierung Ihren Datenzugriff erschweren, hat jedoch enorme Auswirkungen auf die Kosten und die Leistung Ihres Datenzugriffs. Das Testen Ihres Caches kann nicht von außen durchgeführt werden, da Ihre Schnittstelle verbirgt, woher die Daten stammen, und der Cache-Mechanismus ein Implementierungsdetail ist.

Lassen Sie uns sehen, wie Sie das Cache-Verhalten der Songify-Hybriddatenschicht testen.

Cache Hits und Misses

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

Go-Sichtbarkeitsregeln machen es unmöglich, sie direkt aufzurufen oder aus einem anderen Paket zu ersetzen. Um den Cache-Test zu aktivieren, ändere ich diese Methoden in öffentliche Funktionen. Dies ist in Ordnung, da der eigentliche Anwendungscode über die DataLayer-Schnittstelle ausgeführt wird, wodurch diese Methoden nicht verfügbar gemacht werden.

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:

1
func (m *HybridDataLayer) GetRedis() *redis.Client {
2
    return m.redis
3
}

Als nächstes werde ich die Methoden getSongByUser_DB() in eine öffentliche Funktionsvariable ändern. Jetzt kann ich im Test die Variable GetSongsByUser_DB() durch eine Funktion ersetzen, die verfolgt, wie oft sie aufgerufen wurde, und sie dann an die ursprüngliche Funktion weiterleitet. Auf diese Weise können wir überprüfen, ob ein Aufruf von GetSongsByUser() die Songs aus dem Cache oder aus der Datenbank abgerufen hat.

Lassen Sie es uns Stück für Stück aufschlüsseln. Zuerst erhalten wir die Datenschicht (die auch die Datenbank löscht und redis), erstellen einen Benutzer und fügen einen Song hinzu. Die AddSong()-Methode füllt auch Redis.

1
func TestGetSongsByUser_Cache(t *testing.T) {
2
    now := time.Now()
3
	u := User{Name: "Gigi", 
4
             Email: "gg@gg.com", 
5
             RegisteredAt: now, LastLogin: now}
6
	dl, err := getDataLayer()
7
	if err != nil {
8
		t.Error("Failed to create hybrid data layer")
9
	}
10
11
	err = dl.CreateUser(u)
12
	if err != nil {
13
		t.Error("Failed to create user")
14
	}
15
16
	lm, err := NewSongManager(u, dl)
17
	if err != nil {
18
		t.Error("NewSongManager() returned 'nil'")
19
	}
20
21
	err = lm.AddSong(testSong, nil)
22
	if err != nil {
23
		t.Error("AddSong() failed")
24
	}

Das ist der coole Teil. Ich behalte die ursprüngliche Funktion bei und definiere eine neue instrumentierte Funktion, die die lokale callCount-Variable erhöht (alles in einem Abschluss) und die ursprüngliche Funktion aufruft. Dann ordne ich die instrumentierte Funktion der Variablen GetSongsByUser_DB zu. Von nun an geht jeder Aufruf der hybriden Datenschicht an GetSongsByUser_DB() zur instrumentierten Funktion.

1
    callCount := 0
2
	originalFunc := GetSongsByUser_DB
3
	instrumentedFunc := func(m *HybridDataLayer, 
4
	                         email string, 
5
                             songs *[]Song) (err error) {
6
		callCount += 1
7
		return originalFunc(m, email, songs)
8
	}
9
10
	GetSongsByUser_DB = instrumentedFunc

An diesem Punkt sind wir bereit, den Cache-Vorgang tatsächlich zu testen. Zunächst ruft der Test den GetSongsByUser() des SongManager auf, der ihn an die hybride Datenschicht weiterleitet. Der Cache soll für diesen Benutzer gefüllt sein, den wir gerade hinzugefügt haben. Das erwartete Ergebnis ist also, dass unsere instrumentierte Funktion nicht aufgerufen wird und der callCount auf Null bleibt.

1
    _, err = lm.GetSongsByUser(u)
2
	if err != nil {
3
		t.Error("GetSongsByUser() failed")
4
	}
5
6
	// Verify the DB wasn't accessed because cache should be

7
    // populated by AddSong()

8
	if callCount > 0 {
9
		t.Error(`GetSongsByUser_DB() called when it
10
                 shouldn't have`)
11
	}

Der letzte Testfall besteht darin, sicherzustellen, dass die Daten des Benutzers, die sich nicht im Cache befinden, ordnungsgemäß aus der Datenbank abgerufen werden. Der Test führt dies durch, indem Redis gelöscht (alle Daten gelöscht) und GetSongsByUser() erneut aufgerufen wird. Dieses Mal wird die instrumentierte Funktion aufgerufen und der Test überprüft, ob callCount gleich 1 ist. Schließlich wird die ursprüngliche GetSongsByUser_DB()-Funktion wiederhergestellt.

1
    // Clear the cache

2
	dl.GetRedis().FlushDB()
3
4
	// Get the songs again, now it's should go to the DB

5
    // because the cache is empty

6
	_, err = lm.GetSongsByUser(u)
7
	if err != nil {
8
		t.Error("GetSongsByUser() failed")
9
	}
10
11
	// Verify the DB was accessed because the cache is empty

12
	if callCount != 1 {
13
		t.Error(`GetSongsByUser_DB() wasn't called once 
14
                 as it should have`)
15
	}
16
17
	GetSongsByUser_DB = originalFunc
18
}

Cache-Invalidierung

Unser Cache ist sehr einfach und führt keine Ungültigmachung durch. Dies funktioniert ziemlich gut, solange alle Songs über die AddSong()-Methode hinzugefügt werden, mit der Redis aktualisiert wird. Wenn wir weitere Vorgänge wie das Entfernen von Songs oder das Löschen von Benutzern hinzufügen, sollten diese Vorgänge dafür sorgen, dass Redis entsprechend aktualisiert wird.

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

Wenn jedoch die Datenbank und der Cache aufgrund von Wartungsvorgängen oder anderen Werkzeugs und Anwendungen, die unsere Daten ändern, nicht mehr synchron sind, müssen wir eine Richtlinie zur Ungültigmachung und Aktualisierung des Caches erstellen. Es kann mit denselben Techniken getestet werden: Ersetzen Sie die Zielfunktionen oder greifen Sie in Ihrem Test direkt auf die Datenbank und Redis zu, um den Status zu überprüfen.

LRU-Caches

Normalerweise können Sie den Cache nicht einfach unendlich wachsen lassen. Ein gängiges Schema, um die nützlichsten Daten im Cache zu halten, sind LRU-Caches (am wenigsten verwendet). Die ältesten Daten werden aus dem Cache gestoßen, wenn sie die Kapazität erreichen.

Zum Testen muss die Kapazität während des Tests auf eine relativ kleine Zahl eingestellt, die Kapazität überschritten und sichergestellt werden, dass sich die ältesten Daten nicht mehr im Cache befinden. Für den Zugriff ist ein DB-Zugriff erforderlich.

Testen Ihrer Datenintegrität

Ihr System ist nur so gut wie Ihre Datenintegrität. Wenn Sie Daten beschädigt haben oder Daten fehlen, sind Sie 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 prüfen, Fehler, die fehlerhafte Daten zulassen, Administratoren, die manuelle Korrekturen versuchen, 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, selbst wenn sich der Code nicht geändert hat. Sie möchten auf jeden Fall Datenintegritätsprüfungen nach Codeänderungen durchführen, die die Datenspeicherung oder -darstellung verändern könnten, 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 erzwingen lassen. Nullheit, Länge der Textfelder, Eindeutigkeit und 1-N-Beziehungen können einfach definiert werden. SQL kann jedoch nicht alle Einschränkungen überprüfen.

In Desongcious gibt es beispielsweise eine N-N-Beziehung zwischen Benutzern und Songs. Jedes Lied muss mindestens einem Benutzer zugeordnet sein. Es gibt keine gute Möglichkeit, dies in SQL durchzusetzen (Sie können einen Fremdschlüssel von Song zu Benutzer haben und den Song auf einen der damit verbundenen Benutzer verweisen lassen). Eine weitere Einschränkung kann sein, dass jeder Benutzer höchstens 500 Songs haben darf. Auch hier gibt es keine Möglichkeit, es in SQL darzustellen. Wenn Sie NoSQL-Datenspeicher verwenden, wird das Deklarieren und Validieren von Einschränkungen auf Datenspeicherebene normalerweise noch weniger unterstützt.

Damit haben Sie einige Möglichkeiten:

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

Idempotenz testen

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

Das Setzen der Variablen x auf 5 ist beispielsweise idempotent. Sie können x einmal oder millionenfach auf 5 setzen. Es wird immer noch 5 sein. Das Inkrementieren 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, die das Senden einer Nachricht mehrmals wiederholen, wenn keine sofortige Antwort erfolgt.

Wenn Sie Idempotenz in Ihren Datenzugriffscode einbauen, sollten Sie ihn testen. Dies ist normalerweise sehr einfach. Für jede idempotente Operation, die Sie erweitern, führen Sie die Operation zweimal oder mehrmals hintereinander aus und stellen Sie sicher, dass keine Fehler vorliegen und der Status gleich bleibt.

Beachten Sie, dass idempotentes Design manchmal Fehler verbergen kann. Löschen Sie einen Datensatz aus einer Datenbank. Es ist eine idempotente Operation. Nachdem Sie einen Datensatz gelöscht haben, ist der Datensatz nicht mehr im System vorhanden. Wenn Sie versuchen, ihn erneut zu löschen, wird er nicht zurückgesetzt. Das bedeutet, dass der Versuch, einen nicht vorhandenen Datensatz zu löschen, eine gültige Operation ist. Es kann jedoch die Tatsache maskieren, dass der Anrufer den falschen Aufzeichnungsschlüssel übergeben hat. Wenn Sie eine Fehlermeldung zurückgeben, ist diese nicht idempotent.

Testen von Datenmigrationen

Datenmigrationen können sehr riskant sein. Manchmal führen Sie ein Skript über alle Ihre Daten oder kritischen Teile Ihrer Daten aus und führen eine ernsthafte Operation durch. Sie sollten mit Plan B fertig sein, falls etwas schief geht (z. B. zu den Originaldaten zurückkehren und herausfinden, was schief gelaufen ist).

In vielen Fällen kann die Datenmigration ein langsamer und kostspieliger Vorgang sein, für den möglicherweise 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, die 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 nur (z. B. entfernt jemand einen Benutzer und alle seine Titel).

Wenn die fehlenden Daten ein Datenintegritätsproblem verursachen, werden Sie es in Ihren Datenintegritätstests erkennen. Wenn jedoch nur einige Daten fehlen, gibt es keine einfache Möglichkeit, diese zu erkennen. Wenn die Daten nie in einen dauerhaften Speicher gelangt sind, befindet sich möglicherweise eine Ablaufverfolgung in den Protokollen oder anderen temporären Speichern.

Je nachdem, wie hoch das Risiko ist, dass Daten fehlen, können Sie einige Tests schreiben, die absichtlich einige Daten aus Ihrem System entfernen und überprüfen, ob sich das System wie erwartet verhält.

Schlussfolgerung

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

Es gibt keine einzige richtige Antwort. Sie müssen Ihren Sweet Spot im Spektrum finden, von äußerst umfassenden, langsamen und arbeitsintensiven Tests bis hin zu schnellen und leichten Tests.

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Code tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.