2. Структура тестов в Go
В предыдущей главе 1. testing пакет. Основы мы убедились, что тесты в Go — это не интерпретируемые сценарии, а полноценные скомпилированные машинные бинарники. Однако прежде чем тулчейн сможет скомпилировать исполняемый файл для прогона тестов, он должен проанализировать структуру каталогов и связать файлы пакета в единую стройную систему.
Организация файлов и пакетов в Go — это не просто вопрос вкуса или корпоративных договоренностей. Это строгие правила, зашитые непосредственно в компилятор и сборочную систему. Они напрямую определяют инкапсуляцию, границы видимости идентификаторов, предотвращение циклических зависимостей и компоновку боевого бинарника.
В этой главе мы подробно разберем правила физического размещения тестовых файлов, дилемму White-box и Black-box тестирования на уровне пакетов, скрытые механизмы работы точки входа TestMain и работу со специальным каталогом testdata.
Топология файлов: Рядом с кодом
В объектно-ориентированных экосистемах вроде Java или C# принято разносить продуктовый код и тесты по зеркальным иерархиям папок: каталог src/ резервируется под боевой код, а параллельный каталог tests/ — под проверочные классы. В Go этот подход считается грубым антипаттерном.
В Go тестовые файлы всегда размещаются бок о бок с тестируемым кодом в том же самом каталоге файловой системы. Единственное требование — имя тестового файла обязано оканчиваться строгим суффиксом _test.go:
user/
├── user.go # Бизнес-логика сущности
├── user_test.go # Модульные тесты для user.go
├── auth.go # Логика аутентификации
└── auth_test.go # Тесты подсистемы аутентификации
Дилемма пакетов: White-box vs Black-box
Создавая тестовый файл user_test.go внутри пакета user, вы стоите перед фундаментальным архитектурным выбором: объявить в заголовке package user или package user_test. Это решение определяет степень видимости внутренней структуры модуля:
flowchart TD
subgraph Dir["Директория /user на диске"]
A["user.go<br/>package user<br/>(Продуктовый код)"]
B["user_internal_test.go<br/>package user<br/>(White-box тесты)"]
C["user_external_test.go<br/>package user_test<br/>(Black-box тесты)"]
end
A <-->|"Полный доступ к приватным полям<br/>Сборка в одно пространство имен"| B
C -->|"Импорт через публичный контракт<br/>import app/user"| A
C -.->|"НЕТ ДОСТУПА к неэкспортируемым полям"| B
classDef prod fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc;
classDef white fill:#1e293b,stroke:#a855f7,stroke-width:1px,color:#f8fafc;
classDef black fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#f8fafc;
classDef box fill:#0f172a,stroke:#334155,stroke-width:1px,color:#cbd5e1;
class A prod;
class B white;
class C black;
class Dir box;
1. Внутренние тесты (White-box: package user)
Если в начале тестового файла указан тот же самый идентификатор пакета, что и в боевом коде (package user), тест становится внутренним (White-box):
- Тесту доступны абсолютно все внутренности пакета: приватные неэкспортируемые структуры, неэкспортируемые поля, внутренние функции и константы.
- Тестовый код компилируется в то же пространство имен, что и модуль.
Когда это оправдано:
- Проверка сложных математических алгоритмов, внутренних состояний конечных автоматов (FSM) или низкоуровневых парсеров, которые нет смысла выставлять в публичный API.
- Точечное стресс-тестирование приватных DTO-мапперов.
2. Внешние тесты (Black-box: package user_test)
Если к имени пакета добавляется суффикс _test (package user_test), компилятор Go воспринимает этот файл как совершенно независимый внешний пакет, несмотря на то, что физически он лежит в той же директории user.
В таком файле вы обязаны явно импортировать тестируемый пакет (import "myapp/user"), а тесту доступны строго экспортируемые (публичные) идентификаторы, начинающиеся с заглавной буквы.
Преимущества внешнего (Black-box) тестирования:
- Неуязвимость к рефакторингу: Вы проверяете поведение и стабильность публичного контракта. Внутренняя реализация может быть переписана с нуля, но если внешнее поведение сохранено, тесты останутся зелеными.
- Проверка эргономики API: Вы тестируете библиотеку в шкуре реального стороннего потребителя, мгновенно ощущая любые шероховатости, неудобные конструкторы или избыточные параметры.
- Разрыв циклических импортов: Классическая проблема архитектуры: пакет
Aдля проведения тестов нуждается в тестовых утилитах пакетаB, в то время как продуктовый кодBуже зависит отA. Компилятор прерывает сборку с фатальной ошибкойimport cycle not allowed. Перевод тестов в отдельный виртуальный пакетA_testразрывает замкнутый круг.
Глобальный контекст пакета: TestMain
В интеграционном тестировании часто требуется тяжеловесная процедура инициализации и гарантированной финализации на уровне всего пакета (Setup/Teardown): поднять контейнер базы данных через Testcontainers, применить миграции, а по завершении сьюта корректно закрыть соединения и очистить ресурсы.
Для этих целей стандартная библиотека предоставляет специальную функцию TestMain(m *testing.M).
Если в пакете объявлена функция с сигнатурой TestMain, раннер передает бразды правления ей, а не запускает тесты автоматически. Вызов m.Run() осуществляет фактический запуск всех тестов в пакете и возвращает целочисленный статус:
package integration_test
import (
"fmt"
"os"
"testing"
)
func TestMain(m *testing.M) {
// 1. Глобальный Setup пакета
fmt.Println("Инициализация инфраструктуры для тестов пакета...")
// 2. Выполнение всех тестовых функций пакета
code := m.Run()
// 3. Глобальный Teardown
fmt.Println("Очистка тестового окружения...")
// 4. Завершение процесса с кодом возврата раннера
os.Exit(code)
}
Mechanical Sympathy и Ловушка os.Exit
Функция os.Exit(code) завершает выполнение программы через мгновенный системный вызов ядра операционной системы (в Linux это системный вызов exit_group). Ядро немедленно освобождает ресурсы процесса, уничтожает потоки и сбрасывает дескрипторы.
Рантайм Go при этом не раскручивает стек и не выполняет отложенные функции!
Директория testdata
Когда модульным или интеграционным тестам требуются статические входные артефакты — эталонные JSON-документы, тестовые сертификаты TLS, дампы баз данных или файлы с ожидаемым выводом (Golden Files), их ни в коем случае не следует внедрять в виде громоздких строковых литералов в исходный код.
В Go для этих целей предусмотрено специальное зарезервированное имя каталога: testdata/ (или testdata).
parser/
├── parser.go
├── parser_test.go
└── testdata/
├── input_valid.json
└── input_invalid.json
Механические особенности каталога testdata:
- Игнорирование компилятором: Утилиты
go buildиgo installна уровне парсера тулчейна безусловно игнорируют любые директории с именемtestdata. Ваши многомегабайтные фикстуры гарантированно не попадут в production-бинарник. -
Детерминированный рабочий каталог (CWD): При запуске тестов тулчейн
go testгарантирует, что текущая рабочая директория процесса (Current Working Directory) жестко устанавливается в каталог тестируемого пакета. Благодаря этому в файлеparser_test.goвы можете безбоязненно использовать относительный путь:Go data, err := os.ReadFile("testdata/input_valid.json")Этот путь будет успешно разрешен ядром ОС всегда, независимо от того, запускаете ли вы тест из корня монорепозитория или из вложенной поддиректории.
Итог
Архитектура файловой структуры тестов в Go подчинена ясности и строгому контролю на этапе сборки:
- Расположение: Файлы
*_test.goвсегда живут рядом с реализацией, не влияя на боевойgo build. - Инкапсуляция: Предпочитайте
package mypkg_test(Black-box) для тестирования стабильных контрактов. Прибегайте кpackage mypkg(White-box) или трюку сexport_test.go, когда требуется протестировать сложные скрытые механизмы. - Пакетный Setup: Используйте
TestMain(m *testing.M), но помните про системный вызовos.Exit(code)и оборачивайте инициализацию в функцию сdefer. - Фикстуры: Храните тестовые статические данные в специальной папке
testdata/.
Мы детально исследовали организацию файлов на диске. Но что именно происходит в операционной системе и рантайме Go, когда мы нажимаем Enter после команды go test? Какова анатомия компиляции, кэширования и параллелизма? Этому посвящена следующая глава: 3. go test под капотом.