Advertisement
  1. Code
  2. Go

Testen von datenintensivem Code mit Go, Teil 1

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

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

Überblick

Viele nicht triviale Systeme sind auch datenintensiv oder datengesteuert. Das Testen der Teile der Systeme, die datenintensiv sind, unterscheidet sich stark vom Testen codeintensiver Systeme. Erstens kann die Datenschicht selbst sehr ausgefeilt sein, z.B. hybride Datenspeicher, Caching, Backup und Redundanz.

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

In Teil 1 gehe ich auf das Design einer abstrakten Datenschicht ein, die ordnungsgemäße Tests ermöglicht, wie Fehler in der Datenschicht behandelt werden, wie Datenzugriffscode verspottet wird und wie gegen eine abstrakte Datenschicht getestet wird.

Testen gegen einen Data Layer

Der Umgang mit realen Datenspeichern und deren Komplikationen ist kompliziert und hängt nicht mit der Geschäftslogik zusammen. Das Konzept einer Datenschicht ermöglicht es Ihnen, eine übersichtliche Schnittstelle zu Ihren Daten bereitzustellen und die wichtigsten Details darüber zu verbergen, wie die Daten genau gespeichert sind und wie Sie darauf zugreifen können. Ich werde eine Beispielanwendung namens "Songify" für die persönliche Musikverwaltung verwenden, um die Konzepte mit echtem Code zu veranschaulichen.

Entwerfen einer abstrakten Datenschicht

Lassen Sie uns die persönliche Musikverwaltungsdomäne überprüfen - Benutzer können Songs hinzufügen und beschriften - und überlegen, welche Daten wir speichern müssen und wie wir darauf zugreifen können. Die Objekte in unserer Domain sind Benutzer, Songs und Labels. Es gibt zwei Kategorien von Vorgängen, die Sie für Daten ausführen möchten: Abfragen (schreibgeschützt) und Statusänderungen (erstellen, aktualisieren, löschen). Hier ist eine grundlegende Schnittstelle für die Datenschicht:

1
package abstract_data_layer
2
3
import "time"
4
5
type Song struct {
6
    Url         string
7
	Name        string
8
	Description string
9
}
10
11
type Label struct {
12
	Name string
13
}
14
15
type User struct {
16
	Name         string
17
	Email        string
18
	RegisteredAt time.Time
19
	LastLogin    time.Time
20
}
21
22
type DataLayer interface {
23
	// Queries (read-only)

24
	GetUsers() ([]User, error)
25
	GetUserByEmail(email string) (User, error)
26
	GetLabels() ([]Label, error)
27
	GetSongs() ([]Song, error)
28
	GetSongsByUser(user User) ([]Song, error)
29
	GetSongsByLabel(label string) ([]Song, error)
30
31
	// State changing operations

32
	CreateUser(user User) error
33
	ChangeUserName(user User, name string) error
34
	AddLabel(label string) error
35
	AddSong(user User, song Song, labels []Label) error
36
}

Beachten Sie, dass der Zweck dieses Domänenmodells ist eine einfache, jedoch nicht vollständig triviale Datenschicht darzustellen, um die Testaspekte zu demonstrieren. In einer realen Anwendung gibt es natürlich mehr Objekte wie Alben, Genres, Künstler und viel mehr Informationen zu jedem Song. Wenn es darauf ankommt, können Sie jederzeit beliebige Informationen zu einem Song in seiner Beschreibung speichern und so viele Labels anhängen, wie Sie möchten.

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

Was haben wir aus dieser abstrakten Datenschicht gewonnen?

  • One-Stop-Shop für Datenzugriffsvorgänge.
  • Klare Sicht auf die Datenverwaltungsanforderungen unserer Anwendungen in Bezug auf Domänen.
  • Möglichkeit, die Implementierung der konkreten Datenschicht nach Belieben zu ändern.
  • Die Fähigkeit, die Domain / Business-Logiklayer früh gegen die Schnittstelle zu entwickeln, bevor die konkrete Datenschicht vollständig oder stabil ist.
  • Last but not least, die Fähigkeit, die Datenschicht für eine schnelle und flexible Prüfung der Domain / Business-Logik zu verspotten.

