Русский
Русский
English
Статистика
Реклама

Подсказки по написанию тестов в приложениях на Go

В нашей компании в стеке разработки есть язык Go. И иногда,при написании unit-тестов к приложениям написанным на Go, у нас появляются сложности. В этой статье мы расскажем о некоторых моментах, которые мы учитываем при написании тестов. На примерах разберём как их можно использовать.

Используем интерфейсы при разработке

Об этом говорится в каждой статье про написании тестов, и эта не будет исключением. Интерфейсы уменьшают связанность между пакетами. Но главное их преимущество при написании тестов заключается в том, что интерфейсы позволяют мокать конкретные зависимости. Например, есть у нас функция, которая на основе содержимого Redis принимает какое-то решение о своей работе:

package yourpackage import (    "context"     "github.com/go-redis/redis/v8") func CheckLen(ctx context.Context, client *redis.Client, key string) bool {    val, err := client.Get(ctx, key).Result()    if err != nil {    return false    }    return len(val) < 10  }

Тесты для неё могут выглядеть примерно так:

package yourpackage import (    "context"    "testing"     "github.com/go-redis/redis/v8") func TestCheckLen(t *testing.T) {    ctx := context.Background()    rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})    err := rdb.Set(ctx, "some_key", "value", 0).Err()    if err != nil {    t.Fatalf("redis return error: %s", err)    }     got := CheckLen(ctx, rdb, "some_key")    if !got {    t.Errorf("CheckLen return %v; want true", got)    }}

Но как проверить ситуацию, когда Redis возвращает ошибку? Или что делать, если мы не хотим добавлять Redis в наш CI? То есть как нам замокать вызов Redis? И ответ на эти вопросы используйте интерфейсы!

Перепишем наш код с использованием интерфейсов:

package yourpackage import (    "context"     "github.com/go-redis/redis/v8") type Storage interface {    Set(ctx context.Context, key string, v interface{}) error    Get(ctx context.Context, key string) (string, error)} type RedisStorage struct {    Redis *redis.Client} func (rs *RedisStorage) Set(ctx context.Context, key string, v interface{}) error {    return rs.Redis.Set(ctx, key, v, 0).Err()} func (rs *RedisStorage) Get(ctx context.Context, key string) (string, error) {    return rs.Redis.Get(ctx, key).Result()} func CheckLen(ctx context.Context, storage Storage, key string) bool {    val, err := storage.Get(ctx, key)    if err != nil {    return false    }    return len(val) < 10}

Интерфейсы не только упрощают написание тестов, но и в процессе разработки облегчают переход на другие технологии, например, замену Redis на Memcached. Тест же с использованием мока будет выглядеть примерно так:

package yourpackage import (    "context"    "testing") type testRedis struct{} func (t *testRedis) Get(ctx context.Context, key string) (string, error) {    return "value", nil}func (t *testRedis) Set(ctx context.Context, key string, v interface{}) error {    return nil} func TestCheckLen(t *testing.T) {   ctx := context.Background()    storage := &testRedis{}     got := CheckLen(ctx, storage, "some_key")    if !got {    t.Errorf("CheckLen return %v; want true", got)    }}

Используем генераторы моков

Понятное дело, что для каждого случая писать свой мок немного избыточно. Можно попробовать написать универсальный мок. А можно попробовать его сгенерировать на основе интерфейса. Существует множество генераторов моков. Нам нравится https://github.com/vektra/mockery.

Для примера выше написание тестов с использованием генератора могло бы выглядеть следующим образом. Сначала сгенерируем мок для нашего интерфейса:

mockery --recursive=true --inpackage --name=Storage

И дальше используем его в тестах следующим образом:

