Хотя язык программирования Go идёт в комплекте со встроенным тестовым фреймворком, мне сложно себе представить написание всего того количества тестов, что я написал, без testify. В этой заметке я расскажу про несколько маленьких неочевидных трюков, которым я научился в процессе.
В testify есть два основных пакета с проверками — assert и require. Набор проверок в них идентичен, но фейл require-проверки означает прерывание выполнения теста, а assert-проверки — нет.
Когда мы пишем тест, мы хотим, чтобы неудачный запуск выдал нам как можно больше информации о текущем (неправильном) поведении программы. Но если у нас есть череда проверок с require, неудачный запуск сообщит нам только о первом несоответствии.
Поэтому имеет смысл пользоваться require-проверками только если дальнейшее выполнение теста в случае невыполнения условия лишено смысла. Например, когда мы проверяем отсутствие ошибки, или валидируем длину списка, в который полезем дальше по коду теста.
Также стоит быть осторожнее при использовании горутин в тестах. require-проверки производятся через
Очевидно, для реализации практически любого мыслимого теста (кроме паникующего) достаточно одной функции
Аналогично, тест по умолчанию считается упавшим в случае паники, но использование
Этот совет может звучать совсем уж очевидным, но плохо структурированные тесты — проблема, встречающаяся повсеместно.
Suite собирает тесты, объединённые общими компонентами и тестовыми данными.
Методы сюиты проверяют разные, независимые друг от друга сценарии использования этих компонент и сущностей. Они могут запускаться в произвольном порядке.
Секции t.Run() разделяют сценарии на последовательные логические части.
При этом возможностью двухуровнево структурировать тесты внутри сюиты легко злоупотребить — этого тоже следует избегать. Однажды я наткнулся на сюиту в 2 000 строк кода — и оказалось, что это маленький тест, который я написал несколько лет назад и назвал слишком общими словами, спровоцировав коллег одного за другим добавлять туда новые тесты для совершенно несвязанных фичей. Зато каждый тест был в отдельном методе.
Иногда в тестах бывает необходимо вызвать какой-то метод у объекта, который в обычном продакшне не нужен (или даже опасен). Например, если у нас есть компонент с кэширующим слоем и мы хотим после изменения каких-то тестовых данных этот кэш сбросить.
В таком случае удобно положить реализацию этого метода и тестовый интерфейс в отдельный файл с именем
Однако, в таком случае наш тестовый тип и метод будут доступны только в тестах в этой же папке: в пакетах
Гораздо универсальнее и удобнее положить их в обычный
При этом нужно будет начать прокидывать
Также в файлы с
А какие best practices написания тестов на Go используете вы? Поделитесь в комментариях!
Различайте assert и require
В testify есть два основных пакета с проверками — assert и require. Набор проверок в них идентичен, но фейл require-проверки означает прерывание выполнения теста, а assert-проверки — нет.
Когда мы пишем тест, мы хотим, чтобы неудачный запуск выдал нам как можно больше информации о текущем (неправильном) поведении программы. Но если у нас есть череда проверок с require, неудачный запуск сообщит нам только о первом несоответствии.
func TestBehavior(t *testing.T) {
...
price, err := priceManager.GetPrice(ctx, productID)
require.NoError(t, err)
require.Equal(t, 300, price.Amount)
require.Equal(t, money.USD, price.Currency)
}
/*
=== RUN TestBehavior
temp_test.go:21:
Error Trace: behavior_test.go:21
Error: Not equal:
expected: 300
actual : 42 // but is it at least bucks?
Test: TestBehavior
--- FAIL: TestBehavior (0.00s)
Expected :300
Actual :42
*/
Поэтому имеет смысл пользоваться require-проверками только если дальнейшее выполнение теста в случае невыполнения условия лишено смысла. Например, когда мы проверяем отсутствие ошибки, или валидируем длину списка, в который полезем дальше по коду теста.
func TestBehavior(t *testing.T) {
...
price, err := priceManager.GetPrice(ctx, productID)
require.NoError(t, err)
assert.Equal(t, 300, price.Amount)
assert.Equal(t, money.USD, price.Currency)
}
/*
=== RUN TestBehavior
behavior_test.go:22:
Error Trace: behavior_test.go:22
Error: Not equal:
expected: 300
actual : 42
Test: TestBehavior
behavior_test.go:23:
Error Trace: behavior_test.go:23
Error: Not equal:
expected: USD
actual : RUB
Test: TestBehavior
--- FAIL: TestBehavior (0.00s)
*/
Также стоит быть осторожнее при использовании горутин в тестах. require-проверки производятся через
runtime.goexit()
, так что они сработают ожидаемым образом только в основной горутине.Используйте подходящие проверки вместо универсальных
Очевидно, для реализации практически любого мыслимого теста (кроме паникующего) достаточно одной функции
assert.True()
. Тем не менее, в testify есть уйма проверок для разных случаев жизни. Использование более подходящей проверки делает сообщения об ошибках более читаемыми и экономит код.❌ require.Nil(t, err)
✅ require.NoError(t, err)
❌ assert.Equal(t, 300.0, float64(price.Amount))
✅ assert.EqualValues(t, 300.0, price.Amount)
❌ assert.Equal(t, 0, len(result.Errors))
✅ assert.Empty(t, result.Errors)
❌ require.Equal(t, len(expected), len(result)
sort.Slice(expected, ...)
sort.Slice(result, ...)
for i := range result {
assert.Equal(t, expected[i], result[i])
}
✅ assert.ElementsMatch(t, expected, result)
Аналогично, тест по умолчанию считается упавшим в случае паники, но использование
assert.NotPanics()
помогает будущему читателю теста понять, что вы проверяете именно её отсутствие.Структурируйте тесты с помощью Suite и t.Run()
Этот совет может звучать совсем уж очевидным, но плохо структурированные тесты — проблема, встречающаяся повсеместно.
Suite собирает тесты, объединённые общими компонентами и тестовыми данными.
Методы сюиты проверяют разные, независимые друг от друга сценарии использования этих компонент и сущностей. Они могут запускаться в произвольном порядке.
Секции t.Run() разделяют сценарии на последовательные логические части.
При этом возможностью двухуровнево структурировать тесты внутри сюиты легко злоупотребить — этого тоже следует избегать. Однажды я наткнулся на сюиту в 2 000 строк кода — и оказалось, что это маленький тест, который я написал несколько лет назад и назвал слишком общими словами, спровоцировав коллег одного за другим добавлять туда новые тесты для совершенно несвязанных фичей. Зато каждый тест был в отдельном методе.
Прячьте вспомогательные методы за //go:build
Иногда в тестах бывает необходимо вызвать какой-то метод у объекта, который в обычном продакшне не нужен (или даже опасен). Например, если у нас есть компонент с кэширующим слоем и мы хотим после изменения каких-то тестовых данных этот кэш сбросить.
В таком случае удобно положить реализацию этого метода и тестовый интерфейс в отдельный файл с именем
*_test.go
. Так мы выставляем наружу нужные для тестирования методы, но не засоряем публичный интерфейс пакета.package mypackage
type TestManager interface {
Manager
ClearCache(ctx context.Context) error
}
// Поскольку мы в том же пакете, мы можем обращаться
// к приватным структурам и даже добавлять новые методы.
func (m *manager) ClearCache(ctx context.Context) error {
return m.myStuffCache.Clear(ctx)
}
Однако, в таком случае наш тестовый тип и метод будут доступны только в тестах в этой же папке: в пакетах
mypackage
и mypackage_test
. Это довольно серьёзное ограничение. (Также для таких ситуаций предусмотрен довольно хитрый механизм сборки, способный значительно замедлить покоммитные тесты.)Гораздо универсальнее и удобнее положить их в обычный
.go
-файл и выключить его компиляцию клаузой //go:build testmode
.//go:build testmode
package mypackage
type TestManager interface {
Manager
ClearCache(ctx context.Context) error
}
func (m *manager) ClearCache(ctx context.Context) error {
return m.myStuffCache.Clear(ctx)
}
При этом нужно будет начать прокидывать
-tags testmode
при прогоне тестов и сделать отдельную джобу, проверяющую сборку бинарей без этого тега (если у вас в принципе есть CI/CD).Также в файлы с
//go:build testmode
можно складывать тестовые утилиты, свои кастомные сюиты со вспомогательными методами и так далее.А какие best practices написания тестов на Go используете вы? Поделитесь в комментариях!