Fehler und Fehlerbehandlung in der Data-Layer

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. Im Idealfall kann die Fehlerbehandlungslogik (Wiederholungsversuche, Zeitüberschreitungen, Benachrichtigung über katastrophale Fehler) von der konkreten Datenschicht behandelt werden. Der Domänenlogikcode sollte nur die Daten oder einen generischen Fehler zurückerhalten, wenn die Daten nicht erreichbar sind.

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

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 sich auf sehr allgemeine Fehlermeldungen verlassen.

Verspotten des Datenzugriffscodes

Verspotten wir unsere Datenschicht. Der Zweck des Modells besteht darin, die reale Datenschicht während der Tests zu ersetzen. Dies erfordert, dass die Scheindatenschicht dieselbe Schnittstelle verfügbar macht und auf jede Sequenz von Methoden mit einer vordefinierten (oder berechneten) Antwort reagieren kann.

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

Hier ist die Struktur der Scheindatenschicht.

1
package concrete_data_layer
2
3
import (
4
    . "abstract_data_layer"
5
)
6
7
8
const (
9
	GET_USERS = iota
10
	GET_USER_BY_EMAIL 
11
	GET_LABELS          
12
	GET_SONGS
13
	GET_SONGS_BY_USER
14
	GET_SONG_BY_LABEL
15
	ERRORS            
16
)
17
18
type MockDataLayer struct {
19
	Errors                  []error
20
	GetUsersResponses       [][]User
21
	GetUserByEmailResponses []User
22
	GetLabelsResponses      [][]Label
23
	GetSongsResponses       [][]Song
24
	GetSongsByUserResponses [][]Song
25
	GetSongsByLabelResponses[][]Song
26
	Indices                 []int
27
}
28
29
func NewMockDataLayer() MockDataLayer {
30
	return MockDataLayer{Indices: []int{0, 0, 0, 0, 0, 0, 0, 0}}
31
}

Die const-Anweisung listet alle unterstützten Operationen und Fehler auf. Jede Operation hat einen eigenen Index im Indices-Slice. 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 aussehen sollten.

Für jede Methode, die zusätzlich zu einem Fehler einen Rückgabewert hat, gibt es eine Reihe von Antworten. Wenn die Mock-Methode aufgerufen wird, werden die entsprechende Antwort und der entsprechende Fehler (basierend auf dem Index für diese Methode) zurückgegeben. Für Methoden, die außer einem Fehler keinen Rückgabewert haben, muss kein XXXResponses-Slice definiert werden.

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. Die Funktion NewMockDataLayer() gibt eine neue Struktur der Scheindatenschicht zurück, bei der alle Indizes auf Null initialisiert sind.

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

1
func(m *MockDataLayer) GetUsers() (users []User, err error) {
2
    i := m.Indices[GET_USERS]
3
	users = m.GetUsersResponses[i]
4
	if len(m.Errors) > 0 {
5
		err = m.Errors[m.Indices[ERRORS]]
6
		m.Indices[ERRORS]++
7
	}
8
	m.Indices[GET_USERS]++
9
	return
10
}

In der ersten Zeile wird der aktuelle Index der Operation GET_USERS abgerufen (anfänglich 0).

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

Die dritte bis fünfte Zeile weisen den Fehler des aktuellen Index zu, wenn das Feld Errors ausgefüllt wurde, und erhöhen den Fehlerindex. Beim Testen des Happy-Pfads ist der Fehler gleich Null. Um die Verwendung zu vereinfachen, können Sie einfach vermeiden, das Feld Errors zu initialisieren, und dann gibt jede Methode für den Fehler null zurück.

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

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

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

1
func(m *MockDataLayer) GetLabels() (labels []Label, err error) {
2
    i := m.Indices[GET_LABELS]
3
	labels = m.GetLabelsResponses[i]
4
	if len(m.Errors) > 0 {
5
		err = m.Errors[m.Indices[ERRORS]]
6
		m.Indices[ERRORS]++
7
	}
8
	m.Indices[GET_LABELS]++
9
	return
10
}

Dies ist ein Paradebeispiel für einen Anwendungsfall, bei dem Generika viel Code auf dem Boilerplate speichern könnten. Es ist möglich, die Reflexion mit demselben Effekt zu nutzen, sie liegt jedoch außerhalb des Rahmens dieses Lernprogramms. Der Hauptgrund dafür ist, dass die Scheindatenschicht einem allgemeinen Muster folgen und jedes Testszenario unterstützen kann, wie Sie gleich sehen werden.

Wie wäre es mit einigen Methoden, die nur einen Fehler zurückgeben? Überprüfen Sie die CreateUser() -Methode. Es ist noch einfacher, da nur Fehler behandelt werden und die vordefinierten Antworten nicht verwaltet werden müssen.

