Top Go-Module: Schreiben von Unit-Tests mit Testify

UPDATE: Am 1. Mai 2021 wird das zentrale Repository von GoCenter einschließlich aller Funktionen eingestellt. Weitere Informationen zur Einstellung der Center finden Sie im Blog-Beitrag zur Einstellung

 

 

Jeden Monat verleiht GoCenter den leistungsstärksten Modulen ein Gopher Badge als Auszeichnung für ihre Leistung. Wir schreiben über einige dieser Top-Module und wie sie in Go verwendet werden.

Jeder Entwickler hat sie schon gesehen, selbst in gut strukturierten Golang-Programmen: Kommentare, die vorschlagen, dass man sich von Codezeilen fernhalten sollte, da diese auf magische Weise zu funktionieren scheinen. Diese Warnungen machen uns ängstlich, weil wir glauben, wir könnten etwas kaputt machen. Aber Anwendungen müssen sich ändern, um besser zu werden und innovativ zu sein.

Deshalb sind Unit-Tests ein wichtiger Bestandteil der Softwareentwicklung. Sie helfen den Entwicklern zu wissen, ob die kleinen Teile ihrer Software ihre vorgesehene Funktion korrekt erfüllen. Mit dem richtigen Umfang an Unit-Tests fühlen sich Entwickler sicherer, ihre Implementierungen zu ändern und sogar von Grund auf neu zu refaktorisieren. Sie wissen dann nämlich, dass sie leicht überprüfen können, ob die neue Version immer noch wie vorgesehen funktioniert.

Je komplexer die Software wird, desto wichtiger werden Unit-Tests, ebenso wie ein solider Satz von dafür vorgesehenen Tools in der gleichen Sprache. Und da Testcode mit guter Abdeckung sehr umfangreich sein kann, muss er genauso lesbar und wartbar sein wie der Produktcode, damit Entwickler ihn verwenden und seine Vorteile nutzen.

Für unsere Go-Community-Projekte wie GoCenter nutzen wir ausgiebig das beliebte Testify-Modul, das eine Reihe von Golang-Paketen für die Durchführung wichtiger Unit-Test-Funktionen bereitstellt.

In diesem Artikel erfahren Sie, wie Sie die wichtigsten Funktionen von Testify nutzen können, um Unit-Tests in Go zu schreiben, die einfach zu lesen und zu warten sind. Der Artikel beschreibt, wie Unit-Tests bei der Verwendung von reinem Go aussehen würden, wobei die Pakete von Testify vorgestellt werden, die bei der zu erledigenden Aufgabe helfen können. Anschließend sehen Sie den Code, der sich nach der Anwendung von Testify ergibt. Wir zeigen einige Best Practices, wie man Assertions durchführt und Mocks für Abhängigkeiten schreibt.

Testify: Ein Top-Gopher

Testify ist ein entwicklerfreundlicher Satz von Paketen mit über 11.000 Sternen auf GitHub und großer Unterstützung durch die Community. Testify erweitert das schlanke Test -Framework von Go, um Assertions und Mock-Abhängigkeiten durchzuführen.

Diese Funktionen sowie das tägliche Vertrauen unseres Go-Community-Teams darauf sind ein großer Teil der Gründe, warum das Testify-Modul als "Top-Gopher" im GoCenter ausgezeichnet wurde. Wenn Sie sich die umfangreichen Metadaten von GoCenter für das Testify-Modul ansehen, wissen Sie, warum:

  • Die ReadMe des Moduls verweist Sie auf eine umfangreiche Dokumentation. Mehr Details über den Code des Moduls erfahren wir über die GoDoc-Registerkarte, die eine automatisch generierte Dokumentation von Funktionen und mehr zeigt.
  • Die Used By– und Metrics-Registerkarten von GoCenter zeigen, dass dieses Modul angesichts vieler Downloads, Forks, Mitwirkenden und der Verwendung durch andere Go-Module beliebt und weithin vertrauenswürdig ist.
  • Die Security-Registerkarte von GoCenter zeigt auch, dass die aktuelle Version dieses Moduls und seiner Abhängigkeiten keine bekannten NVD-Schwachstellen aufweisen, was durch einen JFrog Xray-Tiefenscan bestätigt wird.

Eine einfache GoLang-Unit

Um mit dem Schreiben der Unit-Tests zu beginnen, benötigen wir zunächst eine zu prüfende Komponente. Für diese Übung werden wir die folgende Dienstdefinition verwenden:

type ProductService interface {
	IsProductReservable(id int) (bool, error)
}

 

Für diese Dienstdefinition haben wir eine Implementierung und wir wollen diese zu testen. Die Implementierung verfügt über einige Business-Logik, um zu bestimmen, ob ein Produkt reservierbar ist oder nicht. Die Implementierung hängt auch von einer Data Access Object-Komponente ab, um Informationen über die Produkte bereitzustellen. Die Implementierung muss die folgenden vereinfachten Testfälle bestehen:

  • Die Dienstimplementierung muss die Dienstdefinition beachten
  • Produkte, die vor mehr als 1 Jahr in den Katalog aufgenommen wurden, sind reservierbar
  • Andere Produkte sind nicht reservierbar
  • Produkte, die nicht im Katalog enthalten sind, sollten einen"Produkt nicht gefunden"-Fehler verursachen

Die Dienstimplementierung sieht wie folgt aus:

type ProductServiceImpl struct {
	productDAO persist.ProductDAO
}

// Constructor
func NewProductServiceImpl(dao persist.ProductDAO) *ProductServiceImpl {
	return &ProductServiceImpl{
		productDAO: dao,
	}
}

func (s *ProductServiceImpl) IsProductReservable(id int) (bool, error) {
	// Produktinformation aus Datenbank holen
	product, err := s.productDAO.GetProduct(id)
	if err != nil {
		return false, fmt.Errorf("failed to get product details: %w", err)
	}

	if product == nil {
		return false, fmt.Errorf("product not found for id %v", id)
	}

	// Nur Produkte, die vor mehr als 1 Jahr in den Katalog aufgenommen wurden, können reserviert werden
	return product.CreatedAt.Before(time.Now().AddDate(-1, 0, 0)), nil
}

Testify verwenden

Da wir nun einen einfachen Dienst haben, können wir mit Testify Unit-Tests erstellen, die gewährleisten, dass er wie vorgesehen funktioniert.

Assertions ausführen

Die grundlegendsten Aufgaben, die von Unit-Tests ausgeführt werden, sind Assertions. Assertions werden in der Regel verwendet, um zu überprüfen, ob die vom Test durchgeführten Aktionen unter Verwendung bestimmter Eingaben das erwartete Ergebnis erzeugen. Sie können auch verwendet werden, um zu prüfen, ob die Komponenten den gewünschten Designregeln entsprechen.

Wenn wir mit reinem Go die Assertions ausführen, die benötigt werden, um zu prüfen, ob der erste Testfall anerkannt und unsere Dienstimplementierung richtig initialisiert wird, erhalten wir den folgenden Code:

import (	
	"service"
	"testing"
)

func TestNewProductServiceImpl(t *testing.T) {
	productDaoMock := ProductDaoMock{} // Mock vorerst ignorieren
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	// Sichert ProductServiceImpl zu implementiert ProductService. Bricht den Compiler ab, wenn er es nicht tut.
	var _ service.ProductService = productServiceImpl

	if productServiceImpl == nil {
		t.Fatal("Produktdienst nicht initialisiert")
	}

	if productServiceImpl.productDAO == nil {
		t.Fatal("Produktdienst-Abhängigkeit nicht initialisiert")
	}
}

Als Hilfe für die Assertions enthält Testify das Paket github.com/stretchr/testify/assert. Dieses Paket bietet mehrere Methoden, die helfen können, Werte mit erwarteten Ergebnissen zu vergleichen. Wenn wir unsere Vergleiche durch diese Methoden ersetzen, erhalten wir folgendes:

import (
	"github.com/stretchr/testify/assert"
	"service"
	"testing"
)

func TestNewProductServiceImpl(t *testing.T) {
	assertions := assert.New(t)
	productDaoMock := ProductDaoMock{} // Mock vorerst ignorieren
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	if !assertions.Implements((*service.ProductService)(nil), new(ProductServiceImpl)) {
		t.Fatal("Produktdienst-Implementierung beachtet Dienstdefinition nicht")
	}

	if !assertions.NotNil(productServiceImpl, "Produktdienst nicht initialisiert") {
		t.Fatal("Produktdienst nicht initialisiert")
	}

	if !assertions.NotNil(productServiceImpl.productDAO, "Produktdienst-Abhängigkeit nicht initialisiert") {
		t.Fatal("Produktdienst-Abhängigkeit nicht initialisiert")
	}
}

Neben der Hilfe bei den Assertions bieten die Testify-Pakete auch eine bessere Benachrichtigung, wenn eine dieser Operationen fehlschlägt. Wenn wir zum Beispiel vergessen haben, das productDAO-Feld im Konstruktor der Dienstimplementierung zu setzen, würden wir den folgenden Testfehler erhalten:

=== RUN   TestNewProductServiceImpl
    TestNewProductServiceImpl: product_service_impl_test.go:22:
        	Error Trace:	product_service_impl_test.go:22
        	Error:      	Erwarteter Wert darf nicht Null sein.
        	Test:       	TestNewProductServiceImpl
        	Meldungen:   	Produktdienst-Abhängigkeit nicht initialisiert
    TestNewProductServiceImpl: product_service_impl_test.go:23: Produktdienst-Abhängigkeit nicht initialisiert
--- FAIL: TestNewProductServiceImpl (0.00s)

Bisher konnten wir trotz besserem Messaging und bequemerer Methoden zur Ausführung der Assertions den Umfang unseres Tests nicht reduzieren. Wir haben immer noch ein sich wiederholendes Muster von "if-not-assertion-break", das es schwieriger machen kann, unseren Testcode zu lesen. Um dabei zu helfen, enthält Testify das Paket github.com/stretchr/testify/require. Dieses Paket verfügt über die gleichen Assertion-Methoden wie das assert-Paket, bricht aber den Test sofort ab, wenn eine Assertion fehlschlägt. Wenn wir dieses Paket einführen, erhalten wir den folgenden kürzeren und leichter zu lesenden Testcode:

import (
	"github.com/stretchr/testify/require"
	"service"
	"testing"
)

func TestNewProductServiceImpl(t *testing.T) {
	assertions := require.New(t)

	productDaoMock := ProductDaoMock{} // Mock vorerst ignorieren
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	assertions.Implements((*service.ProductService)(nil), new(ProductServiceImpl), "Produktdienst-Implementierung beachtet Dienstdefinition nicht")
	assertions.NotNil(productServiceImpl, "Produktdienst nicht initialisiert")
	assertions.NotNil(productServiceImpl.productDAO, "Produktdienst-Abhängigkeit nicht initialisiert")
}

 

Mocking-Abhängigkeiten

Wenn wir eine Komponente testen, wollen wir sie im Idealfall vollständig isolieren, um zu vermeiden, dass Fehler an anderer Stelle unsere Tests beeinträchtigen. Dies ist besonders schwierig, wenn die Komponente, die wir testen wollen, Abhängigkeiten zu anderen Komponenten aus verschiedenen Schichten unserer Software hat. In dem hier verwendeten Szenario hängt unsere Dienstimplementierung von einer Komponente aus der Data Access Object (DAO)-Schicht ab, um auf Informationen über die Produkte zuzugreifen.

Um die gewünschte Isolation zu fördern, ist es üblich, dass Entwickler unechte, vereinfachte Implementierungen dieser Abhängigkeiten schreiben, die während der Tests verwendet werden. Diese unechten Implementierungen werden Mocks genannt.

