Памятка по модульным тестам в Go #
Личная памятка по написанию модульных тестов в Go
Структура теста #
Принципы:
- Обычно модульные тесты в Go располагаются в том же пакете, что и тестируемый код, но в файлах с суффиксом “_test.go”.
- Обычно я указываю в файлах тестов такое же имя пакета, как в тестируемом коде.
Пример:
| Файл | Первая строка в файле | Описание |
|---|---|---|
| query.go | package sql | Тестируемый код |
| query_test.go | package sql | Модульные тесты |
Функции тестов экспортируемые (названы с большой буквы) и принимают парамтер t* testing.T.
Для проверок я предпочитаю функции пакета "github.com/stretchr/testify/require", которые паникуют при первой ошибке:
package main
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestSolution(t *testing.T) {
// Возникнет паника из-за несоответствия expected и actual
expected := 12
actual := 24
require.Equal(t, expected, actual)
}Параметризованные тесты #
Чаще всего мои модульные тесты — параметризованные. Если нет смысла в параметризации, то стоит подумать: почему этот тест не интеграционный?
Параметризованные тесты устроены так:
- основная функция теста перебирает коллекцию примеров
- на каждый пример вызываем метод
t.Run(name string, f func(t *testing.T)):- в параметр name кладём отличительное имя этого примера — оно будет показано в выводе
go testв случае ошибки - в параметр f кладём анонимную функцию, выполняющую тест для этого примера
- в параметр name кладём отличительное имя этого примера — оно будет показано в выводе
Удобно определить структуру с именем testCase и использовать слайс таких структур в параметризованном тесте. Для такой структуры можно определить метод name().
Пример:
package main
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
)
// Структура хранит один пример параметризованного теста.
type testCase struct {
grid [][]int
expected int
}
// Метод name создаёт уникальное имя примера, чтобы отличить пример в выводе `go test`.
func (c *testCase) name() string {
return fmt.Sprintf("%v", c.grid)
}
// Основная функция теста запускает дочерние тесты путём вызова `t.Run(...)` для каждого примера.
func TestSolution(t *testing.T) {
cases := []testCase{
{
[][]int{
{1, 3, 1},
{1, 5, 1},
{4, 2, 1},
},
7,
},
{
[][]int{
{1, 2, 3},
{4, 5, 6},
},
12,
},
}
for _, c := range cases {
t.Run(c.name(), func(t *testing.T) {
sum := minPathSum(c.grid)
require.Equal(t, c.expected, sum)
})
}
}Тестирование синхронизации горутин #
В Go 1.24 появился пакет testing/synctest, позволяющий писать относительно детерминированные тесты для конкурентных и параллельных вычислений.
Основные инструменты:
- Функция
synctest.Test(t *testing.T, f func(*testing.T))запускает тестовый сценарий в изолированном «пузыре» - все горутины, запущенные внутри «пузыря», принадлежат к нему же - пакет time внутри «пузыря» эмулирует ход времени, начиная с 2000-01-01 00:00:00 UTC - Функция
synctest.Wait()ожидает завершения либо блокировки всех остальных горутин в «пузыре»
Для теста, использующего synctest, есть ограничения:
- не следует взаимодействовать с горутинами, запущенными вне теста — кроме горутины, выполняющей тестовый сценарий
- не следует использовать сеть или запускать дочерние процессы
Пример теста для задачи 1115. Print FooBar Alternately с leetcode:
package main
import (
"strings"
"sync"
"testing"
"testing/synctest"
"github.com/stretchr/testify/require"
)
func TestSolution(t *testing.T) {
// Запускаем «пузырь» с изолированными горутинами
synctest.Test(t, func(t *testing.T) {
f := NewFooBar(3)
// Функция print будет вызвана тестируемым кодом.
var callsMutex sync.Mutex
var calls []string
print := func(text string) {
callsMutex.Lock()
defer callsMutex.Unlock()
calls = append(calls, text)
}
// Горутина будет принадлежать «пузырю».
go func() {
f.Foo(func() { print("foo") })
}()
// Горутина будет принадлежать «пузырю».
go func() {
f.Bar(func() { print("bar") })
}()
// Ожидаем завершения либо блокировки всех остальных горутин в пузыре.
// В данном случае будет завершение, а не блокировка.
synctest.Wait()
callsStr := strings.Join(calls, "")
require.Equal(t, "foobarfoobarfoobar", callsStr)
})
}Типовые приёмы модульных тестов #
Клонирование изменяемых данных #
Если вы планируете передать изменяемую структуру в тестируемый код и при этом использовать те же данные для проверки результата, то передаваемые данные лучше клонировать:
- хеш-таблицу можно скопировать функцией maps.Clone
- одномерный слайс можно скопировать функцией slices.Clone
Если слайс содержит изменяемые объекты либо является многомерным, то можно написать свою функцию для более глубокого клонирования данных.
Пример:
// Клонирует двумерный слайс, представляющий матрицу чисел.
func cloneMatrix(m [][]int) [][]int {
cloned := make([][]int, 0, len(m))
for _, row := range m {
cloned = append(cloned, slices.Clone(row))
}
return cloned
}Проверка алгоритма с помощью альтернативной реализации #
Если вы пишете оптимизированный алгоритм — скорее всего для той же задачи есть другой алгоритм, максимально простой и при этом умеренно эффективный. Разумно использовать его для тестирования оптимизированного алгоритма, выполняя сравнение результатов двух версий на различных данных.
Пример: задача 74. Search a 2D Matrix на leetcode.
В этой задаче надо реализовать поиск числа в матрице (двумерном слайсе) с монотонно возрастающими числами в виде функции с такой сигнатурой:
func searchMatrix(matrix [][]int, target int) bool {
}Существует максимально простой неоптимальный способ проверить вхождение числа в матрицу:
- Создать хеш-массив
map[int]bool, используемый как множество чисел - Проверять, попадает ли число в это множество
Итого мы будем проверять решение путём сверки с альтернативным алгоритмом (который должен быть слишком прост, чтобы мы могли в нём ошибиться):
package main
import (
"fmt"
"slices"
"testing"
"github.com/stretchr/testify/require"
)
// cloneMatrix клонирует матрицу чисел
func cloneMatrix(m [][]int) [][]int {
cloned := make([][]int, 0, len(m))
for _, row := range m {
cloned = append(cloned, slices.Clone(row))
}
return cloned
}
// matrixToSet создаёт множество чисел из матрицы чисел
func matrixToSet(m [][]int) map[int]bool {
set := make(map[int]bool, len(m)*len(m[0]))
for _, row := range m {
for _, value := range row {
set[value] = true
}
}
return set
}
// TestSolution проверяет решения, используя простой алгоритм поиска в хеш-массиве
// для проверки оптимизированного алгоритма поиска в матрице монотонно возрастающих чисел.
func TestSolution(t *testing.T) {
cases := [][][]int{
{
{1, 3, 5, 7},
{10, 11, 16, 20},
{23, 30, 34, 60},
},
{
{1},
},
}
for _, matrix := range cases {
set := matrixToSet(matrix)
// Определяем диапазон проверяемых чисел:
// - начинаем с числа, на 1 меньшего наименьшего из чисел матрицы
// - заканчиваем числом, на 1 большим наибольшего из чисел матрицы
i := matrix[0][0] - 1
iMax := matrix[len(matrix)-1][len(matrix[0])-1] + 1
for ; i <= iMax; i++ {
t.Run(fmt.Sprintf("%v, %d", matrix, i), func(t *testing.T) {
actual := searchMatrix(cloneMatrix(matrix), i)
expected := set[i]
require.Equal(t, expected, actual)
})
}
}
}