Существует множество видов функциональных и нефункциональных тестов:
- Юнит-тесты — тестируют минимальную часть функциональности (функцию или методы) в полной изоляции от внешних зависимостей. По сути, тестируются отдельные небольшие кусочки кода.
- Интеграционные тесты — тестируют взаимодействие нескольких крупных частей приложения, например систем оформления заказов и оплаты.
- End-to-end-тесты — тестируют работоспособность всей системы.
- Мутационные тесты — тестируют код на устойчивость к случайным изменениям.
- Нагрузочные тесты — используются для определения максимальной нагрузки, которую система способна выдержать с допустимым уровнем деградации.
Note
Напомним, что функциональные тесты проверяют работоспособность разработанного кода, то есть соответствие функциональным требованиям. Нефункциональные (например, нагрузочные) определяют соответствие кода требованиям надёжности, качества, сопровождения и т. п.
Сконцентрируемся на юнит-тестах, потому что:
- Это самый простой для анализа вид тестов.
- При правильном подходе к тестированию таких тестов в кодовой базе будет большинство.
- Техники и приёмы, которые будут продемонстрированы на их примере, можно использовать и в других типах функциональных тестов.
Инструментарий языка Go, поставляемый вместе с компилятором, включает в себя обширные средства тестирования. Будем рассматривать именно их.
Где размещаются юнит-тесты в Go?
В Go все тесты должны располагаться в файлах с суффиксом _test.go
: например, user_test.go
. Юнит-тесты принято располагать рядом с тестируемым кодом. Файлы user.go
и user_test.go
обычно лежат в одной и той же директории.
Файлы *_test.go
не участвуют в компиляции финальной сборки проекта, поэтому можно не бояться импортировать в них большие библиотеки вроде stretchr/testify
.
Тем не менее при компиляции тестов, как и основного кода, запрещены циклические импорты. Однако при написании тестов они могут возникать часто, поскольку в тестах может понадобиться код, который зависит от тестируемого кода. В таком случае тесты будут зависеть от тестируемого кода и, следовательно, импортировать сами себя. Поэтому для тестов сделали единственное исключение из правила «одна директория — один пакет». Тестовые файлы могут располагаться в пакете с суффиксом _test
— и этой возможностью лучше пользоваться.
Также в пакете _test
стоит располагать код, нужный исключительно для тестов. Допустим, надо сделать приватный тип публичным или добавить класс вспомогательных методов. Вспомогательный файл (часто его называют harness_test.go
или common_test
) может выглядеть так:
package user
import "context"
type UserDAO = userDAO
func (p *userProvider) ResetAllCaches(ctx context.Context) error {
// сбрасываем кеши провайдера
}
Тесты в Go
Теперь, когда вы знаете, где и как хранить тестовый код, поговорим о том, как именно писать тесты на Go.
В Go все тесты — это функции вида:
func TestXxx(t *testing.T)
Префикс Test
обязателен. В качестве Xxx
обычно указывают название тестируемой функции. У каждой тестируемой функции может быть несколько тестов, и тогда нужно указать дополнительную информацию по конкретному тесту.
Для примера протестируем функцию Add
, которая должна сложить два числа при условии, что они положительные. Если одно или оба числа равны нулю, функция должна вернуть ошибку.
Файл add.go
:
package math
import "errors"
func Add(a, b int) (int, error) {
if a == 0 || b == 0 {
return 0, errors.New("arg is zero")
}
if a < 0 || b < 0 {
return 0, errors.New("arg is negative")
}
return a + b, nil
}
Файл add_test.go
:
package math
import "testing"
func TestAddPositive(t *testing.T) {
sum, err := Add(1, 2)
if err != nil {
t.Error("unexpected error")
}
if sum != 3 {
t.Errorf("sum expected to be 3; got %d", sum)
}
}
func TestAddNegative(t *testing.T) {
_, err := Add(-1, 2)
if err == nil {
t.Error("first arg negative - expected error not be nil" )
}
_, err = Add(1, -2)
if err == nil {
t.Error("second arg negative - expected error not be nil" )
}
_, err = Add(-1, -2)
if err == nil {
t.Error("all arg negative - expected error not be nil" )
}
}
Объект *testing.T
предоставляет доступ к нескольким базовым методам:
Error
,Errorf
— записывает сообщение вerror
-лог и помечает тест как непройденный. Исполнение теста продолжается.Fatal
,Fatalf
— делает то же самое, но исполнение теста немедленно завершается. Этот метод часто используется в рабочих проектах при обработке ошибок. Очень удобен при отладке, когда тестируется какой-то конкретный участок кода.Skip
,Skipf
— позволяет пропустить тест с сообщением. Используется, когда окружение для теста не задано. Типичный сценарий — прогон интеграционных тестов с внешним сервисом только на CI, где к нему есть доступы.Log
,Logf
— позволяет выводить лог-сообщения внутри теста. Преимущество перед методами пакетаfmt
в том, что из лога сразу видно, к какому тесту относится сообщение.Run(name string, testf func(t *testing.T) )
— запускает функцию в качестве теста, что удобно при выполнении нескольких запусков теста, например, с разными именами.
go test
Теперь разберём запуск написанных тестов. Для этого в экосистеме Go используется стандартная утилита go test. Она позволяет запускать тесты следующими способами:
- Тесты на основании положения кода в директории. Чтобы запустить все тесты в директории, достаточно перейти в неё и выполнить команду
go test
илиgo test -v
. Флаг-v
перенаправляет наstdout
всё, что тесты логируют вstdout
иstderr
. - Тесты во всех поддиректориях текущей директории:
go test ./...
- Все тесты пакета. Чтобы запустить все тесты в пакете, утилите
go test
надо передать пути импорта этих пакетов, разделённые пробелами. Например:go test math github.com/username/packagename github.com/username/packagename2
. - Тесты, подходящие под регулярное выражение. Также есть возможность протестировать некоторое подмножество тестов пакета. Для этого используется флаг
-run
утилитыgo test
.
Например, если нужно протестировать все тест-кейсы с префиксом TestFunc
в пакете github.com/ytuser/ytpackage
, вызов команды go test
будет выглядеть так:
go test github.com/ytuser/ytpackage -run ^TestFunc
В качестве аргумента передаётся регулярное выражение, под которое должны подходить названия тестов.
Кеширование тестов
Если повторно запустим команду go test <PACKAGE_NAME>
, то увидим, что вывод команды изменился. В случае с пакетом math
получим:
ok math (cached)
Дело в том, что в режиме тестирования пакета go test
кеширует результат прогона тестов и, если код и тесты не были изменены, использует закешированный результат.
Отключить кеширование можно двумя способами:
- Передать флаг
-count 1
, который определяет, сколько раз нужно запустить каждый тест (по умолчанию — один). Соответственно,-count 1
не изменяет количество запусков — если сравнивать со значением по умолчанию, — но выключает кеширование. - Запустить команду
go test clear
, очищающую кеш.
Дополнительные настройки тестирования
-cpu 1,2,4
— позволяет прогнать все тесты несколько раз с использованием разного количества потоков. Пригодится, если нужно протестировать параллельный код и убедиться, что на машинах с разным количеством ядер он будет работать корректно.-list regexp
— вместо того чтобы запускать тесты,go test
выведет в консоль имена тестов, подходящих под переданное регулярное выражение.-parallel n
— позволяет параллельно выполнять тесты, которые в теле вызываютt.Parallel
.-run regexp
— позволяет запускать конкретные тесты.-short
— если этот флаг передан, тоt.Short() == true
. В этом случае можно либо пропустить длительные тесты, либо урезать их функционал.-v
— подробное логирование. Даже в случае успешного прохождения тестов весь их лог будет выведен в консоль.
Покрытие кода
Одна из важнейших метрик качества кода — степень покрытия тестами (test coverage). В Go для вычисления этой метрики используется флаг -cover
утилиты go test
, подробнее о котором можно почитать в официальном блоге Go.
Например, если вы хотите узнать степень покрытия тестами пакета math
из стандартной библиотеки, надо вызвать команду go test math -cover
:
ok math 0.003s coverage: 86.8% of statements
Определим покрытие тестами функции Add
из примера выше:
% go test -cover
PASS
coverage: 80.0% of statements
ok tests_06 0.233s
Но знания одной только метрики зачастую не хватает, и нужно выяснить, какие именно строки кода не были задействованы при прогоне тестов. Этот функционал тоже идёт «из коробки» в виде флага -coverprofile
утилиты go test
и специальной утилиты go cover для анализа профиля покрытия тестами.
Итак, если вы хотите узнать, какой именно код пакета math
не был покрыт тестами, надо сделать следующее:
- Запустить на нём утилиту
go test
и сохранить файл профиля покрытия тестами. Путь к файлу с профилем — это значение флага-coverprofile
. В данном случае сохраним его в файлcoverage.out
текущей директории:
go test . -coverprofile=coverage.out
- Проанализировать полученный файл утилитой
cover
. Например, по собранному профилю можно получить HTML-представление исходного кода с дополнительной разметкой, связанной с покрытием тестами:
go tool cover -html=coverage.out
После выполнения автоматически запустится браузер, где будет отображена информация по покрытию.
Удобное тестирование
Можно тестировать код, используя только *testing.T
, но он не предоставляет доступ к функциям вроде проверки на равенство, проверки на возврат ошибки или проверки на наличие паники при вызове переданного колбэка.
Этот пробел заполняют сторонние библиотеки, среди которых самая популярная — testify. Следует отметить, что они не являются заменой стандартного testing
, а расширяют и дополняют его.
- testify — это швейцарский нож в мире тестирования Go-кода. Поэтому поговорим про часто используемые пакеты из этого репозитория отдельно.
- assert — пакет, в котором собрано множество удобных функций с говорящими названиями: вроде
assert.Equal
илиassert.Nil
. Предназначен для использования внутри обычных Go-тестов видаfunc TestXxx(t *testing.T)
(про необычные поговорим, когда доберёмся до suite). - require — то же самое, что assert, но в случае, если проверки из этого пакета падают, выполнение теста останавливается. То есть внутри используется
Fatal
вместоError
. - suite — пакет, который вводит концепцию тест-сьюта (test suite). Если вы работали с тестами на Java или Python, то, скорее всего, она вам знакома.
Сьют — это объект, содержащий сами тесты в виде методов, а также некоторый набор переменных, доступный всем тестам. Кроме того, сьют даёт возможность определить код, который будет вызван перед и/или после исполнения теста. Эта функция полезна, например, для очистки базы данных между тестами (в случае интеграционных тестов).
Типичный пример работы со suite из официальной документации выглядит так:
import (
"testing"
"github.com/stretchr/testify/suite"
)
// ExampleTestSuite — это тестовый сьют, который создан путём эмбеддинга suite.Suite.
type ExampleTestSuite struct {
suite.Suite
VariableThatShouldStartAtFive int
}
// SetupTest заполняет переменную VariableThatShouldStartAtFive перед началом теста.
func (suite *ExampleTestSuite) SetupTest() {
suite.VariableThatShouldStartAtFive = 5
}
func (suite *ExampleTestSuite) TestExample() { // все тесты должны начинаться со слова Test
suite.Equal(5, suite.VariableThatShouldStartAtFive)
}
func TestExampleTestSuite(t *testing.T) {
// чтобы go test смог запустить сьют, нужно создать обычную тестовую функцию
// и вызвать в ней suite.Run
suite.Run(t, new(ExampleTestSuite))
}
Паттерны тестирования
Есть несколько рекомендаций, как стоит и не стоит писать тесты. Они не специфичны для Go, поэтому их можно применить для тестирования на любом языке.
Каждый тест должен тестировать что-то одно
Например, если вы тестируете функцию func Divide(a, b int) (int, error)
, не стоит писать такой код:
func TestDivision(t *testing.T) {
result, err := Divide(0, 1)
require.NoError(t, err)
assert.Equal(t, 0, result)
result, err = Divide(4, 2)
require.NoError(t, err)
assert.Equal(t, 2, result)
_, err = Divide(1, 0)
require.Error(err)
}
Он будет падать при любой проблеме в тестируемой функции. Лучше разбить его на три теста (или три подтеста внутри этого теста), каждый из которых тестирует свой специфический сценарий:
func TestDivision(t *testing.T) {
t.Run("ZeroNumerator", func(t *testing.T) {
result, err := Divide(0, 1)
require.NoError(t, err)
assert.Equal(t, 0, result)
})
t.Run("BothNonZero", func(t *testing.T) {
result, err = Divide(4, 2)
require.NoError(t, err)
assert.Equal(t, 2, result)
})
t.Run("ZeroDenominator", func(t *testing.T) {
_, err = Divide(1, 0)
require.Error(err)
})
}
Тесты не должны зависеть друг от друга
Если один тест опирается на глобальное состояние, устанавливаемое другими тестами, — это проблема. Тестовая архитектура такого типа приводит к неприятным ошибкам, когда локально тесты проходят, а на CI/CD иногда падают, потому что время от времени порядок выполнения тестов меняется.
Эта же рекомендация относится к тестам внутри одного сьюта. При написании каждого теста нужно исходить из того, что ему на вход передаётся состояние после вызова подготовительного метода SetupTest
.
Результат работы теста — это не лог
При написании теста стоит считать, что при успешном прохождении теста никто на его логи смотреть не будет. Все контракты, которые фиксирует тест, должны быть прописаны в виде проверок. В этом случае можно безопасно гонять тесты на CI/CD без участия человека.
Table-driven-тесты
В примерах выше мы видели, что тесты содержат много повторяющегося кода, и это неспроста. Существует паттерн тестирования, который называется table-driven, или, говоря по-русски, «табличное тестирование». Он реализуется не только в Go, но и в других языках программирования.
Суть паттерна в отделении тестовых данных от выполнения самих тестов. Для коротких тестов паттерн может показаться избыточным, однако он позволяет эффективно организовать и расширять тесты, если тестовых наборов требуется несколько.
Note
В Go есть пакет генерации шаблонов тестов именно в стиле table-driven, поэтому для рассмотрения примера будем использовать генерацию кода.
Если вы используете GoLand или VS Code с плагинами, то инструменты генерации уже встроены в IDE. Если нет, то потребуется немного дополнительных действий.
Установите пакет github.com/cweill/gotests
:
$ go get -u github.com/cweill/gotests/...
Вернёмся к нашей функции Add
. Сгенерируем для неё заготовку теста:
gotests -only Add .
В файл math_test.go
добавился следующий код. Мы добавили к нему комментарии, чтобы было понятнее, что он делает.
func TestAdd(t *testing.T) {
// args — описывает аргументы тестируемой функции
type args struct {
a int
b int
}
// описывает структуру тестовых данных и сами тесты
tests := []struct {
name string // название теста
args args // аргументы
want int // ожидаемое значение
wantErr bool // должна ли функция вернуть ошибку
}{
// TODO: Add test cases.
// сюда добавляем тестовые случаи (testcases)
}
// вызываем тестируемую функцию для каждого тестового случая
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Add(tt.args.a, tt.args.b)
if (err != nil) != tt.wantErr {
t.Errorf("Add() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Add() = %v, want %v", got, tt.want)
}
})
}
}
Добавим тестовые случаи в место, указанное в //TODO
:
{
name: "Test Positive",
args: args{
a: 1,
b: 2,
},
want: 3,
wantErr: false,
},
{
name: "Test Negative 1",
args: args{
a: -1,
b: 2,
},
want: 0,
wantErr: true,
},
{
name: "Test Negative 2",
args: args{
a: 1,
b: -2,
},
want: 0,
wantErr: true,
},
{
name: "Test Negative all",
args: args{
a: -1,
b: -2,
},
want: 0,
wantErr: true,
},
И запустим тесты.
Удобство такого подхода в том, что не требуется добавлять вызов и проверки, чтобы добавить ещё несколько случаев. Достаточно описать аргументы и желаемое поведение.
Ключевые мысли
- В Go инструменты тестирования обеспечивают всё необходимое для проведения юнит-тестирования.
- Код тестов определяется компилятором и не компилируется в конечную сборку.
- Необходимые инструменты можно получить из сторонних библиотек. Особенно полезны testify, suite, gotests.
- Table-driven test — удобный способ организации тестов, который часто встречается на практике.
📂 Go | Последнее изменение: 01.09.2024 14:02