1. Einführung in das Unit Testing

Unit Testing bezieht sich auf das Überprüfen und Validieren der kleinsten testbaren Einheit in einem Programm, wie etwa einer Funktion oder einer Methode in der Go-Sprache. Durch Unit Testing wird sichergestellt, dass der Code wie erwartet funktioniert und ermöglicht es Entwicklern, Änderungen am Code vorzunehmen, ohne versehentlich bestehende Funktionalitäten zu beeinträchtigen.

In einem Golang-Projekt ist die Bedeutung des Unit Testings selbstverständlich. Erstens kann es die Code-Qualität verbessern und den Entwicklern mehr Vertrauen bei der Durchführung von Code-Änderungen geben. Zweitens kann das Unit Testing als Dokumentation für den Code dienen und sein erwartetes Verhalten erklären. Darüber hinaus können durch das automatische Ausführen von Unit Tests in einer Continuous Integration-Umgebung schnell neu eingeführte Fehler entdeckt werden, was die Stabilität der Software verbessert.

2. Durchführen von Grundtests mit dem testing-Paket

Die Standardbibliothek der Go-Sprache enthält das testing-Paket, das Werkzeuge und Funktionalitäten zum Schreiben und Ausführen von Tests bietet.

2.1 Erstellen Ihres ersten Testfalls

Um eine Testfunktion zu schreiben, müssen Sie eine Datei mit dem Suffix _test.go erstellen. Wenn beispielsweise Ihre Quellcodedatei calculator.go heißt, sollte Ihre Testdatei calculator_test.go heißen.

Als nächstes ist es an der Zeit, die Testfunktion zu erstellen. Eine Testfunktion muss das testing-Paket importieren und einem bestimmten Format folgen. Hier ist ein einfaches Beispiel:

// calculator_test.go
package calculator

import (
	"testing"
	"fmt"
)

// Test der Additionsfunktion
func TestAdd(t *testing.T) {
	result := Add(1, 2)
	expected := 3

if result != expected {
		t.Errorf("Erwartet wurde %v, aber erhalten wurde %v", expected, result)
	}
}

In diesem Beispiel ist TestAdd eine Testfunktion, die eine imaginäre Add-Funktion testet. Wenn das Ergebnis der Add-Funktion mit dem erwarteten Ergebnis übereinstimmt, schlägt der Test fehl; andernfalls wird t.Errorf aufgerufen, um Informationen über das Testversagen aufzuzeichnen.

2.2 Verständnis der Benennungsregeln und Signatur von Testfunktionen

Testfunktionen müssen mit Test beginnen, gefolgt von einer beliebigen Nicht-Kleinbuchstaben-Zeichenkette, und ihr einziger Parameter muss ein Zeiger auf testing.T sein. Wie im Beispiel gezeigt, folgt TestAdd den korrekten Benennungsregeln und der Signatur.

2.3 Ausführen von Testfällen

Sie können Ihre Testfälle mit dem Befehlszeilentool ausführen. Für einen bestimmten Testfall führen Sie den folgenden Befehl aus:

go test -v // Führt Tests im aktuellen Verzeichnis aus und zeigt detaillierte Ausgabe an

Wenn Sie einen bestimmten Testfall ausführen möchten, können Sie die -run-Flagge gefolgt von einem regulären Ausdruck verwenden:

go test -v -run TestAdd // Führt nur die Testfunktion TestAdd aus

Der Befehl go test findet automatisch alle _test.go-Dateien und führt jede Testfunktion aus, die den Kriterien entspricht. Wenn alle Tests bestanden werden, wird eine Meldung ähnlich wie PASS in der Befehlszeile angezeigt; wenn ein Test fehlschlägt, wird FAIL zusammen mit der entsprechenden Fehlermeldung angezeigt.

3. Schreiben von Testfällen

3.1 Fehler mithilfe von t.Errorf und t.Fatalf melden

In der Go-Sprache stellt das Test-Framework verschiedene Methoden zum Melden von Fehlern bereit. Die zwei am häufigsten verwendeten Funktionen sind Errorf und Fatalf, beide sind Methoden des Objekts testing.T. Errorf wird verwendet, um Fehler im Test zu melden, stoppt jedoch den aktuellen Testfall nicht, während Fatalf den aktuellen Test sofort nach Melden eines Fehlers stoppt. Es ist wichtig, die geeignete Methode je nach Testanforderungen zu wählen.

Beispiel für die Verwendung von Errorf:

func TestAdd(t *testing.T) {
    got := Add(1, 2)
    want := 3
    if got != want {
        t.Errorf("Add(1, 2) = %d; erwarte %d", got, want)
    }
}

Wenn Sie den Test sofort stoppen möchten, wenn ein Fehler erkannt wird, können Sie Fatalf verwenden:

func TestSubtract(t *testing.T) {
    got := Subtract(5, 3)
    if got != 2 {
        t.Fatalf("Subtract(5, 3) = %d; erwarte 2", got)
    }
}