Wir können eine Mock-Implementierung des ProductDAO erstellen, die in die Dienstimplementierung für die Testausführung injiziert wird. Die ProductDAO-Schnittstelle, die unser Mock implementieren muss, sieht wie folgt aus:

type ProductDAO interface {
	GetProduct(id int) (*model.Product, error)
}

Um die Testausführung zu ermöglichen, ist es notwendig, dass der Mock ein Verhalten zeigt, das mit allen Testfällen, die wir validieren wollen, kompatibel ist, da wir sonst nicht die gewünschte Testabdeckung erreichen können. Mit reinem Go würde unser Testfall mit dem Mock wie folgt aussehen:

import (
	"errors"
	"model"
	"persist"
	"testing"
	"time"
)

type ProductDaoMock struct {
}

func (m *ProductDaoMock) GetProduct(id int) (*model.Product, error) {
	switch id {
	case 1:
		return &model.Product{
			Id:          1,
			Description: "Produkt wurde vor 2 Jahren erstellt",
			CreatedAt:   time.Now().AddDate(-2, 0, 0),
		}, nil
	case 2:
		return &model.Product{
			Id:          2,
			Description: "Produkt kürzlich erstellt",
			CreatedAt:   time.Now(),
		}, nil
	case 999:
		return nil, persist.ErrProductNotFound
	}
	return nil, nil
}

func TestProductServiceImpl_IsProductReservable(t *testing.T) {
	testDataSet := map[int]bool {
		1: true,
		2: false,
	}

	productDaoMock := ProductDaoMock{}
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	for productId, expectedResult := range testDataSet {
		reservable, err := productServiceImpl.IsProductReservable(productId)
		if err != nil {
			t.Fatalf("Es konnte nicht geprüft werden, ob Produkt %v reservierbar ist: %s", productId, err)
		}

		if reservable != expectedResult {
			t.Fatalf("Falsche Reservierungsinfo für Produkt-ID erhalten %v. Expected: %v. Got: %v", productId, expectedResult, reservable)
		}
	}
}

func TestProductServiceImpl_IsProductReservable_NotFound(t *testing.T) {
	productDaoMock := ProductDaoMock{}
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	_, err := productServiceImpl.IsProductReservable(999)
	if !errors.Is(err, persist.ErrProductNotFound) {
		t.Fatalf("Unerwartetes Fehlerergebnis erhalten: %s", err)
	}
}

 

Das Hauptproblem mit dem obigen Ansatz ist, dass unsere Testfalllogik nun verteilt ist. Ein Teil davon ist im Testfall selbst implementiert, wo wir Ereignisse an die getestete Komponente senden und Assertions mit den Ergebnissen ausführen, während der andere Teil im Mock implementiert ist. Dieser wiederum muss ein Verhalten bereitstellen, das mit dem des Testfalls kompatibel ist. Es ist leicht zu erkennen, wie unser Testfall jetzt abgebrochen werden könnte, und zwar nicht wegen eines Problems im Test selbst, sondern weil der Mock nicht die erforderlichen Daten zurückgibt.

Ein weiteres Problem, das die Sache noch frustrierender machen kann, ist die Tatsache, dass wir den Mock auf mehrere Testfälle verteilen. Es ist möglich, dass Änderungen, die am Mock vorgenommen werden, um die Erfordernisse eines Testfalls zu erfüllen, andere Testfälle zerstören. In unserem Szenario haben wir nur 3 Testfälle, um die wir uns kümmern. Sie können sich aber vorstellen, wie unübersichtlich es werden könnte, wenn wir komplexere Testfälle hätten. Eine Aufteilung des Mocks in mehrere würde nicht unbedingt helfen, sondern könnte die Komplexität sogar noch erhöhen. Wenn sich unsere gemockte Schnittstelle ändert, müssten wir außerdem mehrere Mocks aktualisieren, um sie kompatibel zu halten.

