Четыре простых лайфхака при написании тестов на Go + testify

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Хотя язык программирования 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 используете вы? Поделитесь в комментариях!
Источник: https://habr.com/ru/company/joom/blog/666440/


Интересные статьи

Интересные статьи

Test-driven development — по-прежнему спорная тема. Часто встречается обоснованное, в общем-то, мнение, что такая разработка нужна только большим компаниям, потому что только у них достаточно ресурс...
Переход к любой новой технологии требует тщательного планирования и скоординированных усилий. В этой статье мы разобрали четыре способа перехода с унаследованной платформы, такой как Cloudera CDH или ...
19 апреля автор курса «Алгоритмы для разработчиков» в Яндекс.Практикуме и разработчик в компании Joom Александра Воронцова провела открытый вебинар «Оптимизация на простых типах данных». ...
Данная статья мотивирована необходимостью построить более общую картину, что такое жизнь (и какой она может быть) по отношению к остальным явлениям во Вселенной.(Является...
Перед вами перевод статьи из блога Carlos Caballero на сайте Medium.com. Автор расскажет нам о функциях, которые появились в версии ES10 2019 года. ES10 — это версия ECMAScript, актуальная...