package yourpackageimport (    "context"    "testing"     mock "github.com/stretchr/testify/mock") func TestCheckLen(t *testing.T) {    ctx := context.Background()     storage := new(MockStorage)    storage.On("Get", mock.Anything, "some_key").Return("value", nil)     got := CheckLen(ctx, storage, "some_key")    if !got {    t.Errorf("CheckLen return %v; want true", got)    }

Перехватываем логирование

Допустим у нас есть код, который логирует свои действия с использованием какой-либо сторонней библиотеки, например, Logrus.

package yourpackage import (    log "github.com/sirupsen/logrus") func Minus(a, b int) int {    log.Infof("Minus(%v, %v)", a, b)    return a - b} func Plus(a, b int) int {    log.Infof("Plus(%v, %v)", a, b)    return a + b} func Mul(a, b int) int {    log.Infof("Mul(%v, %v)", a, b)    return a + b // тут ошибка}

И тесты к этому коду:

package yourpackage import "testing" func TestPlus(t *testing.T) {    a, b, expected := 3, 2, 5    got := Plus(a, b)    if got != expected {    t.Errorf("Plus(%v, %v) return %v; want %v", a, b, got, expected)    }} func TestMinus(t *testing.T) {    a, b, expected := 3, 2, 1    got := Minus(a, b)    if got != expected {    t.Errorf("Minus(%v, %v) return %v; want %v", a, b, got, expected)    }} func TestMul(t *testing.T) {    a, b, expected := 3, 2, 6    got := Mul(a, b)    if got != expected {    t.Errorf("Mul(%v, %v) return %v; want %v", a, b, got, expected)    }}

При запуске тестов мы видим, помимо ошибки, ещё логирование от других тестов:

time="2021-03-22T22:09:54+03:00" level=info msg="Plus(3, 2)"time="2021-03-22T22:09:54+03:00" level=info msg="Minus(3, 2)"time="2021-03-22T22:09:54+03:00" level=info msg="Mul(3, 2)"--- FAIL: TestMul (0.00s)yourpackage_test.go:55: Mul(3, 2) return 5; want 6FAILFAILgotest2/yourpackage 0.002sFAIL

Если кодовая база большая, то упавшие тесты потеряются среди лишнего логирования. Чтобы такого не было, можно сделать перехват логов в тестах. Для приведённого примера это может выглядеть вот так:

package yourpackage import (    "io"    "testing"     "github.com/sirupsen/logrus") type logCapturer struct {    *testing.T    origOut io.Writer} func (tl logCapturer) Write(p []byte) (n int, err error) {    tl.Logf((string)(p))    return len(p), nil} func (tl logCapturer) Release() {    logrus.SetOutput(tl.origOut)} func CaptureLog(t *testing.T) *logCapturer {    lc := logCapturer{T: t, origOut: logrus.StandardLogger().Out}    if !testing.Verbose() {    logrus.SetOutput(lc)    }    return &lc} func TestPlus(t *testing.T) {    defer CaptureLog(t).Release()    a, b, expected := 3, 2, 5    got := Plus(a, b)    if got != expected {    t.Errorf("Plus(%v, %v) return %v; want %v", a, b, got, expected)    }} func TestMinus(t *testing.T) {    defer CaptureLog(t).Release()    a, b, expected := 3, 2, 5    got := Minus(a, b)    if got != expected {    t.Errorf("Minus(%v, %v) return %v; want %v", a, b, got, expected)    }} func TestMul(t *testing.T) {    defer CaptureLog(t).Release()    a, b, expected := 3, 2, 5    got := Mul(a, b)    if got != expected {    t.Errorf("Mul(%v, %v) return %v; want %v", a, b, got, expected)    }}

И тогда вывод тестов будет лаконичнее, и сразу будет понятно, какое логирование и в каком тесте велось:

--- FAIL: TestMul (0.00s)yourpackage_test.go:16: time="2021-03-22T22:10:52+03:00" level=info msg="Mul(3, 2)"yourpackage_test.go:55: Mul(3, 2) return 5; want 6FAILFAILgotest2/yourpackage 0.002sFAIL

Здесь приведён пример для Logrus, но нечто похожее можно сделать с любой библиотекой логирования. Например, для библиотеки Zap есть отдельный модуль, который облегчает тестирование.

Считаем покрытие правильно

В Go всегда была какая-то странность с подсчётом покрытия кода тестами. Сначала нельзя было построить отчёт по покрытию для всех пакетов, написанных в рамках приложения. До сих пор в некоторых репозиториях можно встретить скрипты, похожие на этот, которые используются для запуска тестов по всем пакетам и объединения информации о покрытии в один отчёт. Сейчас с этим всё хорошо, но есть неочевидный момент.

Допустим, наш проект состоит из трёх пакетов. И мы хотим для них посчитать покрытие. Обращаемся за помощью к утилите cover, которая скажет нам примерно следующее:

$ go tool cover -helpUsage of 'go tool cover':Given a coverage profile produced by 'go test':    go test -coverprofile=c.out...Display coverage percentages to stdout for each function:    go tool cover -func=c.out

Пробуем:

$ go test -coverprofile=c.out ./...ok  gotestcover/minus   0.001s  coverage: 100.0% of statements?   gotestcover/mul [no test files]ok  gotestcover/plus    0.001s  coverage: 100.0% of statements

Уже из этого вывода видно, что у нас два пакета покрыты на 100 % и для одного пакета нет тестовых файлов. Получим отчёт о покрытии:

$ go tool cover -func=c.outgotestcover/minus/minus.go:4:   Minus       100.0%gotestcover/plus/plus.go:4: Plus        100.0%total:                      (statements)100.0%

Но тут что-то не так. В отчёте говорится о полном покрытии тестами. Хотя мы знаем, что это не так. Это всё потому, что при подсчёте покрытия не учитывается пакет, в котором нет тестов. Его не будет и в HTML-отчёте. И кажется, что это не всегда может быть корректным, потому что зачастую мы хотим знать покрытие всего кода, а не только того, для которого мы написали тесты. Это можно исправить, указав специальный параметр при запуске тестов:

go test -coverpkg=./... -coverprofile=c.out ./

И теперь отчёт выдаёт ожидаемый процент покрытия тестами:

$ go tool cover -func=c.outgotestcover/minus/minus.go:4:   Minus       100.0%gotestcover/mul/mul.go:4:   Mul         0.0%gotestcover/plus/plus.go:4: Plus        100.0%total:                      (statements)66.7%

Считаем покрытие при тестировании приложения как черного ящика

Писать тесты на Go довольно-таки сложно. И если вы разрабатываете какой-нибудь веб-сервис, то иногда бывает проще написать тесты на другом языке, например, на Python, и тестировать приложение как чёрный ящик.

Но возникает вопрос, а можно ли посчитать покрытие для такого способа тестирования? Да, посчитать можно. В целом, идея довольно проста. Пишем подобный тест:

func TestRunMain(t *testing.T) {    main()}

Запускаем его, потом интеграционные тесты, и завершаем наш тест. Звучит просто, но есть несколько нюансов. Зачастую надо сделать так, чтобы этот тест не запускался со всеми остальными тестами. Он особый, и для него должна быть отдельная логика запуска. Ещё функция main не должна приводить к выходу с ненулевым кодом возврата. И надо реализовать способ выхода из main по сигналу, не завершая при этом сам тест. То есть в целом надо реализовать для нашего web-сервиса graceful shutdown, что несложно сделать, и это в целом полезно. Давайте на примере реализуем небольшой web-сервис, протестируем его с помощью curl, и посчитаем покрытие тестами.

Сервис наш будет выглядеть следующим образом (взято с https://gobyexample.com/http-servers):

package main import (    "context"    "fmt"    "net/http"    "os"    "os/signal"    "time") func hello(w http.ResponseWriter, req *http.Request) {    fmt.Fprintf(w, "hello\n")} func headers(w http.ResponseWriter, req *http.Request) {    for name, headers := range req.Header {    for _, h := range headers {    fmt.Fprintf(w, "%v: %v\n", name, h)    }    }} func main() {    http.HandleFunc("/hello", hello)    http.HandleFunc("/headers", headers)     // Приложим некоторые усилия, чтобы приложение завершилось с нулевым кодом выхода    // Это важно для тестов, и в целом приятно    server := &http.Server{Addr: ":8090", Handler: nil}    // Запускаем приложение в отдельной горутине    go func() {    server.ListenAndServe()    }()     // А в текущей ждём сигнала об остановке приложения    quit := make(chan os.Signal, 1)    signal.Notify(quit, os.Interrupt)    <-quit    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)    defer cancel()    server.Shutdown(ctx)}

И тест к нему:

// +build testrunmain package main import "testing" func TestRunMain(t *testing.T) {    main()}

Комментарий +build testrunmain говорит о том, что тест будет запускаться только в случае, если передан соответствующий tag. Запускаем наш тест:

$ go test -v -tags testrunmain -coverpkg=./... -coverprofile=c.out  ./...=== RUN   TestRunMain

Тестируем с помощью curl:

$ curl 127.0.0.1:8090/hellohello

И завершаем наше тестирование, нажав Ctrl+C:

$ go test -v -tags testrunmain -coverpkg=./... -coverprofile=c.out  ./...=== RUN   TestRunMain^C--- PASS: TestRunMain (100.92s)PASScoverage: 80.0% of statements in ./...ok  gobintest   100.926s    coverage: 80.0% of statements in ./

Можем посмотреть покрытие тестами и увидеть, что обработчик headers остался не протестированным:

$ go tool cover -func=c.outgobintest/main.go:12:   hello       100.0%gobintest/main.go:16:   headers     0.0%gobintest/main.go:24:   main        100.0%total:              (statements)80.0%

Мы рассмотрели некоторые вопросы, которые возникали у нас в компании, когда речь заходила о тестировании приложений на Go. Надеемся, что статья будет вам полезна.

Хотите узнать больше о тестировании в Go? Вот ещё несколько интересных статей на хабре: один, два, три.

Источник: habr.com
К списку статей
Опубликовано: 23.04.2021 14:18:12
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании онлайн-кинотеатр ivi

Тестирование it-систем

Go

Тестирование веб-сервисов

Ivi

Тестирование

Golang

Unit-testing

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru