Avatar ·

Правильное именование пакетов для тестирования в Go (unit-testing)

Привет, коллеги! Я новичок в Go и пытаюсь разобраться с тем, как правильно называть пакеты для тестов. У меня есть проект с такой структурой: ``` myapp/ ├── internal/ │ ├── calculator/ │ │ ├── calc.go │ │ └── calc_test.go │ └── validator/ │ ├── valid.go │ └── valid_test.go ├── cmd/ │ └── main.go └── go.mod ``` Внутри `calc.go` у меня объявлен пакет `package calculator`. В `calc_test.go` я тоже написал `package calculator`, и тесты работают. Но я видел, что некоторые разработчики используют `package calculator_test` для тестовых файлов. В чём разница? Когда нужно использовать `_test` в имени пакета, а когда — нет? Меня смущает, что при использовании `package calculator_test` я не могу обращаться к неэкспортированным функциям и переменным из тестируемого пакета. Например, у меня есть неэкспортированная функция `addInternal()` в `calc.go`, и я хочу её протестировать. Если я напишу `package calculator_test`, то Go выдаст ошибку компиляции. Но если я напишу `package calculator`, то всё работает. Подскажите, пожалуйста, какой подход считается правильным и в каких ситуациях? Есть ли какие-то best practices для именования тестовых пакетов в Go? Заранее спасибо за помощь! <<>>

Основные подходы к именованию тестовых пакетов в Go

В Go существует два основных подхода к именованию пакетов для тестов: внутреннее тестирование (package foo) и внешнее тестирование (package foo_test). Оба варианта легитимны, но у них разные цели и ограничения.

1. Внутреннее тестирование (package foo)

Когда вы используете package calculator в файле calc_test.go, вы пишете тесты внутри того же пакета. Это даёт доступ ко всем объявленным в пакете сущностям — как экспортированным, так и неэкспортированным.

// internal/calculator/calc.go
package calculator

func Add(a, b int) int {
    return a + b
}

// Неэкспортированная функция
func addInternal(a, b int) int {
    return a + b + 1 // с ошибкой для демонстрации
}

// internal/calculator/calc_test.go
package calculator

import "testing"

func TestAddInternal(t *testing.T) {
    result := addInternal(2, 3) // доступ к неэкспортированной функции
    if result != 5 {
        t.Errorf("addInternal(2,3) = %d; want 5", result)
    }
}

Плюсы:

  • Доступ к неэкспортированным функциям и переменным — можно тестировать внутреннюю логику.
  • Меньше дублирования кода (не нужно создавать дополнительные обёртки).
  • Подходит для модульного тестирования "белого ящика".

Минусы:

  • Тесты становятся хрупкими — при изменении внутренней реализации тесты могут сломаться.
  • Нарушается инкапсуляция: тесты "знают" о внутренностях пакета.

2. Внешнее тестирование (package foo_test)

Использование package calculator_test создаёт отдельный пакет для тестов. В этом случае вы можете обращаться только к экспортированным идентификаторам (с большой буквы).

// internal/calculator/calc_test.go
package calculator_test

import (
    "testing"
    "myapp/internal/calculator"
)

func TestAdd(t *testing.T) {
    result := calculator.Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2,3) = %d; want 5", result)
    }
}

// Ошибка! addInternal не экспортирована:
// func TestAddInternal(t *testing.T) {
//     result := calculator.addInternal(2, 3)
//     ...
// }

Плюсы:

  • Тесты проверяют только публичный API — это соответствует принципу "тестируйте поведение, а не реализацию".
  • Тесты менее хрупкие: вы можете переписать внутренности пакета, и тесты не сломаются, если публичный интерфейс не изменился.
  • Имитирует использование пакета сторонним кодом — выявляет проблемы с API.

Минусы:

  • Нельзя напрямую тестировать неэкспортированные функции.
  • Требуется импортировать тестируемый пакет (как это делают пользователи).

Когда что использовать?

  • package foo — для модульных тестов, когда нужно протестировать внутреннюю логику, сложные алгоритмы или функции, которые не должны быть публичными. Особенно полезно для TDD (Test-Driven Development).
  • package foo_test — для интеграционных или acceptance-тестов, когда вы проверяете, что публичный API работает корректно. Рекомендуется для библиотек, которые будут использоваться другими разработчиками.
  • Комбинированный подход — можно использовать оба варианта в одном проекте. Например, в calc_internal_test.go (package calculator) тестировать внутренние функции, а в calc_test.go (package calculator_test) — публичный API.

Best practices

  1. Используйте package foo_test как основной подход — это стандарт Go сообщества и рекомендуется в официальной документации.
  2. Если вам нужно протестировать неэкспортированную функцию, подумайте: а нужно ли её тестировать? Возможно, её поведение покрывается тестами публичных функций.
  3. Для сложных пакетов используйте отдельные файлы с суффиксом _internal_test.go для внутренних тестов — это явно показывает намерение.
  4. Не бойтесь экспортировать функции, если они нужны для тестирования — это лучше, чем нарушать инкапсуляцию через внутренние тесты.

В итоге, для вашего примера с addInternal правильным решением будет либо сделать эту функцию экспортированной (если она полезна вне пакета), либо оставить внутренний тест (package calculator) для её проверки, а для публичного API использовать внешний тест (package calculator_test).

<<>>

Альтернативный подход: тестирование через экспорт с помощью файлов-обёрток

Если вы хотите сохранить внешнее тестирование (package foo_test), но при этом протестировать неэкспортированные функции, можно использовать трюк с отдельным файлом, который экспортирует внутренние функции только для тестов.

// internal/calculator/calc_exports_test.go
// Этот файл компилируется только при тестировании
package calculator

// ExportedAddInternal — обёртка для тестов
var ExportedAddInternal = addInternal

Теперь в вашем внешнем тесте (package calculator_test) вы можете обратиться к этой функции:

// internal/calculator/calc_test.go
package calculator_test

import (
    "testing"
    "myapp/internal/calculator"
)

func TestAddInternalViaExport(t *testing.T) {
    result := calculator.ExportedAddInternal(2, 3)
    if result != 5 {
        t.Errorf("addInternal(2,3) = %d; want 5", result)
    }
}

Плюсы этого подхода:

  • Вы сохраняете внешнее тестирование (package foo_test) со всеми его преимуществами.
  • Неэкспортированные функции остаются скрытыми от пользователей пакета (файл _test.go не компилируется в обычной сборке).
  • Явно указываете, какие внутренние функции тестируются.

Минусы:

  • Дополнительный файл для каждого набора внутренних функций.
  • Может ввести в заблуждение, если кто-то увидит "экспортированную" версию функции.
  • Не является стандартным подходом — используйте его только при крайней необходимости.

Этот метод полезен, когда вы пишете библиотеку и хотите, чтобы тесты были максимально похожи на код пользователей, но при этом нужно проверить внутренние алгоритмы. Однако я рекомендую сначала попробовать подход с внутренними тестами (package foo) — он проще и понятнее для большинства случаев.