Wstęp
Testowanie jednostkowe to jedna z podstawowych technik weryfikowania poprawnego działania programu. Nie oznacza to jednak, że temat jest prosty. Szczególnie w przypadku Go, gdzie biblioteka testing, mimo iż potężna, nie narzuca jednego właściwego podejścia do tematu. Daje to nam dużą swobodę, ale nie za darmo. W przypadku większych zespołów ta swoboda może być problemem. Warto się wtedy zastanowić nad ustandaryzowaniem swojego podejścia.
Chciałbym się podzielić z wami moim sposobem pisania nieco bardziej złożonych testów jednostkowych. Nie będzie to nic wyrafinowanego. Celem nadrzędnym jest, aby po spotkaniu z nieznanym do tej pory kodem, interpretowanie oraz rozszerzanie testów było proste.
Problem
Przyjmijmy, że mamy do przetestowania kontroler naszego web serwisu. Jest to prosta aplikacja, która umożliwia dilerowi samochodów zarządzanie swoją flotą.
Kontroler
Kontrolery w naszej aplikacji muszą spełniać następujący interfejs:
type controller interface {
handle(*http.Request) (interface{}, error)
}
Taka abstrakcja pozwala nam, odseparować warstwę biznesową od transportowej.
Ktoś mógłby zwrócić uwagę, że przez użycie http.Request
jest to niemożliwe.
Aby nie komplikować naszego przykładu aż zanadto, musimy zaakceptować to niewielkie niedociągnięcie.
Na tapetę weźmiemy kontroler dodawania oraz modyfikowania samochodów, którego uproszczona implementacja mogłaby wyglądać następująco:
package example
type Payload struct {
ID int64 `storage:"identifier"` // if set, storage will perform update, otherwise insert
Name string
Age, Mileage int
Owner string
}
type PutCarController struct {
Storage Storage
}
func (pcc *PutCarController) Handle(req *http.Request) (interface{}, error) {
var pay Payload
if err := json.NewDecoder(req.Body).Decode(&pay); err != nil {
return nil, err
}
req.Body.Close()
if pay.Name == "" {
return nil, errors.New("missing name")
}
if err := pcc.Storage.Put(req.Context(), &pay); err != nil {
return nil, err
}
return &pay, nil
}
Jest ona pozbawiona wszelkiego rodzaju ozdobników. Nawet walidacja żądania jest uproszczona. To, co sprawi najwięcej problemu podczas testowania tego kawałka kodu to przygotowanie atrapy bazy danych.
Baza danych
Jaka jest to baza danych, nie ma dla nas żadnego znaczenia. Chociaż nie ukrywam, że planując jej interfejs, wzorowałem się na Google Datastore. Oto on:
package example
type Storage interface {
Put(context.Context, interface{}) error
Get(context.Context, int64) (interface{}, error)
}
Put
zapisuje obiekt do bazy danych. Jeżeli operacja zakończy się sukcesem i obiekt nie miał wcześniej nadanego identyfikatora (pole oznaczona tagiem identifier
), zostanie mu on nadany, a przekazany obiekt zmodyfikowany o ten identyfikator. W razie fiaska zwraca błąd.
Get
nie jest nam do niczego potrzebny, jest tutaj jedynie, aby nadać sensu kolejnej sekcji ;)
Stub czy mock?
Nasz przypadek jest bardzo uproszczony. Użycie stuba wydaje się (i słusznie) uzasadnione. Oto jak taki stub mógłby wyglądać:
type testStorage struct {
storage // embeded interface
id int64
err error
}
func (ts *testStorage) Put(_ context.Context, obj interface{}) (int64, error) {
return ts.id, ts.err
}
Dzięki osadzeniu interfejsu storage
w testStorage
nasza struktura implementuje cały potrzebny interfejs.
Trzeba jednak pamiętać, że wywołanie Get
zakończy się, wyrzuceniem wyjątku (panic).
Aby spełnić obietnicę z tytułu, przekombinujemy nieco nasze rozwiązanie. Nie zważając na rozsądek, wykorzystamy mocki.
Do utworzenia atrap, posłuży nam mockery.
Narzędzie te w połączeniu z go generate
umożliwi nam w łatwy sposób wygenerować wszystkie potrzebne obiekty.
W bardziej złożonych aplikacjach takie podejście odpłaci się z nawiązką.
Możemy ten proces zautomatyzować, dodając do naszego kodu:
//go:generate mockery -case=underscore -all
Dzięki temu, przy każdorazowym wywołaniu komendy go generate
, wszystkie atrapy zostaną wygenerowane automatycznie.
Scenariusz
Nasz test powinien pokrywać możliwie dużo pozytywnych, jak i negatywnych przypadków. Powinny być one, od siebie całkowicie odseparowane (nie mogą dzielić stanu). Dodawanie nowych przypadków powinno być proste i nie narażać już istniejących na modyfikacje.
Table Driven Testing
Jest to powszechnie stosowany wzorzec, polegający na grupowaniu różnych przypadków w jednym teście i iterowaniu po nich. Przeciwieństwem jest tworzenie osobnego testu dla każdego przypadku z osobna:
func TestPutCarController_Handle_success(t *testing.T) { ... }
func TestPutCarController_Handle_deadlineExceeded(t *testing.T) { ... }
TDT ułatwia utrzymywanie oraz poruszanie się po testach. Osobiście jestem zwolennikiem stosowania mapy, gdzie klucze służą wyjaśnieniu co taki test ma udowodnić oraz pozwalają szybko przeskoczyć (cmd+f) z lini komend do konkretnego miejsca w kodzie, gdzie taki test jest zdefiniowany.
Aby przetestować metodę Handle
naszego kontrolera, będziemy potrzebować struktury opisującej kolekcje przypadków.
cases := map[string]struct {
req *http.Request
init func(*testing.T)
res interface{}
err error
}{}
Składa się ona z:
req
- argumentu przekazywanego do metodyHandle
init
- funkcji inicjalizującej wszystkie atrapy, może byćnil
res
- przewidywanej odpowiedzierr
- w razie, jeżeli jest to scenariusz testujący pesymistyczny przypadek, potrzebujemy obiektu błędu do porównania
Szablon
Szablon, na razie bez zaimplementowanych przypadków, wygląda następująco:
package example_test
func TestPutCarController_Handle(t *testing.T) {
storage := &mocks.Storage{}
req := func(t *testing.T, obj interface{}) *http.Request {
buf, err := json.Marshal(obj)
if err != nil {
t.Fatalf("payload marshal failure: %s", err.Error())
}
return httptest.NewRequest(
http.MethodPut,
"http://localhost",
bytes.NewReader(buf),
)
} // 1
cases := map[string]struct {
req *http.Request
init func(*testing.T)
res interface{}
err error
}{
// TODO: implement
}
for hint, given := range cases {
t.Run(hint, func(t *testing.T) {
storage.ExpectedCalls = nil // 2
if given.init != nil { // 3
given.init(t)
}
got, err := (&example.PutCarController{
Storage: storage,
}).Handle(given.req)
if given.err != nil { // 4
if err == nil {
t.Fatal("expected error, got nil")
}
if given.err.Error() != err.Error() {
t.Fatalf("wrong error, expected:\n %s\nbut got:\n %s", given.err.Error(), err.Error())
}
} else {
if !reflect.DeepEqual(given.res, got) {
t.Fatalf("wrong response, expected:\n %v\nbut got:\n %v", given.res, got)
}
}
mock.AssertExpectationsForObjects(t, storage) // 5
})
}
}
Warto się na chwile pochylić nad powyższym kodem i przeanalizować go nieco głębiej. Snippet ten posiada kilka oznaczonych punktów, które są warte wyjaśnienia:
- Pomocnicza funkcja, która inicjalizuje obiekt
http.Request
, który niesie ze sobą dane w formacie JSON. - Resetowanie mock’ów.
- Nie każdy przypadek będzie potrzebował dodatkowej inicjalizacji. Dla utrzymania przejrzystości, funkcja
init
jest opcjonalna. - Sprawdzamy, czy oczekiwanym rezultatem jest błąd. Innymi słowy, czy jest to przypadek pesymistyczny. Jeżeli tak, porównujemy zwrócony błąd, do tego którego oczekujemy.
- Sprawdzamy, czy liczba wywołań metod naszych atrap zgadza się z oczekiwaniami.
Przypadki
Brakująca nazwa
cases := map[string]struct {
req *http.Request
init func(*testing.T)
res interface{}
err error
}{
"missing-name": {
req: req(t, &example.Payload{}),
err: errors.New("missing name"),
},
}
Przekroczenie czasu żądania
Ten test weryfikuje, czy zwrócony błąd przez bazę danych jest przekazany dalej. Wspólny dekorator dla wszystkich kontrolerów mógłby interpretować taki błąd i ustawiać odpowiedni kod statusu. W tym przypadku byłby to http.StatusRequestTimeout
.
cases := map[string]struct {
req *http.Request
init func(*testing.T)
res interface{}
err error
}{
"deadline-exceeded": {
req: req(t, &example.Payload{
Name: "brand new car",
}),
err: context.DeadlineExceeded,
init: func(t *testing.T) {
storage.On("Put", mock.Anything, mock.AnythingOfType("*example.Payload")).
Return(context.DeadlineExceeded).
Once()
},
},
}
Zły format żądania
W przypadku gdy klient wyśle źle sformatowane dane, powinien zostać powiadomiony o tym.
cases := map[string]struct {
req *http.Request
init func(*testing.T)
res interface{}
err error
}{
"text-payload": {
req: httptest.NewRequest(http.MethodPut, "http://localhost", strings.NewReader("not-json-at-all")),
err: errors.New("invalid json payload"),
},
}
Sukces
Zdecydowanie najpełniejszy przykład. Pokazuje on jak stosować paczkę mock.
cases := map[string]struct {
req *http.Request
init func(*testing.T)
res interface{}
err error
}{
"success": {
req: req(t, &example.Payload{
Name: "brand new car",
}),
res: &example.Payload{
ID: 100,
Name: "brand new car",
},
init: func(t *testing.T) {
storage.On("Put", mock.Anything, mock.AnythingOfType("*example.Payload")).
Run(func(args mock.Arguments) {
if pay, ok := args.Get(1).(*example.Payload); ok {
pay.ID = 100
}
}).
Return(nil).
Once()
},
},
}
Podsumowanie
Prezentowany sposób jest czytelny i nieźle skaluje się wraz ze wzrostem przypadków, jak i testów. Osiągnięcie pełnego pokrycia kodu w testach nie jest problemem:
go test -v -cover
=== RUN TestPutCarController_Handle
=== RUN TestPutCarController_Handle/deadline-exceeded
=== RUN TestPutCarController_Handle/text-payload
=== RUN TestPutCarController_Handle/missing-name
=== RUN TestPutCarController_Handle/success
--- PASS: TestPutCarController_Handle (0.00s)
--- PASS: TestPutCarController_Handle/deadline-exceeded (0.00s)
mock.go:420: PASS: Put(string,mock.AnythingOfTypeArgument)
--- PASS: TestPutCarController_Handle/text-payload (0.00s)
--- PASS: TestPutCarController_Handle/missing-name (0.00s)
--- PASS: TestPutCarController_Handle/success (0.00s)
mock.go:420: PASS: Put(string,mock.AnythingOfTypeArgument)
PASS
coverage: 100.0% of statements
ok github.com/piotrkowalczuk/blog/examples/testy-jednostkowe-w-golangu 0.016s
Jest jednak jeszcze trochę miejsca na ulepszenia. Wspólna funkcja assertError
pozwoliłoby wyeliminować duplikację kodu pomiędzy testami. Dodanie własnego typu błędu umożliwiłoby lepszą obsługę błędów w samym kontrolerze, jak i bardziej elastyczną asercję. Dekodowanie zawartości żądania poza kontrolerem pozwoliłoby na jeszcze lepszą separację warstw.
W kolejnym kroku polecam zapoznać się ze świetnym wpisem Error handling in Upspin. Rob Pike przedstawia tam dość nowatorskie jak na standardy Go podejście do obsługi błędów.
Pełen kod źródłowy do wglądu tutaj.