Go: Автоматическое тестирование

Теория: Параллельные тесты и t.Parallel

В обычных тестах в Go каждая функция выполняется последовательно. Это просто и безопасно, но иногда долго. Если тестов сотни или тысячи, общее время прогонки может быть значительным. В Go есть инструмент для ускорения — параллельные тесты. С помощью метода t.Parallel() тесты можно запускать одновременно.

Но параллельность — это палка о двух концах. С одной стороны, тесты проходят быстрее. С другой стороны, если код работает с общими данными, параллельное выполнение может привести к неожиданным результатам. Такие ситуации называются гонками данных.

Что такое гонки данных

Гонка данных (race condition) — это ситуация, когда несколько потоков (или горутин) одновременно читают и изменяют одну и ту же переменную, и результат зависит от порядка выполнения.

Проще всего это понять на примере.

var counter int

func Increment() {
	counter = counter + 1
}

Функция увеличивает глобальный счётчик. Если запустить её из нескольких горутин одновременно, получится хаос. Иногда результат будет верный, иногда — меньше, чем ожидалось.

Исторически такие ошибки — одни из самых сложных в отладке. В 70–80-е годы программисты писали многопоточные системы без хороших инструментов, и гонки данных приводили к самым странным сбоям: от неправильных расчётов до падений ядерных симуляторов. Ошибка могла проявляться раз в миллион запусков — именно поэтому такие баги страшны.

Как Go помогает находить гонки

Go имеет встроенный инструмент для поиска гонок. Достаточно запустить тесты с флагом -race:

go test -race ./...

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

Пример:

WARNING: DATA RACE
Read at 0x00c0000140b0 by goroutine 7:
  main.Increment()
      /path/to/code.go:10

Это сообщение указывает: была гонка данных в функции Increment. Такой инструмент сильно упрощает жизнь — баги находят тесты, а не пользователи.

Первый пример: последовательные тесты

Для функции без побочных эффектов параллельность безопасна.

func Square(n int) int {
	return n * n
}

Тесты без параллельности:

func TestSquare(t *testing.T) {
	if got := Square(2); got != 4 {
		t.Errorf("Square(2) = %d, хотели 4", got)
	}
	if got := Square(3); got != 9 {
		t.Errorf("Square(3) = %d, хотели 9", got)
	}
}

Тесты проходят последовательно. Всё верно, но долго, если таких тестов сотни.

Подключение параллельности

Чтобы тест можно было запускать одновременно с другими, в его начале вызывают t.Parallel().

func TestSquare_Parallel1(t *testing.T) {
	t.Parallel() // этот тест теперь может идти параллельно
	if got := Square(2); got != 4 {
		t.Errorf("Square(2) = %d, хотели 4", got)
	}
}

func TestSquare_Parallel2(t *testing.T) {
	t.Parallel()
	if got := Square(3); got != 9 {
		t.Errorf("Square(3) = %d, хотели 9", got)
	}
}

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

Параллельные табличные тесты

Особое внимание нужно уделять табличным тестам. В них часто используется цикл, и есть риск допустить ошибку с замыканиями.

Ошибочный код:

func TestSquare_Table(t *testing.T) {
	cases := []struct {
		in, want int
	}{
		{2, 4},
		{3, 9},
		{4, 16},
	}

	for _, c := range cases {
		t.Run(fmt.Sprintf("%d", c.in), func(t *testing.T) {
			t.Parallel()
			got := Square(c.in)
			if got != c.want {
				t.Errorf("Square(%d) = %d, хотели %d", c.in, got, c.want)
			}
		})
	}
}

Здесь переменная c одна на весь цикл, и все под-тесты начинают драться за неё. Результаты будут случайными.

Правильный вариант: делать копию c внутри цикла.

for _, c := range cases {
	c := c // создаём копию
	t.Run(fmt.Sprintf("%d", c.in), func(t *testing.T) {
		t.Parallel()
		got := Square(c.in)
		if got != c.want {
			t.Errorf("Square(%d) = %d, хотели %d", c.in, got, c.want)
		}
	})
}

Теперь каждый под-тест работает со своим набором данных.

Как защититься от гонок

Есть несколько стратегий.

Первое: стараться делать тесты независимыми. У каждого теста — свои данные и своя временная директория. Для файлов лучше использовать t.TempDir(), чтобы тесты не затирали друг другу результаты.

Второе: если тесты работают с общим ресурсом, его нужно защищать. Для этого есть мьютекс.

Что такое мьютекс

Мьютекс (от англ. mutual exclusion, «взаимное исключение») — это объект, который разрешает доступ к ресурсу только одному потоку одновременно. Пока один поток держит мьютекс, другие ждут.

В Go это реализуется через sync.Mutex.

Пример с небезопасным счётчиком:

var counter int

func Increment() {
	counter++
}

Здесь параллельные вызовы приведут к гонке.

Исправленный вариант с мьютексом:

var (
	mu      sync.Mutex
	counter int
)

func IncrementSafe() {
	mu.Lock() // берём «замок»
	counter++
	mu.Unlock() // отпускаем
}

Теперь даже если 100 горутин вызовут IncrementSafe одновременно, результат будет корректным: каждая по очереди возьмёт замок, увеличит счётчик и освободит ресурс.

Когда параллельность не нужна

Если тесты используют глобальные переменные, внешний ресурс (например, файл или базу данных), или порядок выполнения критичен, лучше не включать t.Parallel(). В таких случаях безопаснее оставить последовательный запуск. t.Parallel() позволяет ускорить выполнение тестов, но пользоваться им нужно осторожно. Как только в игру вступает параллельность, появляется риск гонок данных — ситуаций, когда несколько горутин одновременно обращаются к одной переменной и меняют её без синхронизации.

Go помогает разработчику: при запуске с флагом -race он автоматически отслеживает доступ к памяти и сообщает о проблемах. Но устранить их обязан сам программист. Чаще всего это делается так: тесты стараются строить так, чтобы каждый работал только со своими данными, для временных файлов применяют t.TempDir(), а при необходимости разделять общий ресурс используют мьютексы.

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

В итоге параллельные тесты превращаются из потенциальной угрозы в полезный инструмент. Они позволяют делать проверки быстрее и одновременно учат код «жить» в условиях конкуренции, что особенно важно для программ, которые должны быть устойчивыми и надёжными в реальной многопоточной среде.

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845