1
func(m *MockDataLayer) CreateUser(user User) (err error) {
2
    if len(m.Errors) > 0 {
3
		i := m.Indices[CREATE_USER]
4
		err = m.Errors[m.Indices[ERRORS]]
5
		m.Indices[ERRORS]++
6
	}
7
	return
8
}

Diese Scheindatenschicht ist nur ein Beispiel dafür, wie man eine Schnittstelle verspottet und einige nützliche Dienste zum Testen bereitstellt. Sie können eine eigene Mock-Implementierung erstellen oder verfügbare Mock-Bibliotheken verwenden. Es gibt sogar ein Standard-GoMock-Framework.

Ich persönlich finde Mock-Frameworks einfach zu implementieren und bevorzuge es, meine eigenen zu rollen (oft automatisch zu generieren), da ich den größten Teil meiner Entwicklungszeit damit verbringe, Tests zu schreiben und Abhängigkeiten zu verspotten. YMMV.

Testen gegen eine abstrakte Datenschicht

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

Angenommen, ein Benutzer möchte einen Titel hinzufügen, wir haben jedoch ein Kontingent von 100 Titeln pro Benutzer. Das erwartete Verhalten ist, dass wenn der Benutzer weniger als 100 Songs hat und der hinzugefügte Song neu ist, er hinzugefügt wird. Wenn das Lied bereits vorhanden ist, wird der Fehler "Lied duplizieren" zurückgegeben. Wenn der Benutzer bereits 100 Songs hat, wird der Fehler "Song-Kontingent überschritten" zurückgegeben.

Schreiben wir einen Test für diese Testfälle mithilfe unserer Scheindatenschicht. Dies ist ein White-Box-Test. Dies bedeutet, dass Sie wissen müssen, welche Methoden der Datenschicht der zu testende Code aufruft und in welcher Reihenfolge, damit Sie die Scheinantworten und -fehler ordnungsgemäß ausfüllen können. Der Test-First-Ansatz ist hier also nicht ideal. Schreiben wir zuerst den Code.

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

Der SongManager selbst ist völlig unabhängig von der konkreten Implementierung der DataLayer-Schnittstelle. Die SongManager-Struktur akzeptiert auch einen Benutzer, den sie speichert. Vermutlich hat jeder aktive Benutzer eine eigene SongManager-Instanz, und Benutzer können nur Songs für sich selbst hinzufügen. Die NewSongManager() -Funktion stellt sicher, dass die DataLayer-Eingabeschnittstelle nicht Null ist.

1
package song_manager
2
3
import (
4
    "errors"
5
	. "abstract_data_layer"
6
)
7
8
9
const (
10
	MAX_SONGS_PER_USER = 100
11
)
12
13
14
type SongManager struct {
15
    user User
16
	dal DataLayer
17
}
18
19
func NewSongManager(user User, 
20
                    dal DataLayer) (*SongManager, error) {
21
	if dal == nil {
22
		return nil, errors.New("DataLayer can't be nil")
23
	}
24
	return &SongManager{user, dal}, nil
25
}

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

1
func(lm *SongManager) AddSong(newSong Song, 
2
                              labels []Label) error {
3
    songs, err := lm.dal.GetSongsByUser(lm.user)
4
	if err != nil {
5
		return nil
6
	}
7
8
	// Check if song is a duplicate

9
	for _, song := range songs {
10
		if song.Url == newSong.Url {
11
			return errors.New("Duplicate song")
12
		}
13
	}
14
15
	// Check if user has max number of songs

16
	if len(songs) == MAX_SONGS_PER_USER {
17
		return errors.New("Song quota exceeded")
18
	}
19
20
	return lm.dal.AddSong(user, newSong, labels)
21
}

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

Das Feld GetSongsByUserResponses wird mit einem Slice gefüllt, das ein leeres Slice enthält. Dies führt zu einem leeren Slice, wenn der SongManager GetSongsByUser() auf der Scheindatenebene ohne Fehler aufruft. Für den Aufruf der AddSong() -Methode der Scheindatenschicht, die standardmäßig keinen Fehler zurückgibt, muss nichts unternommen werden. Der Test überprüft lediglich, dass vom übergeordneten Aufruf der AddSong() -Methode des SongManager tatsächlich kein Fehler zurückgegeben wurde.

1
package song_manager
2
3
import (
4
    "testing"
5
	. "abstract_data_layer"
6
	. "concrete_data_layer"
7
)
8
9
func TestAddSong_Success(t *testing.T) {
10
	u := User{Name:"Gigi", Email: "gg@gg.com"}
11
	mock := NewMockDataLayer()
12
	// Prepare mock responses

13
	mock.GetSongsByUserResponses = [][]Song{{}}
14
15
	lm, err := NewSongManager(u, &mock)
16
	if err != nil {
17
		t.Error("NewSongManager() returned 'nil'")
18
	}
19
    url := https://www.youtube.com/watch?v=MlW7T0SUH0E"

20
	err = lm.AddSong(Song{Url: url", Name: "Chacarron"}, nil)

21
	if err != nil {
22
		t.Error("AddSong() failed")
23
	}
24
}
25
26
$ go test
27
PASS
28
ok  	song_manager	0.006s

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

1
func TestAddSong_Duplicate(t *testing.T) {
2
    u := User{Name:"Gigi", Email: "gg@gg.com"}
3
4
	mock := NewMockDataLayer()
5
	// Prepare mock responses

6
	mock.GetSongsByUserResponses = [][]Song{{testSong}}
7
8
	lm, err := NewSongManager(u, &mock)
9
	if err != nil {
10
		t.Error("NewSongManager() returned 'nil'")
11
	}
12
13
	err = lm.AddSong(testSong, nil)
14
	if err == nil {
15
		t.Error("AddSong() should have failed")
16
	}
17
18
	if err.Error() != "Duplicate song" {
19
		t.Error("AddSong() wrong error: " + err.Error())
20
	}
21
}

Die folgenden zwei Testfälle testen, ob die richtige Fehlermeldung zurückgegeben wird, wenn die Datenschicht selbst ausfällt. Im ersten Fall gibt GetSongsByUser() der Datenschicht einen Fehler zurück.

1
func TestAddSong_DataLayerFailure_1(t *testing.T) {
2
    u := User{Name:"Gigi", Email: "gg@gg.com"}
3
4
	mock := NewMockDataLayer()
5
	// Prepare mock responses
6
	mock.GetSongsByUserResponses = [][]Song{{}}
7
	e := errors.New("GetSongsByUser() failure")
8
	mock.Errors = []error{e}
9
10
	lm, err := NewSongManager(u, &mock)
11
	if err != nil {
12
		t.Error("NewSongManager() returned 'nil'")
13
	}
14
15
	err = lm.AddSong(testSong, nil)
16
	if err == nil {
17
		t.Error("AddSong() should have failed")
18
	}
19
20
	if err.Error() != "GetSongsByUser() failure" {
21
		t.Error("AddSong() wrong error: " + err.Error())
22
	}
23
}

Im zweiten Fall gibt die AddSong() -Methode der Datenschicht einen Fehler zurück. Da der erste Aufruf von GetSongsByUser() erfolgreich sein sollte, enthält das Slice mock.Errors zwei Elemente: null für den ersten Aufruf und den Fehler für den zweiten Aufruf.

1
func TestAddSong_DataLayerFailure_2(t *testing.T) {
2
    u := User{Name:"Gigi", Email: "gg@gg.com"}
3
4
	mock := NewMockDataLayer()
5
	// Prepare mock responses

6
	mock.GetSongsByUserResponses = [][]Song{{}}
7
	e := errors.New("AddSong() failure")
8
	mock.Errors = []error{nil, e}
9
10
	lm, err := NewSongManager(u, &mock)
11
	if err != nil {
12
		t.Error("NewSongManager() returned 'nil'")
13
	}
14
15
	err = lm.AddSong(testSong, nil)
16
	if err == nil {
17
		t.Error("AddSong() should have failed")
18
	}
19
20
	if err.Error() != "AddSong() failure" {
21
		t.Error("AddSong() wrong error: " + err.Error())
22
	}
23
}

Schlussfolgerung

In diesem Tutorial haben wir das Konzept einer abstrakten Datenschicht vorgestellt. Anschließend haben wir anhand der persönlichen Musikverwaltungsdomäne gezeigt, wie eine Datenschicht entworfen, eine Scheindatenschicht erstellt und die Scheindatenschicht zum Testen der Anwendung verwendet wird.

In Teil zwei konzentrieren wir uns auf das Testen unter Verwendung einer realen In-Memory-Datenschicht. Bleib dran.

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.