Table-driven тесты в Go
Попробовал сравнить две структуры оператором == - код не собрался: invalid operation: a == b (struct containing []string cannot be compared). Так и выясняется, что в Go есть свои правила на этот счёт. Спрашивают, как выглядит идиоматичный тест, почему предпочитают таблицы и как сравнивать сложные значения.
Стержень: таблица случаев плюс t.Run на каждый - новый случай добавляется строкой, а в отчёте видно, какой именно упал.
// Формулировки: «как пишете тесты?», «зачем t.Run?», «как сравнивать структуры?»
Таблица и подтесты
Случаи описывают срезом структур с полями «имя, вход, ожидание», а тело теста проходит по нему циклом. Логика проверки написана один раз, новый случай - новая строка данных.
Каждый случай запускают через t.Run(name, ...). Имя попадает в отчёт, поэтому видно, какой именно случай упал; падение одного не прерывает остальные; и запустить его отдельно можно флагом -run с косой чертой. Я проверял: -run 'TestSubtests/second' выполнил ровно один подтест из трёх.
// С Go 1.22 переменная цикла своя на каждой итерации. Проверил на трёх горутинах без строчки tc := tc: собрались все три разных значения, сумма 6. На старых версиях там было бы три раза последнее.
- table-driven
- случаи данными, логика проверки одна
- t.Run
- подтест с именем: виден в отчёте и в -run
Сравнение результатов и его ловушки
Оператор == со структурой, внутри которой срез, мапа или функция, не компилируется вовсе. Сообщение конкретное: struct containing []string cannot be compared. Поэтому берут reflect.DeepEqual, а для слайсов и мап с версии 1.21 есть точные slices.Equal и maps.Equal.
У DeepEqual своя ловушка, и я на неё специально посмотрел. Пустой срез []string{} и nil он считает разными - выдал false, хотя длины у обоих ноль. Типы он тоже различает: int(1) и int64(1) для него не равны. В тестах это выстреливает, когда функция возвращает nil, а ожидание записано пустым литералом.
// Сравнивать через форматирование в строку не стоит: порядок обхода мапы случаен, и тест начнёт мигать через раз.
- reflect.DeepEqual
- рекурсивное сравнение, различает nil и пустой срез
- slices.Equal
- точное сравнение слайсов, с версии 1.21
Ошибки сравнивают не по тексту
Опыт: обернул ошибку через fmt.Errorf("загрузка профиля: %w", errBase). Текст стал «загрузка профиля: база недоступна», и сравнение с исходным текстом дало false. А errors.Is при этом вернул true - он идёт по цепочке оборачиваний.
Отсюда правило: проверяй ошибку через errors.Is для сентинелов и errors.As для типов. Текст меняется при первой же правке формулировки и не переживает добавление контекста, а контекст к ошибкам добавляют постоянно.
// Тот же принцип шире: тест должен проверять наблюдаемое поведение, а не текст сообщения и не внутреннее устройство функции.
- errors.Is
- проверка по цепочке оборачиваний, а не по тексту
- %w
- обёртка, сохраняющая исходную ошибку внутри
Как отвечать: «Как вы пишете тесты в Go и зачем таблица?»
По умолчанию табличными. Случаи описываю срезом структур с полями «имя, вход, ожидаемый результат», а тело теста идёт по нему циклом. Смысл в том, что логика проверки написана один раз, а новый случай стоит одной строки данных - сразу видно, что покрыто и чего не хватает. Каждый случай запускаю через t.Run с именем: имя попадает в отчёт, падение одного случая не прерывает остальные, и запустить его отдельно можно флагом -run с косой чертой, я так и делаю при разборе. С Go 1.22 переменная цикла своя на каждой итерации, поэтому старый приём с копированием tc := tc перед параллельными подтестами больше не нужен - проверял, три горутины собирают три разных значения. Результаты сравниваю через reflect.DeepEqual, для слайсов и мап с версии 1.21 есть точные slices.Equal и maps.Equal, а оператор == со структурой, содержащей срез, просто не соберётся. И держу в голове ловушку DeepEqual: пустой срез и nil он считает разными, хотя длина у обоих ноль. Ошибки проверяю через errors.Is или errors.As, но не по тексту: обернул через %w - и текст уже другой, а errors.Is продолжает работать.
Сильный ответ: ценность таблицы объяснена через «логика один раз, случай - строка», названы конкретные выгоды t.Run, упомянуто изменение семантики в 1.22 и правильно выбраны инструменты сравнения. Ловушка DeepEqual с пустым срезом сразу показывает практика.
На чём валят
- −Пишут отдельную функцию на каждый случай, и логика проверки дублируется пять раз.
- −Забывают t.Run: в отчёте не видно, какой именно случай упал.
- −Сравнивают структуры через == и не понимают ошибку компилятора про несравнимый тип.
- −Ловят мигающий тест на DeepEqual: пустой срез и nil для него разные.
- −Проверяют ошибку по тексту. Одна обёртка через %w - и сравнение сломалось.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 6, остальные разбираются в тренажёре.
- Что делает t.Parallel() внутри подтеста?A)Запускает подтест сразу на всех доступных ядрах, распараллеливая внутри его телоB)Ускоряет подтест, отключая для него сбор покрытия и подробных трейсов выполненияC)Форсит запуск подтеста в отдельном процессе ОС ради его полной изоляции от прочихD)Помечает подтест параллельным: такие идут одновременно после серийных
показать ответ и разбор
+D)Помечает подтест параллельным: такие идут одновременно после серийных// разбор: t.Parallel() помечает подтест как параллельный: вызвав его, подтест приостанавливается, родитель доигрывает серийные части, а затем все помеченные параллельные подтесты запускаются одновременно (до GOMAXPROCS). Ускоряет набор независимых тестов, но требует изоляции — общее состояние между ними надо защищать или устранять. Тело одного подтеста при этом не распараллеливается.
- Почему table-driven тесты предпочитают набору отдельных тест-функций?A)Они выполняются заметно быстрее за счёт общей компиляции всех кейсов в один проходB)Новый случай добавляется одной строкой, а логика проверки не дублируетсяC)Только они умеют запускаться параллельно, обычным тестам t.Parallel недоступенD)Компилятор прямо требует table-driven для функций с несколькими проверками подряд
показать ответ и разбор
+B)Новый случай добавляется одной строкой, а логика проверки не дублируется// разбор: Table-driven отделяет ДАННЫЕ (кейсы) от ЛОГИКИ проверки: добавить сценарий — дописать строку в срез, а не копировать целую тест-функцию. Меньше дублирования, легче читать, а t.Run даёт именованные подтесты. Скорость тут ни при чём, параллелизм доступен и обычным тестам, и никакого требования компилятора нет — это стиль, а не правило языка.
- Зачем в table-driven тесте каждому случаю дают имя и запускают через t.Run(name, ...)?A)Иначе тестовые случаи выполнятся в непредсказуемом порядкеB)Иначе компилятор не сможет собрать таблицу случаев в один тестC)Чтобы в отчёте было видно, какой именно случай упал, и его можно было запустить отдельноD)Чтобы каждый случай получил собственный экземпляр структуры testing.T
показать ответ и разбор
+C)Чтобы в отчёте было видно, какой именно случай упал, и его можно было запустить отдельно// разбор: Без подтестов падение выглядит как «TestParse провалился» — какой из двадцати случаев, приходится выяснять по сообщению. С t.Run имя случая попадает в отчёт, а запустить только его можно через -run TestParse/имя_случая. Плюс каждый случай продолжает выполняться, даже если предыдущий вызвал t.Fatal, — прерывается только он сам.
- Как в Go 1.22 и новее сравнивать ожидаемые и полученные значения сложных структур в тестах?A)Оператором == — он работает со структурами напрямуюB)Через сериализацию обеих сторон в JSON и сравнение байтовC)Через fmt.Sprintf("%v") и сравнение получившихся строкD)Через reflect.DeepEqual или сравнение по полям, а для слайсов — slices.Equal
показать ответ и разбор
+D)Через reflect.DeepEqual или сравнение по полям, а для слайсов — slices.Equal// разбор: Оператор == не работает со структурами, содержащими слайсы, map или функции, — код просто не соберётся. reflect.DeepEqual сравнивает рекурсивно и годится в большинстве случаев; для слайсов и map с версии 1.21 есть точные slices.Equal и maps.Equal. Сравнение через форматирование в строку — плохая идея: порядок обхода map случаен, и тест начнёт мигать.
- Как проверить, что функция вернула именно ожидаемую ошибку?A)Сравнить текст ошибки со строкой через ==B)Проверить, что ошибка не nil, — этого достаточноC)Сравнить значение через errors.Is или достать тип через errors.AsD)Сравнить типы через reflect.TypeOf обеих ошибок
показать ответ и разбор
+C)Сравнить значение через errors.Is или достать тип через errors.As// разбор: Сравнение текстов ломается при первой же правке формулировки и не видит обёрток: fmt.Errorf с %w меняет строку. errors.Is находит нужное значение сквозь всю цепочку обёрток, errors.As достаёт ошибку конкретного типа, если нужны её поля. Проверка «просто не nil» слишком слабая — тест пройдёт и на совсем другой ошибке.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.