Dlaczego testy jednostkowe w Go wyglądają inaczej niż w innych językach
Osoba sięgająca po testy jednostkowe w Go zwykle chce czegoś bardzo konkretnego: szybko wyłapywać regresje, spokojnie refaktoryzować kod i mieć zaufanie do wdrożeń bez utraty prostoty, którą Go obiecuje w codziennej pracy.
Testy jednostkowe w Go są mocno osadzone w filozofii języka: prostota, mało magii, mało frameworków. To podejście ma wyraźne konsekwencje dla stylu pisania testów, sposobu mockowania i projektowania kodu pod testy.
Rola testów jednostkowych w projektach Go
Testy jednostkowe w Go przede wszystkim:
- zabezpieczają refaktoryzację – pozwalają zmieniać strukturę pakietów, nazwy, implementacje, zachowując zewnętrzne kontrakty,
- łapią regresje – szczególnie w obszarach złożonej logiki domenowej, parsowania, walidacji czy obliczeń,
- wymuszają prostszy design – kod, który trudno przetestować, zwykle ma za dużo odpowiedzialności lub zbyt sztywne zależności,
- dokumentują zachowanie – dobrze nazwane testy tabelaryczne są często czytelniejsze niż długi komentarz nad funkcją.
W Go bardzo wiele problemów rozwiązuje się bez ciężkiego frameworka testowego. Standardowy pakiet testing plus kilka wspólnych idiomów często wystarcza na lata rozwoju projektu.
Kontrast z testami w językach dynamicznych i mocno obiektowych
W Pythonie, JavaScripcie czy Rubym łatwo i szybko tworzy się fikcyjne obiekty, podmienia globalne funkcje lub monkey patchuje zachowanie w locie. W Javie czy C# dominują rozbudowane frameworki (JUnit, NUnit, xUnit, Mockito, Jest itp.), które:
- dodają własne cykle życia testów (setup/teardown, adnotacje),
- tworzą mocki na podstawie klas,
- oferują rozbudowane DSL-e asercji.
Go stawia na minimalizm:
- brak klas i dziedziczenia oznacza, że podstawowym narzędziem izolacji są interfejsy,
- pakiet
testingma dosłownie kilka publicznych typów i funkcji, - Nie ma „magicznych” adnotacji testowych – wszystko jest zwykłym kodem.
Efekt: mniej ukrytej złożoności, ale więcej odpowiedzialności po stronie projektowania pakietów i zależności. Zamiast czarów frameworka trzeba świadomie zdecydować, gdzie przebiega granica między logiką domenową a światem zewnętrznym.
Minimalistyczny pakiet testing: zalety i ograniczenia
testing daje w zasadzie trzy podstawowe rzeczy:
- konwencję nazewniczą (pliki
_test.go, funkcjeTestXxx,BenchmarkXxx,ExampleXxx), - typ
*testing.Toraz*testing.Bz prostymi metodami (Errorf,Fatalf,Run,Helper), - wbudowane wsparcie w narzędziu
go test(uruchamianie, raportowanie, benchmarki, race detector).
Plusy takiego podejścia:
- testy są zwykłym Go – żadnych DSL-i, ukrytych hooków, adnotacji,
- łatwo zrozumieć test napisany przez kogoś innego, bo mechanika jest wszędzie taka sama,
- nie ma dodatkowych zależności w module, co upraszcza zarządzanie wersjami.
Ograniczenia:
- więcej „szumu” przy pisaniu asercji (ciągle to samo
if got != want { ... }), - brak wbudowanego porównywania złożonych struktur (typu
assert.JSONEqualitp.), - brak mechanizmu mockowania – wszystko trzeba oprzeć o własne interfejsy lub zewnętrzne narzędzia.
Z tych powodów w większych projektach Go często łączy się testing z przemyślaną architekturą oraz lekkimi bibliotekami typu testify czy go-cmp.
Kiedy test jednostkowy w Go staje się testem integracyjnym
Granica między testem jednostkowym a integracyjnym jest w Go szczególnie istotna. Przykład:
func TestCreateUser(t *testing.T) {
db, _ := sql.Open("postgres", os.Getenv("DSN"))
defer db.Close()
repo := NewUserRepository(db)
err := repo.CreateUser(context.Background(), User{Name: "Anna"})
if err != nil {
t.Fatalf("CreateUser returned error: %v", err)
}
}
Taki test dotyka bazy danych, więc nie jest testem jednostkowym, nawet jeśli technicznie nazywa się TestCreateUser. To już test integracyjny, bo sprawdza współdziałanie kilku elementów: logiki repozytorium, konfiguracji połączenia, dostępności bazy.
Test jednostkowy w Go można rozpoznać po tym, że:
- testuje jeden moduł/logikę z minimalnymi zależnościami,
- wszystkie zewnętrzne komponenty (bazy, API, system plików, kolejki) są zastąpione stubami, mockami lub fakes,
- nie wymaga dodatkowej infrastruktury poza procesem
go test.
Test integracyjny z kolei:
- korzysta z prawdziwych połączeń, plików, sieci, dockerowych serwisów,
- jest wolniejszy, ale sprawdza większy fragment systemu,
- często wymaga dodatkowego tagu budowania (
//go:build integration) lub innego mechanizmu selekcji.
Świadome rozróżnienie tych dwóch klas testów dramatycznie zmniejsza frustrację przy uruchamianiu testów na CI i podczas refaktoryzacji.

Podstawy go test: struktura plików, funkcje testowe i kluczowe idiomy
Konwencje nazewnicze: pliki _test.go i pakiety testowe
Go stawia na konwencję zamiast konfiguracji. Aby testy jednostkowe w Go zostały wykryte przez go test, trzeba spełnić kilka prostych warunków:
- pliki z testami muszą kończyć się na
_test.go, - funkcje testowe mają postać
func TestXxx(t *testing.T), - nazwy testów są rozpoznawane po prefiksie
Test– reszta nazwy jest dowolna, ale warto, by odnosiła się do testowanego zachowania.
Kluczowa decyzja dotyczy pakietu testowego. Mamy dwie opcje:
package foow plikufoo_test.go– testy są częścią tego samego pakietu, mają dostęp do nieeksportowanych symboli,package foo_test– testy są w osobnym pakiecie, korzystają tylko z eksportowanego API.
Porównanie podejść:
| Aspekt | Testy w package foo | Testy w package foo_test |
|---|---|---|
| Dostęp do nieeksportowanych symboli | Tak | Nie |
| Testowanie API jak użytkownik zewnętrzny | Gorsze | Dobre |
| Wygoda przy testowaniu wewnętrznych helperów | Wysoka | Niska |
| Ryzyko zbyt mocnego sprzęgnięcia testów z implementacją | Wyższe | Niższe |
Typowe podejście w większych projektach: dla pakietów „zewnętrznych” używać package foo_test, dla pakietów niskopoziomowych, technicznych – package foo, jeśli naprawdę trzeba testować helpery wewnętrzne.
Uruchamianie testów: go test ./…, flagi i równoległość
Najczęściej wykorzystywane komendy:
go test ./...– uruchamia wszystkie testy w module (rekurencyjnie po katalogach),go test ./pkg/...– tylko poddrzewo katalogupkg,go test -run TestName ./...– uruchamia tylko testy, których nazwa pasuje do wyrażenia regularnegoTestName,go test -run TestCreateUser -v ./internal/user– jeden test w trybie verbose w konkretnym pakiecie.
Przydatne flagi:
-v– bardziej szczegółowy output (nazwa każdego testu),-count=1– wyłącza cache testów (przydatne przy debugowaniu),-race– włącza wykrywanie wyścigów danych,-run <regex>– selekcjonuje testy po nazwie.
Równoległość: w testing.T dostępna jest metoda t.Parallel(). Pozwala ona uruchamiać testy równolegle, ale:
- należy jej używać tylko w testach, które nie współdzielą stanu globalnego,
- każdy test z
t.Parallel()powinien mieć własne dane wejściowe i wyjściowe, - nie powinno się mieszać równoległych testów z globalnymi zmiennymi, plikami o stałej nazwie, nasłuchiwaniem na stałych portach itp.
W testach tabelarycznych równoległość często łączy się z t.Run, ale trzeba pilnować poprawnego przekazywania zmiennej z pętli do closures.
Prosty test krok po kroku: struktura asercji i komunikatów
Przykładowa funkcja:
func Add(a, b int) int {
return a + b
}
Minimalny test:
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Fatalf("Add(2, 3) = %d, want %d", got, want)
}
}
Elementy, na które warto zwrócić uwagę:
- czytelna nazwa testu –
TestAdd, - zmienne
gotiwant– bardzo popularny idiom w Go testach, - komunikat błędu zawierający konkretne dane wejściowe oraz różnicę między
gotiwant, - użycie
FatalfzamiastErrorf– zatrzymuje test natychmiast, gdy nie ma sensu kontynuować.
Prosty test może już być czytelny, jeśli skupia się na jednym scenariuszu i dobrze opisuje porażkę. Zbyt rozbudowane testy, które sprawdzają wiele rzeczy na raz, są trudniejsze w utrzymaniu niż kilka mniejszych testów.
t.Helper() i t.Run: pomocnicy i podtesty
Wraz z rozwojem testów pojawia się pokusa duplikowania podobnego kodu. Zamiast tego warto przenieść powtarzalne fragmenty do funkcji pomocniczych. t.Helper() pozwala zachować sensowny stack trace:
func assertEqualInt(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Fatalf("got %d, want %d", got, want)
}
}
func TestAdd(t *testing.T) {
got := Add(2, 3)
assertEqualInt(t, got, 5)
}
Dzięki t.Helper() raport z testu wskaże linię w TestAdd, a nie wewnątrz assertEqualInt, co ułatwia lokalizowanie problemu.
t.Run służy do tworzenia podtestów. Najczęściej łączy się go z testami tabelarycznymi:
func TestAdd_Table(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"2+3", 2, 3, 5},
{"0+0", 0, 0, 0},
{"-1+1", -1, 1, 0},
}
for _, tt := range tests {
tt := tt // shadow to avoid capturing loop variable
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.want {
t.Fatalf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
Każdy podtest ma własną nazwę i jest raportowany osobno. Daje to lepszą diagnozę, który przypadek testowy zawiódł.
Projektowanie kodu pod testy: interfejsy, zależności i granice odpowiedzialności
Podział na pakiety i warstwy a łatwość testowania
W Go testowanie staje się dużo prostsze, gdy kod jest naturalnie podzielony na:
- logikę domenową – czyste funkcje, struktury, walidacje, reguły biznesowe,
- adaptery zewnętrzne – HTTP, bazy danych, system plików, message queue,
Interfejs jako granica modułu: jak ciąć zależności, żeby testy nie bolały
Największe problemy z testami w Go nie wynikają z samego testing, tylko z tego, jak są ułożone zależności. Gdy moduł „wie” za dużo o świecie zewnętrznym, każdy test zamienia się w walkę z kontekstem. Dobry punkt wyjścia to proste pytanie: gdzie kończy się logika, a zaczyna I/O?
Typowy przykład serwisu pracującego z bazą danych:
type User struct {
ID int64
Name string
}
type UserRepository interface {
Create(ctx context.Context, u User) (int64, error)
FindByID(ctx context.Context, id int64) (User, error)
}
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) Register(ctx context.Context, name string) (User, error) {
if len(name) < 3 {
return User{}, fmt.Errorf("name too short")
}
id, err := s.repo.Create(ctx, User{Name: name})
if err != nil {
return User{}, fmt.Errorf("create user: %w", err)
}
return User{ID: id, Name: name}, nil
}
Zależność UserService od bazy danych jest reprezentowana przez UserRepository. Konsekwencja jest prosta:
- test jednostkowy dla
UserServicepodmieniaUserRepositoryna mock / stub, - test integracyjny dla adaptera bazy (
postgresUserRepository,mysqlUserRepositoryitp.) korzysta z prawdziwej bazy albo dockera.
Granica modułu przechodzi po interfejsie. Implementacji może być kilka – inne w testach, inne w produkcji – ale kontrakt pozostaje wspólny.
Interfejs w tym samym pakiecie czy osobno?
Są dwa popularne warianty umieszczania interfejsów w Go:
- Interfejs w pakiecie konsumenta (np.
servicedefiniujeUserRepository). - Interfejs w pakiecie implementacji (np.
repositorydefiniujeUserRepository).
Konsekwencje projektowe różnią się dość wyraźnie:
| Aspekt | Interfejs u konsumenta | Interfejs u implementacji |
|---|---|---|
| Swoboda zmiany implementacji | Wysoka – serwis „dyktuje” kontrakt | Niższa – serwis musi się dostosować |
| Łatwość mockowania w testach serwisu | Wysoka, mock ma mały, celowy interfejs | Zależna od „grubości” interfejsu repo |
| Ryzyko „interfejsów od wszystkiego” | Średnie – pokusa mnożenia kontraktów | Niższe, ale interfejsy bywają zbyt ogólne |
| Coupling między warstwami | Serwis słabo sprzęgnięty z repo | Serwis ściśle zależy od pakietu repo |
W testowalnych aplikacjach częściej sprawdza się pierwszy wariant: interfejsy trzyma konsument. Wtedy testy jednostkowe operują na prostych, wąskich kontraktach, a adaptery do baz czy HTTP jedynie je implementują.
Unikanie interfejsów „na wszelki wypadek”
Skrajnie interfejsowe podejście kończy się czasem projektem, w którym każda struktura ma swój Somethinger, choć nikt inny go nie potrzebuje. Z punktu widzenia testów jest to wręcz szkodliwe – ilość typów rośnie, a czytelność maleje.
Dobry filtr: interfejs ma sens, jeśli istnieje więcej niż jedna implementacja lub chcesz tę zależność zastąpić w testach. Jeśli struktura jest używana tylko w jednym miejscu i nie ma zewnętrznych efektów, można ją testować bezpośrednio, bez interfejsu i bez mocków.
// Bez interfejsu, prosta, czysta logika
type PriceCalculator struct {
taxRate float64
}
func (c PriceCalculator) FinalPrice(net float64) float64 {
return net * (1 + c.taxRate)
}
Test dla takiej struktury to czysta przyjemność – bez konfiguracji DI, bez mocków, tylko wejście i wyjście.
Zależności przez konstruktor vs. przez pola struktury
Dla testów dużo robi sposób wstrzykiwania zależności. W praktyce dominują dwa podejścia:
- wstrzykiwanie przez konstruktor – preferowane w kodzie produkcyjnym,
- modyfikacja pól struktury w testach – czasem przydatna w małych pakietach / helperach.
Porównanie na przykładzie:
type EmailSender interface {
Send(ctx context.Context, to, body string) error
}
type NotificationService struct {
sender EmailSender
}
func NewNotificationService(sender EmailSender) *NotificationService {
return &NotificationService{sender: sender}
}
W testach można z łatwością podać własny EmailSender:
type emailSenderMock struct {
sent []struct {
to string
body string
}
err error
}
func (m *emailSenderMock) Send(_ context.Context, to, body string) error {
m.sent = append(m.sent, struct {
to string
body string
}{to: to, body: body})
return m.err
}
func TestNotificationService_SendsEmail(t *testing.T) {
mock := &emailSenderMock{}
svc := NewNotificationService(mock)
err := svc.Notify(context.Background(), "anna@example.com", "Welcome!")
if err != nil {
t.Fatalf("Notify returned error: %v", err)
}
if len(mock.sent) != 1 {
t.Fatalf("expected 1 email sent, got %d", len(mock.sent))
}
}
Wariant ze „swobodnym” modyfikowaniem pól (bez konstruktora) kusi większą elastycznością, ale w większych projektach szybko prowadzi do struktur, których nie da się poprawnie zainicjalizować bez znajomości szczegółów implementacji.
Granice odpowiedzialności: gdzie kończy się test jednostkowy
Granica między warstwami przekłada się na granice testów. Jeden pakiet – jedna odpowiedzialność – jeden typ testów jako główna oś:
- pakiet domenowy – testy jednostkowe logiki (bez I/O),
- pakiet repozytorium – testy integracyjne z bazą,
- pakiet HTTP – testy handlerów z
httptest, bez uruchamiania całego serwera jako procesu.
Dwa częste antywzorce:
- Handler, który sam otwiera połączenie z bazą – nie da się go sensownie przetestować jednostkowo, bo nie ma gdzie „podstawić” innej implementacji.
- Serwis, który „zna” detale SQL – miesza warstwy, tworzy silne sprzężenie i utrudnia refaktoryzację.
Wycięcie zależności do interfejsu i wstrzyknięcie ich w konstruktor natychmiast obniża koszt testów. Scenariusze biznesowe można wykonywać w pamięci przy użyciu prostych fakes, a prawdziwe I/O zostawia się testom integracyjnym.

Style pisania testów w Go: od prostych asercji po testy tabelaryczne
Testy „jeden scenariusz – jeden test”
Dla prostych funkcji i wyraźnych przypadków happy path czy pojedynczych błędów wystarcza styl „jeden scenariusz – jeden test”:
func TestRegisterUser_TooShortName(t *testing.T) {
svc := NewUserService(nil) // repo nie jest tu potrzebne
_, err := svc.Register(context.Background(), "Al")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "name too short") {
t.Fatalf("unexpected error: %v", err)
}
}
Zalety:
- czytelna nazwa opisująca konkretny przypadek,
- test łatwy do zrozumienia bez dodatkowych struktur,
- idealny dla początkujących w zespole – niski próg wejścia.
Wady ujawniają się przy rozroście liczby scenariuszy: 10 bardzo podobnych testów różniących się jednym parametrem zaczyna generować sporo duplikacji.
Testy tabelaryczne: kiedy zaczynają mieć sens
Styl tabelaryczny dominuje w standardowej bibliotece Go. Łączy dane wejściowe i oczekiwany wynik w jedną tabelę, a pętla wykonuje scenariusze po kolei:
func TestNormalizeUsername(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"simple", "Anna", "anna"},
{"with spaces", " Bob ", "bob"},
{"with unicode", "Łukasz", "łukasz"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
got := NormalizeUsername(tt.in)
if got != tt.want {
t.Fatalf("NormalizeUsername(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
Plusy tabel:
- łatwe dodawanie nowych przypadków przez dopisanie kolejnej pozycji,
- zgrabne grupowanie testów tej samej funkcji w jednym miejscu,
- możliwość uruchamiania każdym przypadkiem jako osobnym podtestem.
Minusy pojawiają się, gdy tabela zaczyna zawierać zbyt wiele pól i flag. Jeśli struktura ma kilkanaście pól, z których większość to opcjonalne booleany, test staje się nieczytelny. Wtedy lepszym wyborem jest rozbicie tabeli na mniejsze grupy lub powrót do prostych testów dla wybranych scenariuszy.
Testy tabelaryczne z oczekiwanym błędem
Częsty wzorzec w Go to łączenie wyniku i błędu w jednej tabeli. Pozwala to łatwo opisać zarówno ścieżki sukcesu, jak i porażki:
func TestParseAge(t *testing.T) {
tests := []struct {
name string
input string
want int
wantErr bool
}{
{"valid age", "18", 18, false},
{"negative", "-1", 0, true},
{"non-numeric", "abc", 0, true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
got, err := ParseAge(tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("ParseAge(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
if err == nil && got != tt.want {
t.Fatalf("ParseAge(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
W porównaniu z osobnymi testami dla każdego błędu, ten styl lepiej pokazuje komplet zachowań funkcji. W małych projektach może być przesadą, ale przy parserach, walidatorach czy mapowaniu danych z zewnątrz zwykle się opłaca.
Nazewnictwo testów: Given–When–Then czy opisowe?
Dwa popularne style nazewnictwa:
- opisowe:
TestRegisterUser_TooShortName,TestRegisterUser_OK, - Given–When–Then:
TestRegisterUser_GivenTooShortName_ReturnsError.
Pierwszy jest krótszy, lepiej mieści się w logach go test -v i w praktyce w zupełności wystarcza. Drugi zyskuje zwolenników w większych zespołach przy testach bardziej złożonych scenariuszy, bo przypomina język BDD. W Go rzadko wchodzi się tu w pełen BDD z dedykowanymi frameworkami, ale sam wzorzec nazwy bywa użyteczny.
Testy stylu „arrange–act–assert” w Go
Większość testów da się poukładać według prostego schematu:
- Arrange – przygotowanie danych i zależności.
- Act – wykonanie operacji.
- Assert – sprawdzenie wyniku.
func TestUserService_Register(t *testing.T) {
// Arrange
repo := &userRepoMock{}
svc := NewUserService(repo)
// Act
got, err := svc.Register(context.Background(), "Anna")
// Assert
if err != nil {
t.Fatalf("Register returned error: %v", err)
}
if got.Name != "Anna" {
t.Fatalf("got Name %q, want %q", got.Name, "Anna")
}
if len(repo.created) != 1 {
t.Fatalf("expected 1 user in repo, got %d", len(repo.created))
}
}
Różnica między pisaniem testów w takim stylu a „spontanicznym” mieszaniem przygotowania, wywołania i asercji jest szczególnie widoczna po roku, gdy trzeba przeanalizować powód porażki w CI. Czysta struktura testu przyspiesza debugowanie znacznie bardziej niż najbardziej wyrafinowane biblioteki asercji.
Kiedy użyć podtestów, a kiedy osobnych funkcji testowych
Podtesty (t.Run) są wygodne przy grupowaniu scenariuszy dla jednej funkcji lub metody. Gdy scenariusze stają się semantycznie różne (np. różne endpointy HTTP, różne typy zdarzeń), osobne funkcje testowe są czytelniejsze.
Porównanie na prostym handlerze HTTP:
Podtesty dla jednego endpointu kontra wiele funkcji dla różnych ścieżek
Handler HTTP można testować na dwa główne sposoby. Pierwszy: jeden handler – jedna funkcja testowa – kilka podtestów opisujących scenariusze:
func TestLoginHandler(t *testing.T) {
h := NewLoginHandler(fakeAuthService{})
tests := []struct {
name string
body string
wantStatus int
}{
{"ok", `{"email":"a@example.com","password":"x"}`, http.StatusOK},
{"missing email", `{"password":"x"}`, http.StatusBadRequest},
{"invalid json", `{`, http.StatusBadRequest},
{"wrong credentials", `{"email":"a@example.com","password":"bad"}`, http.StatusUnauthorized},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(tt.body))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d", w.Code, tt.wantStatus)
}
})
}
}
Drugi: rozdzielenie scenariuszy do osobnych funkcji:
func TestLoginHandler_OK(t *testing.T) {
h := NewLoginHandler(fakeAuthService{})
req := httptest.NewRequest(http.MethodPost, "/login",
strings.NewReader(`{"email":"a@example.com","password":"x"}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
}
}
func TestLoginHandler_MissingEmail(t *testing.T) {
h := NewLoginHandler(fakeAuthService{})
req := httptest.NewRequest(http.MethodPost, "/login",
strings.NewReader(`{"password":"x"}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
Przy prostym handlerze z czterema ścieżkami pierwszy wariant daje bardziej kompaktowy obraz zachowań. Gdy jednak endpoint robi się rozbudowany (różne nagłówki, role użytkownika, parametry zapytań), tabelaryczny test z kilkunastoma polami będzie mniej czytelny niż kilka funkcji testowych opisujących logicznie odrębne gałęzie, np. TestLoginHandler_AdminUser, TestLoginHandler_BlockedUser.
Praktyczny kompromis:
- podtesty – dla wariantów jednego „tematu” (np. walidacja wejścia, różne parametry tej samej operacji),
- oddzielne funkcje – dla innej logiki biznesowej (inne role, inne typy żądań, inne reguły autoryzacji).

Asercje i porównywanie wyników: standardowa biblioteka, `testify`, `go-cmp`
Go „na piechotę”: asercje z użyciem `testing.T`
Podstawowy sposób to ręczne porównywanie wartości i wywoływanie t.Fatalf, t.Errorf lub t.Helper. Z perspektywy porównania z innymi językami (Java, C#, JavaScript) to krok wstecz – brak tu wbudowanego assert – ale za to pełna kontrola nad komunikatami:
func TestSum(t *testing.T) {
got := Sum(2, 3)
want := 5
if got != want {
t.Fatalf("Sum(2, 3) = %d, want %d", got, want)
}
}
Przy strukturach stosuje się reflect.DeepEqual lub w Go 1.21+ per-field porównania z generics. W klasycznym kodzie wygląda to tak:
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected result:n got %#vn want %#v", got, want)
}
Zaletą jest brak zależności zewnętrznych i pełne osadzenie w ekosystemie Go. Wadą – mniej przyjazne komunikaty przy porównaniu złożonych struktur, zwłaszcza z mapami czy wskaźnikami.
Minimalne „uładzenie” standardu: własne helpery asercji
Zanim na stałe trafi do projektu biblioteka testowa, często wystarcza kilka prostych helperów:
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func assertEqual[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Fatalf("got %v, want %v", got, want)
}
}
Korzystanie z nich jest zbliżone do popularnych bibliotek, a nadal pozostaje w „czystym” Go:
func TestCreateUser(t *testing.T) {
user, err := CreateUser("Anna", 18)
assertNoError(t, err)
assertEqual(t, "Anna", user.Name)
}
Porównując to z pełnym frameworkiem asercji, zyskuje się minimum ergonomii bez ryzyka „magicznym” zachowaniem makr czy refleksji. Dla małych usług lub bibliotek open source to zwykle wystarczający kompromis.
`testify/assert` i `require`: szybkość pisania kontra przejrzystość
Najpopularniejszy pakiet asercji w Go to testify. Udostępnia dwa rodzaje funkcji:
assert– raportuje błąd, ale pozwala testowi biec dalej,require– przy niepowodzeniu kończy test natychmiast.
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadConfig(t *testing.T) {
cfg, err := LoadConfig("testdata/config.yaml")
require.NoError(t, err)
assert.Equal(t, "dev", cfg.Env)
assert.Equal(t, 8080, cfg.Port)
}
W porównaniu z czystym testing zyskuje się:
- krótsze testy (mniej „if err != nil…”),
- lepsze komunikaty błędów dla struktur,
- wbudowane asercje specjalistyczne (np.
Len,Contains,ElementsMatch).
Minusy:
- dodatkowe zależności w module – bywa problemem dla bibliotek, które chcą pozostać „lekkie”,
- czasem zbyt „magiczne” komunikaty, trudniejsze do dostosowania pod swój styl,
- pokusa, by test upchnąć w jednej funkcji z dziesiątkami asercji, co obniża czytelność.
W mniejszych projektach produktowych testify zwykle przyspiesza pracę. W bibliotekach przeznaczonych dla szerokiej społeczności bardziej akceptowany jest „goły” pakiet testing i ewentualny go-cmp jako narzędzie, nie framework.
`go-cmp`: dokładne różnice struktur zamiast `reflect.DeepEqual`
go-cmp powstał jako odpowiedź na bolączki reflect.DeepEqual. Zamiast zwracać zwykłe true/false, generuje czytelny diff opisujący różnice między strukturami:
import (
"testing"
"github.com/google/go-cmp/cmp"
)
func TestMapUser(t *testing.T) {
got := MapUser(sourceUser{})
want := User{ID: "123", Name: "Anna"}
if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("MapUser() mismatch (-want +got):n%s", diff)
}
}
W porównaniu z testify.Equal podstawowa przewaga to możliwość konfiguracji:
- ignorowanie pól (np. znaczników czasu czy ID generowanych w bazie),
- własne funkcje porównujące typy domenowe,
- porządkowanie map i slice przed porównaniem.
var cmpUserOpts = []cmp.Option{
cmp.FilterPath(func(p cmp.Path) bool {
return p.String() == "CreatedAt" || p.String() == "UpdatedAt"
}, cmp.Ignore()),
}
func TestUserRepo_Save(t *testing.T) {
repo := NewUserRepo(memDB{})
user := User{Name: "Anna"}
got, err := repo.Save(context.Background(), user)
if err != nil {
t.Fatalf("Save returned error: %v", err)
}
want := User{Name: "Anna"}
if diff := cmp.Diff(want, got, cmpUserOpts...); diff != "" {
t.Fatalf("User mismatch (-want +got):n%s", diff)
}
}
Jeśli porówna się to z prostym reflect.DeepEqual, go-cmp wypada korzystniej przy każdym niebanalnym typie: mniej szumu w testach, lepsze komunikaty diffa. Koszt – zależność i niewielkie narzuty wydajnościowe, zwykle niezauważalne w testach jednostkowych.
Jak dobrać narzędzie do projektu
Porównując trzy podejścia:
- czyste
testing– gdy liczba zewnętrznych zależności ma być minimalna (biblioteki, SDK, narzędzia CLI), testing+ własne helpery – gdy zespół chce spójnego stylu bez sięgania po duże frameworki,testify+go-cmp– gdy szybkość pisania testów i dobry diff struktur są ważniejsze niż rozmiar zależności.
W praktyce często stosuje się mieszankę: podstawowe asercje z testify/require, a dla szczególnie skomplikowanych struktur (DTO z dużą liczbą pól, zagnieżdżone mapy) – pojedyncze porównania z go-cmp.
Mocki w Go: podejścia, narzędzia i zdrowy rozsądek
Ręczne mocki vs generatory: dwie filozofie
Go nie ma wbudowanego systemu mocków w stylu JUnit + Mockito. Typowy wybór sprowadza się do dwóch strategii:
- ręcznie pisane implementacje interfejsów („mocki z kartki”),
- mocki generowane na podstawie interfejsów (narzędzia typu
mockgen,moq,counterfeiter).
Ręczne podejście wygląda podobnie do pokazanego wcześniej emailSenderMock. Przykładowy mock repozytorium:
type userRepoMock struct {
created []User
err error
}
func (m *userRepoMock) Create(_ context.Context, u User) (User, error) {
if m.err != nil {
return User{}, m.err
}
m.created = append(m.created, u)
return u, nil
}
Z kolei generator (przykład z moq) tworzy plik z gotową strukturą i polami rejestrującymi wywołania. Zamiast pisać userRepoMock, używa się czegoś takiego:
repo := &UserRepoMock{
CreateFunc: func(_ context.Context, u User) (User, error) {
return u, nil
},
}
Różnice w praktyce:
- ręczne mocki są proste, ale trzeba je utrzymywać przy zmianach interfejsu,
- generatory zdejmują zespół z tego obowiązku, ale dodają warstwę narzędzi (dodatkowe komendy, pliki wygenerowane, hooki w CI).
W małej usłudze REST kilka ręcznie napisanych mocków bywa szybsze niż konfiguracja generatora. W większym systemie mikroserwisów z dziesiątkami interfejsów manualne doganianie zmian szybko staje się uciążliwe – wtedy generator daje przewagę.
Fakes, stubs i mocki: jak rozróżniać w kodzie Go
W testach Go często miesza się pojęcia. W praktyce wygodnie trzymać rozróżnienie:
- fake – „prawie prawdziwa” implementacja, często w pamięci (np. repozytorium na mapie zamiast SQL),
- stub – prosta implementacja zwracająca z góry ustalony wynik, bez rejestrowania wywołań,
- mock – implementacja rejestrująca wywołania i używana do asercji (sprawdzanie, czy coś zostało wywołane).
Fake w Go to zwykle mały „in-memory database”:
type UserRepoInMemory struct {
mu sync.Mutex
store map[string]User
}
func NewUserRepoInMemory() *UserRepoInMemory {
return &UserRepoInMemory{store: make(map[string]User)}
}
func (r *UserRepoInMemory) Create(_ context.Context, u User) (User, error) {
r.mu.Lock()
defer r.mu.Unlock()
if u.ID == "" {
u.ID = uuid.NewString()
}
r.store[u.ID] = u
return u, nil
}
Stub jest jeszcze prostszy:
type authServiceStub struct {
user User
err error
}
func (s authServiceStub) Authenticate(_ context.Context, token string) (User, error) {
return s.user, s.err
}
Mock – jak w przykładzie z e-mailem – oprócz zachowania gromadzi dane do późniejszego sprawdzenia. W projektach domenowych częściej używa się fakes (bliżej realnego zachowania) niż mocków z rozbudowaną logiką oczekiwań.
Mocki „z oczekiwaniami” kontra „zapisz, a ja sprawdzę w teście”
W wielu językach popularne są biblioteki, które wymuszają styl „najpierw zadeklaruj oczekiwania, potem uruchom kod” (expect-run-verify). W Go częściej stosuje się prosty schemat:
- uruchom kod,
- sprawdź, co mock zarejestrował.
Przykład drugiego podejścia (bliższego idiomom Go):
Najczęściej zadawane pytania (FAQ)
Jak pisać testy jednostkowe w Go bez użycia zewnętrznych frameworków?
Podstawą są pliki zakończone na _test.go oraz funkcje w formie func TestXxx(t *testing.T). W środku używasz zwykłych instrukcji if i metod t.Errorf lub t.Fatalf do sygnalizowania błędów. Nie ma tu magii: test to po prostu funkcja, która przygotowuje dane, wywołuje kod i porównuje wynik z oczekiwaniem.
W małych projektach i prostych przypadkach standardowy pakiet testing całkowicie wystarcza. Gdy zaczyna przeszkadzać powtarzalne if got != want, można dołożyć lekką bibliotekę do asercji (np. testify lub go-cmp), ale wciąż opierać się na tym samym cyklu działania go test.
Jak odróżnić test jednostkowy od integracyjnego w Go?
Test jednostkowy sprawdza pojedynczy moduł lub fragment logiki i nie dotyka prawdziwych zasobów zewnętrznych. Baza danych, API, system plików czy kolejka są w nim zastąpione prostymi stubami, mockami lub „fałszywymi” implementacjami interfejsów. Taki test da się uruchomić samym go test, bez przygotowywania infrastruktury.
Test integracyjny używa prawdziwych połączeń i serwisów – na przykład odpala PostgreSQL w Dockerze, wysyła żądania HTTP do lokalnego serwera lub zapisuje pliki na dysk. Jest wolniejszy, ale sprawdza współdziałanie kilku komponentów naraz. Często oznacza się go osobnym tagiem kompilacji (np. //go:build integration) lub umieszcza w innej ścieżce, by nie uruchamiać go przy każdym szybkim go test ./....
Jak poprawnie mockować zależności w testach Go?
W Go zamiast klas i frameworków mockujących wykorzystuje się interfejsy. Produkcyjny kod przyjmuje interfejs (np. UserRepository), a w testach tworzysz lekką strukturę, która ten interfejs implementuje i zachowuje się tak, jak potrzebujesz (np. zwraca błąd przy drugim wywołaniu). Dzięki temu nie trzeba „podmieniać” globalnego stanu ani stosować refleksji.
W prostych projektach wystarczy kilka ręcznie napisanych mocków lub fake’ów. W większych kodach, gdy liczba interfejsów rośnie, można skorzystać z generatorów (np. mockgen), ale kluczem zawsze pozostaje dobre zaprojektowanie granicy między logiką domenową a światem zewnętrznym.
Czy lepiej pisać testy w package foo czy w package foo_test?
Testy w package foo mają dostęp do nieeksportowanych symboli, więc łatwiej sprawdzić wewnętrzne helpery i szczegóły implementacji. Minusem jest silniejsze powiązanie testów z wnętrzem pakietu – każda zmiana detalu może wymagać przebudowy testów, nawet jeśli publiczne API się nie zmieniło.
Testy w package foo_test widzą tylko eksportowane API, więc weryfikują zachowanie tak, jak zrobiłby to kod zewnętrzny. To pomaga utrzymać lepszy design i stabilny interfejs, ale utrudnia testowanie prywatnych funkcji. Typowe podejście: dla „publicznych” pakietów używać foo_test, a dla niskopoziomowych, technicznych modułów — foo, gdy faktycznie trzeba dotknąć wnętrza.
Jak uruchamiać tylko wybrane testy w Go (np. konkretną funkcję testową)?
Do selekcji służy flaga -run w go test, która przyjmuje wyrażenie regularne na nazwach testów. Przykład: go test -run TestCreateUser ./internal/user odpali tylko te testy z pakietu internal/user, których nazwa pasuje do wzorca TestCreateUser. Można też użyć fragmentów nazw, np. -run User, by złapać całą grupę przypadków.
W połączeniu z -v dostajesz dokładny log, który test się wykonał, co ułatwia debugowanie pojedynczego scenariusza. Podczas pracy nad trudnym błędem wielu programistów korzysta z -run oraz -count=1, żeby omijać cache testów i zawsze widzieć świeży wynik.
Kiedy w testach Go używać t.Parallel() i na co uważać?
t.Parallel() przyspiesza suite testowy, uruchamiając niektóre testy równolegle. Dobrze sprawdza się przy funkcjach czysto obliczeniowych, parsowaniu, walidacji – wszędzie tam, gdzie testy nie modyfikują wspólnego stanu ani nie korzystają z tych samych zasobów (pliki, porty, globalne zmienne).
Problemy pojawiają się, gdy równoległe testy zapisują do tych samych globali, używają tej samej bazy danych lub pliku o stałej nazwie. Wtedy wchodzą sobie w drogę i generują trudne do odtworzenia błędy. W testach tabelarycznych warto dodatkowo pilnować poprawnego „zamykania” zmiennej z pętli, np. przez przypisanie jej do lokalnej zmiennej wewnątrz for przed wywołaniem t.Run.
Jak projektować kod w Go, żeby był łatwy do testowania jednostkowego?
Kluczowe jest rozdzielenie logiki domenowej od wejść/wyjść: logika nie powinna sama otwierać połączeń do bazy, czytać os.Getenv ani pisać na stdout. Zamiast tego przyjmuje zależności jako interfejsy lub parametry i oddziela obliczenia od efektów ubocznych. Dzięki temu w testach możesz podstawiać własne implementacje i sprawdzać zachowanie bez uruchamiania infrastruktury.
Dodatkowo opłaca się utrzymywać małe, spójne funkcje i pakiety. Gdy funkcja robi „wszystko naraz” (pobiera dane z API, przetwarza je i zapisuje do bazy), trudno ją pokryć sensownymi testami jednostkowymi. Podział na kilka mniejszych kroków pozwala testować czystą logikę osobno, a interakcje z zewnętrzem zostawiać testom integracyjnym.






