Хотя язык программирования Go идёт в комплекте со встроенным тестовым фреймворком, мне сложно себе представить написание всего того количества тестов, что я написал, без testify. В этой заметке я расскажу про несколько маленьких неочевидных трюков, которым я научился в процессе.




Различайте 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 используете вы? Поделитесь в комментариях!

Комментарии (0)