Was wir brauchen, ist, dass die Testfalllogik zentralisiert und unabhängig bleibt. Um dabei zu helfen, enthält Testify das Paket github.com/stretchr/testify/mock. Dieses Paket stellt Tools zur Verfügung, um Mocks zu erstellen, die es ermöglichen, das Verhalten zur Laufzeit zu injizieren. Dabei sorgt der Testfall selbst dafür, dass die gemockte Logik nahe an der Testlogik bleibt.

Durch die Verwendung des Testify-Mock-Pakets zur Erstellung unseres DAO-Mocks und das Verschieben der Initialisierung des Mock-Verhaltens in die Testfälle sowie das Hinzufügen des Testify-Require-Pakets zur Ausführung unserer Assertions sieht unser Testcode wie folgt aus:

import (
      "github.com/stretchr/testify/require"
      "github.com/stretchr/testify/mock"
	"errors"
	"model"
	"persist"	
	"testing"
	"time"
)

type ProductDaoTestifyMock struct {
	mock.Mock
}

func (m *ProductDaoTestifyMock) GetProduct(id int) (*model.Product, error) {
	args := m.Called(id)
	return args.Get(0).(*model.Product), args.Error(1)
}

func TestProductServiceImpl_IsProductReservable(t *testing.T) {
	assertions := require.New(t)

	// Register test mocks
	productDaoMock := ProductDaoTestifyMock{}
	productDaoMock.On("GetProduct", 1).Return(&model.Product{
		Id:          1,
		Description: "Produkt wurde vor 2 Jahren erstellt",
		CreatedAt:   time.Now().AddDate(-2, 0, 0),		
	}, nil)
	productDaoMock.On("GetProduct", 2).Return(&model.Product{
		Id:          2,
		Description: "Produkt kürzlich erstellt",
		CreatedAt:   time.Now(),
	}, nil)

	testDataSet := map[int]bool {
		1: true,
		2: false,
	}

	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	for productId, expectedResult := range testDataSet {
		reservable, err := productServiceImpl.IsProductReservable(productId)
		assertions.NoErrorf(err, "Konnte nicht prüfen, ob Produkt %v reservierbar ist: %s", productId, err)
		assertions.Equalf(expectedResult, reservable, "Falsche Reservierungsinfo für Produkt-ID erhalten %v", productId)
	}
}

func TestProductServiceImpl_IsProductReservable_NotFound(t *testing.T) {
	assertions := require.New(t)

	// Register test mocks
	productDaoMock := ProductDaoTestifyMock{}
	productDaoMock.On("GetProduct", 1).Return((*model.Product)(nil), persist.ErrProductNotFound)

	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	_, err := productServiceImpl.IsProductReservable(1)

	if !errors.Is(err, persist.ErrProductNotFound) {
		assertions.Failf("Got unexpected error result", "Unerwartetes Fehlerergebnis erhalten: %s", err)
	}
}

In der obigen Implementierung sehen Sie, wie das Mock-Verhalten und die Testlogik innerhalb des Testfalls zentralisiert sind. Beachten Sie auch, dass das erfasste Scheinverhalten ausschließlich für den Testfall gilt, in dem es platziert ist, da es zu einer einzelnen Mock-Instanz gehört, die nicht von mehreren Tests gemeinsam genutzt wird. Die Tests erfassen ohne jegliche Probleme sogar unterschiedliche Verhaltensweisen für die Produkt-ID 1. ProductDaoTestifyMock kann sicher zwischen mehreren Testfällen wiederverwendet werden, da es kein Verhalten hat.

Fazit

Ich hoffe, Sie haben nützliche Informationen in diesem Artikel gefunden und ich hoffe, dass er Ihnen helfen kann, bessere Unit-Tests in Ihren Projekten zu schreiben. Um Testify mit Go-Modulen zu Ihrem Projekt hinzuzufügen und damit zu spielen, führen Sie einfach die folgenden Befehle aus:

$ export GOPROXY=https://gocenter.io
$ go get github.com/stretchr/testify

Sehen Sie sich Testify auf GoCenter an oder suchen Sie, um noch mehr tolle Go-Module zu entdecken.