Памятка по модульным тестам в Go

Памятка по модульным тестам в Go #

Личная памятка по написанию модульных тестов в Go

Структура теста #

Принципы:

  • Обычно модульные тесты в Go располагаются в том же пакете, что и тестируемый код, но в файлах с суффиксом “_test.go”.
  • Обычно я указываю в файлах тестов такое же имя пакета, как в тестируемом коде.

Пример:

ФайлПервая строка в файлеОписание
query.gopackage sqlТестируемый код
query_test.gopackage 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 кладём анонимную функцию, выполняющую тест для этого примера

Удобно определить структуру с именем 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 {
}

Существует максимально простой неоптимальный способ проверить вхождение числа в матрицу:

  1. Создать хеш-массив map[int]bool, используемый как множество чисел
  2. Проверять, попадает ли число в это множество

Итого мы будем проверять решение путём сверки с альтернативным алгоритмом (который должен быть слишком прост, чтобы мы могли в нём ошибиться):

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)
			})
		}
	}
}

Сайт atdd.ru — блог разработчика.