Im Allgemeinen wird empfohlen, Fatalf zu verwenden, wenn der Fehler dazu führen würde, dass nachfolgender Code nicht richtig ausgeführt wird oder das Testversagen im Voraus bestätigt werden kann. Andernfalls wird empfohlen, Errorf zu verwenden, um ein umfassenderes Testergebnis zu erhalten.

3.2 Organisieren von Subtests und Ausführen von Subtests

In Go können wir t.Run verwenden, um Subtests zu organisieren, was uns hilft, Testcode auf strukturiertere Weise zu schreiben. Subtests können ihr eigenes Setup und Teardown haben und können individuell ausgeführt werden, was eine große Flexibilität bietet. Dies ist besonders nützlich für die Durchführung von komplexen Tests oder parameterisierten Tests.

Beispiel für die Verwendung von Subtest t.Run:

func TestMultiply(t *testing.T) {
    testcases := []struct {
        name           string
        a, b, expected int
    }{
        {"2x3", 2, 3, 6},
        {"-1x-1", -1, -1, 1},
        {"0x4", 0, 4, 0},
    }

    for _, tc := range testcases {
        t.Run(tc.name, func(t *testing.T) {
            if got := Multiply(tc.a, tc.b); got != tc.expected {
                t.Errorf("Multiply(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.expected)
            }
        })
    }
}

Wenn wir beispielsweise den Subtest mit dem Namen "2x3" individuell ausführen möchten, können wir den folgenden Befehl in der Befehlszeile ausführen:

go test -run TestMultiply/2x3

Bitte beachten Sie, dass Subtestnamen Groß- und Kleinschreibung beachten.

4. Vorbereitende Arbeiten vor und nach dem Testen

4.1 Setup und Teardown

Bei der Durchführung von Tests müssen wir oft einige Ausgangszustände für die Tests vorbereiten (wie z. B. die Verbindung zur Datenbank, Erstellung von Dateien usw.), und ähnlich müssen wir einige Aufräumarbeiten nach Abschluss der Tests durchführen. In Go führen wir normalerweise Setup und Teardown direkt in den Testfunktionen durch, und die Funktion t.Cleanup bietet uns die Möglichkeit, Aufrufrückruffunktionen zu registrieren.

Hier ist ein einfaches Beispiel:

func TestDatabase(t *testing.T) {
    db, err := SetupDatabase()
    if err != nil {
        t.Fatalf("Setup fehlgeschlagen: %v", err)
    }

    // Registrieren einer Aufräumarbeitsrückruffunktion, um sicherzustellen, dass die Datenbankverbindung geschlossen wird, wenn der Test abgeschlossen ist
    t.Cleanup(func() {
        if err := db.Close(); err != nil {
            t.Errorf("Fehler beim Schließen der Datenbank: %v", err)
        }
    })

    // Führe den Test durch...
}

In der Funktion TestDatabase rufen wir zuerst die Funktion SetupDatabase auf, um die Testumgebung einzurichten. Dann verwenden wir t.Cleanup(), um eine Funktion zu registrieren, die nach Abschluss des Tests aufgerufen wird, um Aufräumarbeiten durchzuführen, in diesem Beispiel das Schließen der Datenbankverbindung. Auf diese Weise können wir sicherstellen, dass Ressourcen unabhängig davon, ob der Test erfolgreich oder fehlerhaft ist, korrekt freigegeben werden.

5. Verbesserung der Testeffizienz

Die Verbesserung der Testeffizienz kann uns dabei helfen, die Entwicklung schneller durchzuführen, Probleme schnell zu entdecken und die Codequalität zu gewährleisten. Im Folgenden werden Testabdeckung, tabellenbasierte Tests und die Verwendung von Mocks zur Verbesserung der Testeffizienz erörtert.

5.1 Testabdeckung und damit verbundene Tools

Das Tool go test bietet eine sehr nützliche Testabdeckungsfunktion, die uns dabei hilft zu verstehen, welche Teile des Codes von den Testfällen abgedeckt werden und somit Bereiche des Codes zu entdecken, die nicht von den Testfällen abgedeckt werden.

Mit dem Befehl go test -cover können Sie den aktuellen Testabdeckungsprozentsatz sehen:

go test -cover

Möchten Sie genauere Informationen darüber, welche Codezeilen ausgeführt wurden und welche nicht, können Sie den Parameter -coverprofile verwenden, der eine Coverage-Daten-Datei generiert. Daraufhin können Sie den Befehl go tool cover verwenden, um einen detaillierten Testabdeckungsbericht zu generieren.

go test -coverprofile=coverage.out
go tool cover -html=coverage.out

Der obige Befehl wird einen Webbericht öffnen, der visuell zeigt, welche Codezeilen von Tests abgedeckt sind und welche nicht. Grün repräsentiert getesteten Code, während Rot Zeilen von Code darstellt, die nicht abgedeckt sind.

5.2 Verwendung von Mocks

In der Testphase stoßen wir oft auf Szenarien, in denen wir externe Abhängigkeiten simulieren müssen. Mocks können uns dabei helfen, diese Abhängigkeiten zu simulieren und somit die Notwendigkeit der Nutzung spezifischer externer Dienste oder Ressourcen in der Testumgebung zu beseitigen.

In der Go-Community gibt es viele Mock-Tools, wie z. B. testify/mock und gomock. Diese Tools bieten in der Regel eine Reihe von APIs zur Erstellung und Nutzung von Mock-Objekten.

Hier ist ein grundlegendes Beispiel zur Verwendung von testify/mock. Zunächst definieren wir eine Schnittstelle und deren Mock-Version:

type DataService interface {
    FetchData() (int, error)
}

type MockDataService struct {
    mock.Mock
}

func (m *MockDataService) FetchData() (int, error) {
    args := m.Called()
    return args.Int(0), args.Error(1)
}

Im Test können wir MockDataService verwenden, um den tatsächlichen Datendienst zu ersetzen:

func TestSomething(t *testing.T) {
    mockDataSvc := new(MockDataService)
    mockDataSvc.On("FetchData").Return(42, nil) // Konfiguration des erwarteten Verhaltens

    result, err := mockDataSvc.FetchData() // Verwendung des Mock-Objekts
    assert.NoError(t, err)
    assert.Equal(t, 42, result)

    mockDataSvc.AssertExpectations(t) // Überprüfung, ob das erwartete Verhalten aufgetreten ist
}

Durch diesen Ansatz können wir in Tests auf externe Dienste, Datenbankaufrufe usw. verzichten oder diese isolieren. Dies kann die Ausführung von Tests beschleunigen und unsere Tests stabiler und zuverlässiger machen.

6. Fortgeschrittene Testtechniken

Nachdem wir die Grundlagen des Go-Unit-Testings gemeistert haben, können wir einige fortgeschrittenere Testtechniken erkunden, die dazu beitragen, robustere Software zu entwickeln und die Testeffizienz zu verbessern.

6.1 Testen von privaten Funktionen

In Golang beziehen sich private Funktionen in der Regel auf nicht exportierte Funktionen, d. h. Funktionen, deren Namen mit einem Kleinbuchstaben beginnen. Normalerweise bevorzugen wir es, öffentliche Schnittstellen zu testen, da sie die Benutzerfreundlichkeit des Codes widerspiegeln. Es gibt jedoch Fälle, in denen es auch sinnvoll ist, private Funktionen direkt zu testen, z. B. wenn die private Funktion komplexe Logik aufweist und von mehreren öffentlichen Funktionen aufgerufen wird.

Das Testen privater Funktionen unterscheidet sich vom Testen öffentlicher Funktionen, da sie von außerhalb des Pakets nicht zugegriffen werden können. Eine gängige Technik besteht darin, den Testcode im selben Paket zu schreiben, um Zugriff auf die private Funktion zu ermöglichen.

Hier ist ein einfaches Beispiel:

// calculator.go
package calculator

func add(a, b int) int {
    return a + b
}

Die entsprechende Testdatei lautet wie folgt:

// calculator_test.go
package calculator

import "testing"

func TestAdd(t *testing.T) {
    expected := 4
    actual := add(2, 2)
    if actual != expected {
        t.Errorf("erwartet %d, erhalten %d", expected, actual)
    }
}

Durch Platzierung der Testdatei im selben Paket können wir die add-Funktion direkt testen.

6.2 Häufige Testmuster und bewährte Praktiken

Googles Unit-Testing hat einige gängige Muster, die die Testarbeit erleichtern und dazu beitragen, die Code-Klarheit und -wartbarkeit aufrechtzuerhalten.

  1. Tabellenbasierte Tests

    Tabellenbasiertes Testen ist eine Methode zur Organisation von Testeingaben und erwarteten Ausgaben. Durch die Definition einer Reihe von Testfällen und deren Durchlauf für Tests macht diese Methode das Hinzufügen neuer Testfälle sehr einfach und erleichtert außerdem das Lesen und die Wartung des Codes.

    // calculator_test.go
    package calculator

    import "testing"

    func TestAddTableDriven(t *testing.T) {
        var tests = []struct {
            a, b   int
            want   int
        }{
            {1, 2, 3},
            {2, 2, 4},
            {5, -1, 4},
        }

        for _, tt := range tests {
            testname := fmt.Sprintf("%d,%d", tt.a, tt.b)
            t.Run(testname, func(t *testing.T) {
                ans := add(tt.a, tt.b)
                if tt.want != ans {
                    t.Errorf("erhalten %d, erwartet %d", ans, tt.want)
                }
            })
        }
    }
  1. Verwendung von Mocks für Tests

    Mocking ist eine Testtechnik, bei der Abhängigkeiten ersetzt werden, um verschiedene Teile der Funktionalität zu testen. In Golang sind Schnittstellen der hauptsächliche Weg zur Implementierung von Mocks. Durch die Verwendung von Schnittstellen kann eine Mock-Implementierung erstellt und dann im Test verwendet werden.