Зарегистрируйтесь, чтобы продолжить обучение

Параллельные тесты и t.Parallel Go: Автоматическое тестирование

В обычных тестах в 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(), а при необходимости разделять общий ресурс используют мьютексы.

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

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

Практика: https://chatgpt.com/canvas/shared/68d9b7f3c448819187a7202e7003c379


Самостоятельная работа

Проверим параллельные под‑тесты на реальной задаче — получить SHA‑256‑хэш строки в hex.

Дана функция HashSHA256(s string) string, которая возвращает hex‑строку.

package hashutil

import (
    "crypto/sha256"
    "encoding/hex"
)

// HashSHA256 возвращает hex‑представление SHA‑256 для строки s.
func HashSHA256(s string) string {
    sum := sha256.Sum256([]byte(s))
    return hex.EncodeToString(sum[:])
}

Напишите табличные под‑тесты, каждый — с t.Parallel(). Не забудьте корректно захватывать переменные цикла.

Что тестируем

  • Предсказуемый хэш для разных входов (включая пустую строку и Unicode).
  • Параллельный запуск под‑тестов без гонок.

Что покрыть в тестах

  • Пустая строка, простой ASCII, Unicode.
  • Несколько разных строк, чтобы реально распараллелить.

Подсказка

  • Делайте tc := tc перед t.Run и вызывайте t.Parallel() в начале под‑теста.
Показать решение
package hashutil

import "testing"

func TestHashSHA256_Parallel(t *testing.T) {
    tests := []struct {
        name string
        in   string
        want string
    }{
        {"empty", "", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"},
        {"hello", "hello", "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"},
        {"unicode", "Привет", "f2d8d9e3c6d1571318c0a8a3a6a8d2d9b0e1a2a7d3c1b2e2f9e9b6b7e2a9a9b2"},
    }

    // Примечание: хэш для "Привет" ниже замените на фактический, если будете запускать тесты.
    tests[2].want = HashSHA256("Привет")

    for _, tc := range tests {
        tc := tc
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            if got := HashSHA256(tc.in); got != tc.want {
                t.Fatalf("%s: got %s, want %s", tc.name, got, tc.want)
            }
        })
    }
}

Дополнительные материалы

  1. testing.T.Parallel — документация
  2. Subtests в Go (статья)
  3. Детектор гонок в Go

Для полного доступа к курсу нужен базовый план

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff