Testowanie aplikacji Go: zacznij we właściwy sposób
Opublikowany: 2022-03-11Ucząc się czegoś nowego, ważne jest, aby mieć świeży stan umysłu.
Jeśli jesteś nowicjuszem w Go i wywodzisz się z języków takich jak JavaScript czy Ruby, prawdopodobnie przywykłeś do korzystania z istniejących frameworków, które pomagają w kpinie, asercji i innych czarach testowych.
Teraz wyeliminuj ideę polegania na zewnętrznych zależnościach lub frameworkach! Testowanie było pierwszą przeszkodą, na którą natknąłem się kilka lat temu, ucząc się tego niezwykłego języka programowania, w czasach, gdy dostępnych było znacznie mniej zasobów.
Teraz wiem, że sukces testowania w Go oznacza podróżowanie lekko po zależnościach (jak w przypadku wszystkich rzeczy Go), poleganie w minimalnym stopniu na zewnętrznych bibliotekach i pisanie dobrego kodu wielokrotnego użytku. Ta prezentacja doświadczeń Blake'a Mizerany'ego, którzy wyruszyli z zewnętrznymi bibliotekami testowymi, jest świetnym początkiem do zmiany sposobu myślenia. Zobaczysz kilka dobrych argumentów na temat korzystania z zewnętrznych bibliotek i frameworków w przeciwieństwie do robienia tego „na drogę”.
Może wydawać się sprzeczne z intuicją budowanie własnego frameworka testowego i kpiących koncepcji, ale jest łatwiejsze niż mogłoby się wydawać i jest dobrym punktem wyjścia do nauki języka. Dodatkowo, w przeciwieństwie do tego, kiedy się uczyłem, ten artykuł poprowadzi Cię przez typowe scenariusze testowania, a także przedstawi techniki, które uważam za najlepsze, aby skutecznie testować i utrzymywać kod w czystości.
Testowanie stołu w Go
Podstawową jednostką testową - sławy "testy jednostkowe" - może być dowolny składnik programu w najprostszej postaci, który pobiera dane wejściowe i zwraca dane wyjściowe. Przyjrzyjmy się prostej funkcji, dla której chcielibyśmy napisać testy. Nie jest nigdzie bliski ideału ani kompletności, ale jest wystarczająco dobry do celów demonstracyjnych:
avg.go
func Avg(nos ...int) int { sum := 0 for _, n := range nos { sum += n } if sum == 0 { return 0 } return sum / len(nos) }
Powyższa funkcja, func Avg(nos ...int)
, zwraca albo zero, albo średnią całkowitą szeregu liczb, które są jej nadawane. Teraz napiszmy na to test.
W Go uważa się, że najlepszą praktyką jest nazwanie pliku testowego o tej samej nazwie, co plik zawierający testowany kod, z dodanym przyrostkiem _test
. Na przykład powyższy kod znajduje się w pliku o nazwie avg.go
, więc nasz plik testowy będzie miał nazwę avg_test.go
.
Zwróć uwagę, że te przykłady to tylko fragmenty rzeczywistych plików, ponieważ dla uproszczenia pominięto definicję pakietu i importy.
Oto test funkcji Avg
:
avg__test.go
func TestAvg(t *testing.T) { for _, tt := range []struct { Nos []int Result int }{ {Nos: []int{2, 4}, Result: 3}, {Nos: []int{1, 2, 5}, Result: 2}, {Nos: []int{1}, Result: 1}, {Nos: []int{}, Result: 0}, {Nos: []int{2, -2}, Result: 0}, } { if avg := Average(tt.Nos...); avg != tt.Result { t.Fatalf("expected average of %v to be %d, got %d\n", tt.Nos, tt.Result, avg) } } }
Należy zwrócić uwagę na kilka kwestii dotyczących definicji funkcji:
- Po pierwsze, przedrostek „Test” w nazwie funkcji testowej. Jest to konieczne, aby narzędzie odebrało to jako ważny test.
- Druga część nazwy funkcji to zazwyczaj nazwa testowanej funkcji lub metody, w tym przypadku
Avg
. - Musimy również przekazać strukturę testową o nazwie
testing.T
, która pozwala na kontrolę przebiegu testu. Więcej informacji na temat tego interfejsu API można znaleźć na stronie dokumentacji.
Porozmawiajmy teraz o formie, w jakiej napisany jest przykład. Zestaw testów (seria testów) jest uruchamiany przez funkcję Avg()
, a każdy test zawiera określone dane wejściowe i oczekiwane dane wyjściowe. W naszym przypadku każdy test wysyła wycinek liczb całkowitych ( Nos
) i oczekuje określonej wartości zwracanej ( Result
).
Wyśmiewanie interfejsu Golang
Jedną z największych i najpotężniejszych funkcji oferowanych przez język Go jest interfejs. Poza mocą i elastycznością, jaką uzyskujemy dzięki interfejsom podczas projektowania naszych programów, interfejsy dają nam również niesamowite możliwości oddzielenia naszych komponentów i dokładnego przetestowania ich w miejscu ich spotkania.
Weźmy wyimaginowany scenariusz, w którym musimy odczytać pierwsze N bajtów z io.Reader i zwrócić je jako ciąg. Wyglądałoby to mniej więcej tak:
readn.go
// readN reads at most n bytes from r and returns them as a string. func readN(r io.Reader, n int) (string, error) { buf := make([]byte, n) m, err := r.Read(buf) if err != nil { return "", err } return string(buf[:m]), nil }
Oczywiście najważniejszą rzeczą do przetestowania jest to, że funkcja readN
, po podaniu różnych danych wejściowych, zwraca prawidłowe wyjście. Można to zrobić za pomocą testów stołowych. Ale są jeszcze dwa inne nietrywialne aspekty, które powinniśmy omówić, a które sprawdzają to:
-
r.Read
jest wywoływany z buforem o rozmiarze n. -
r.Read
zwraca błąd, jeśli zostanie rzucony.
Aby poznać rozmiar bufora przekazywanego do r.Read
, a także przejąć kontrolę nad zwracanym przez niego błędem, musimy zakpić r
przekazywane do readN
. Jeśli spojrzymy na dokumentację Go dotyczącą typu Reader, zobaczymy, jak wygląda io.Reader
:
type Reader interface { Read(p []byte) (n int, err error) }
Wydaje się to dość łatwe. Wszystko, co musimy zrobić, aby zadowolić io.Reader
, to mieć naszą próbną metodę Read
. Tak więc nasz ReaderMock
może wyglądać następująco:
type ReaderMock struct { ReadMock func([]byte) (int, error) } func (m ReaderMock) Read(p []byte) (int, error) { return m.ReadMock(p) }
Przeanalizujmy trochę powyższy kod. Każde wystąpienie ReaderMock
wyraźnie spełnia interfejs io.Reader
, ponieważ implementuje niezbędną metodę Read
. Nasz makieta zawiera również pole ReadMock
, które pozwala nam ustawić dokładne zachowanie mocowanej metody, co bardzo ułatwia nam dynamiczne tworzenie instancji tego, czego potrzebujemy.
Świetną sztuczką bez pamięci, która zapewnia, że interfejs jest spełniony w czasie wykonywania, jest wstawienie do naszego kodu następującego kodu:
var _ io.Reader = (*MockReader)(nil)
To sprawdza asercję, ale niczego nie przydziela, co pozwala nam upewnić się, że interfejs jest poprawnie zaimplementowany w czasie kompilacji, zanim program faktycznie uruchomi jakąkolwiek funkcjonalność, która go używa. Opcjonalna sztuczka, ale pomocna.
Idąc dalej, napiszmy nasz pierwszy test, w którym wywoływany jest r.Read
z buforem o rozmiarze n
. Aby to zrobić, używamy naszego ReaderMock
w następujący sposób:
func TestReadN_bufSize(t *testing.T) { total := 0 mr := &MockReader{func(b []byte) (int, error) { total = len(b) return 0, nil }} readN(mr, 5) if total != 5 { t.Fatalf("expected 5, got %d", total) } }
Jak widać powyżej, zdefiniowaliśmy zachowanie funkcji Read
naszego „fałszywego” io.Reader
za pomocą zmiennej zakresu, która może być później użyta do potwierdzenia poprawności naszego testu. Wystarczająco łatwe.
Przyjrzyjmy się drugiemu scenariuszowi, który musimy przetestować, który wymaga od nas zakłamania Read
, aby zwrócić błąd:
func TestReadN_error(t *testing.T) { expect := errors.New("some non-nil error") mr := &MockReader{func(b []byte) (int, error) { return 0, expect }} _, err := readN(mr, 5) if err != expect { t.Fatal("expected error") } }
W powyższych testach każde wywołanie mr.Read
(naszego wykpiwanego czytnika) zwróci zdefiniowany błąd, więc można bezpiecznie założyć, że poprawne działanie readN
zrobi to samo.
Mocowanie funkcji za pomocą Go
Nieczęsto musimy kpić z funkcji, ponieważ zamiast tego używamy struktur i interfejsów. Są łatwiejsze do kontrolowania, ale czasami możemy wpaść na tę konieczność i często widzę zamieszanie wokół tematu. Niektórzy ludzie pytali nawet, jak kpić z rzeczy takich jak log.Println
. Chociaż rzadko zdarza się, że musimy przetestować dane wejściowe przekazane do log.Println
, skorzystamy z tej okazji, aby to zademonstrować.

Rozważ poniższą prostą instrukcję if
, która rejestruje dane wyjściowe w zależności od wartości n
:
func printSize(n int) { if n < 10 { log.Println("SMALL") } else { log.Println("LARGE") } }
W powyższym przykładzie zakładamy absurdalny scenariusz, w którym specjalnie testujemy, że log.Println
jest wywoływany z poprawnymi wartościami. Abyśmy mogli zakpić z tej funkcji, musimy najpierw owinąć ją w naszą własną:
var show = func(v ...interface{}) { log.Println(v...) }
Zadeklarowanie funkcji w ten sposób - jako zmiennej - pozwala nam nadpisać ją w naszych testach i przypisać jej dowolne zachowanie. W domyśle wiersze odnoszące się do log.Println
są zastępowane przez show
, więc nasz program staje się:
func printSize(n int) { if n < 10 { show("SMALL") } else { show("LARGE") } }
Teraz możemy przetestować:
func TestPrintSize(t *testing.T) { var got string oldShow := show show = func(v ...interface{}) { if len(v) != 1 { t.Fatalf("expected show to be called with 1 param, got %d", len(v)) } var ok bool got, ok = v[0].(string) if !ok { t.Fatal("expected show to be called with a string") } } for _, tt := range []struct{ N int Out string }{ {2, "SMALL"}, {3, "SMALL"}, {9, "SMALL"}, {10, "LARGE"}, {11, "LARGE"}, {100, "LARGE"}, } { got = "" printSize(tt.N) if got != tt.Out { t.Fatalf("on %d, expected '%s', got '%s'\n", tt.N, tt.Out, got) } } // careful though, we must not forget to restore it to its original value // before finishing the test, or it might interfere with other tests in our // suite, giving us unexpected and hard to trace behavior. show = oldShow }
Naszym na wynos nie powinno być „mock log.Println
”, ale w tych bardzo okazjonalnych scenariuszach, kiedy musimy zakpić z funkcji na poziomie pakietu z uzasadnionych powodów, jedynym sposobem na to (o ile mi wiadomo) jest deklarując ją jako zmienną na poziomie pakietu, abyśmy mogli przejąć kontrolę nad jej wartością.
Jednakże, jeśli kiedykolwiek będziemy musieli kpić z rzeczy takich jak log.Println
, można napisać znacznie bardziej eleganckie rozwiązanie, gdybyśmy mieli użyć niestandardowego rejestratora.
Testy renderowania szablonów
Innym dość powszechnym scenariuszem jest sprawdzenie, czy wynik renderowanego szablonu jest zgodny z oczekiwaniami. Rozważmy żądanie GET do http://localhost:3999/welcome?name=Frank
, które zwraca następującą treść:
<html> <head><title>Welcome page</title></head> <body> <h1 class="header-name"> Welcome <span class="name">Frank</span>! </h1> </body> </html>
Na wypadek, gdyby do tej pory nie było to wystarczająco oczywiste, nie jest przypadkiem, że name
parametru zapytania pasuje do zawartości span
zaklasyfikowanego jako „nazwa”. W tym przypadku oczywistym testem byłoby sprawdzenie, czy dzieje się to poprawnie za każdym razem na wielu wyjściach. Uważam, że biblioteka GoQuery jest tutaj niezmiernie pomocna.
Teraz możemy napisać nasz test w ten sposób:
welcome__test.go
func TestWelcome_name(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } doc, err := goquery.NewDocumentFromResponse(resp) if err != nil { t.Fatal(err) } if v := doc.Find("h1.header-name span.name").Text(); v != "Frank" { t.Fatalf("expected markup to contain 'Frank', got '%s'", v) } }
Najpierw sprawdzamy, czy kod odpowiedzi to 200/OK, zanim przejdziemy dalej.
Uważam, że nie jest zbyt daleko idące założenie, że reszta powyższego fragmentu kodu jest oczywista: pobieramy adres URL za pomocą pakietu http
i tworzymy nowy dokument zgodny z goquery na podstawie odpowiedzi, którego następnie używamy do zapytania DOM, który został zwrócony. Sprawdzamy, czy span.name
wewnątrz h1.header-name
zawiera tekst „Frank”.
Testowanie interfejsów API JSON
Go jest często używany do pisania pewnego rodzaju interfejsów API, więc na koniec przyjrzyjmy się kilku wysokopoziomowym sposobom testowania interfejsów API JSON.
Zastanów się, czy punkt końcowy wcześniej zwracał JSON zamiast HTML, więc z http://localhost:3999/welcome.json?name=Frank
oczekiwalibyśmy, że treść odpowiedzi będzie wyglądać mniej więcej tak:
{"Salutation": "Hello Frank!"}
Asercja odpowiedzi JSON, jak można się już domyślać, nie różni się zbytnio od asercji odpowiedzi szablonu, z wyjątkiem tego, że nie potrzebujemy żadnych zewnętrznych bibliotek ani zależności. Standardowe biblioteki Go są wystarczające. Oto nasz test potwierdzający, że dla podanych parametrów zwracany jest poprawny JSON:
welcome__test.go
func TestWelcome_name_JSON(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome.json?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } var dst struct{ Salutation string } if err := json.NewDecoder(resp.Body).Decode(&dst); err != nil { t.Fatal(err) } if dst.Salutation != "Hello Frank!" { t.Fatalf("expected 'Hello Frank!', got '%s'", dst.Salutation) } }
Jeśli zostanie zwrócone coś innego niż struktura, według której dekodujemy, json.NewDecoder
zwróci błąd, a test zakończy się niepowodzeniem. Biorąc pod uwagę, że odpowiedź dekoduje się pomyślnie względem struktury, sprawdzamy, czy zawartość pola jest zgodna z oczekiwaniami – w naszym przypadku „Hello Frank!”.
Konfiguracja i niszczenie
Testowanie za pomocą Go jest łatwe, ale jest jeden problem zarówno z powyższym testem JSON, jak i wcześniejszym testem renderowania szablonu. Obaj zakładają, że serwer działa, a to tworzy zawodną zależność. Nie jest też dobrym pomysłem, aby walczyć z „żywym” serwerem.
Na szczęście Go oferuje pakiet httptest do tworzenia serwerów testowych. Testy uruchamiają własny osobny serwer, niezależny od naszego głównego, dzięki czemu testowanie nie będzie kolidować z produkcją.
W takich przypadkach idealnie jest utworzyć ogólne funkcje setup
i teardown
, które będą wywoływane przez wszystkie testy wymagające działającego serwera. Zgodnie z tym nowym, bezpieczniejszym wzorcem, nasze testy wyglądałyby mniej więcej tak:
func setup() *httptest.Server { return httptest.NewServer(app.Handler()) } func teardown(s *httptest.Server) { s.Close() } func TestWelcome_name(t *testing.T) { srv := setup() url := fmt.Sprintf("%s/welcome.json?name=Frank", srv.URL) resp, err := http.Get(url) // verify errors & run assertions as usual teardown(srv) }
Zwróć uwagę na odwołanie do app.Handler()
. Jest to funkcja najlepszych praktyk, która zwraca http.Handler aplikacji, która może tworzyć instancję serwera produkcyjnego lub serwera testowego.
Wniosek
Testowanie w Go to świetna okazja, aby spojrzeć na Twój program z zewnętrznej perspektywy i wcielić się w rolę odwiedzających lub w większości przypadków użytkowników Twojego API. To świetna okazja, aby upewnić się, że dostarczasz zarówno dobry kod, jak i wysoką jakość obsługi.
Zawsze, gdy nie masz pewności co do bardziej złożonych funkcji w kodzie, testowanie przydaje się jako zapewnienie, a także gwarantuje, że elementy będą nadal dobrze ze sobą współpracować podczas modyfikowania części większych systemów.
Mam nadzieję, że ten artykuł był dla Ciebie przydatny i możesz skomentować, jeśli znasz inne sztuczki testowe.