Хватит требовать один assert на тест: несколько assert'ов - это нормально

Assertion Roulette* не означает, что несколько assert'ов - это плохо.
Когда я консультирую команды или индивидуальных разработчиков о том, как заниматься test-driven development (TDD) или unit-тестированием, я часто слышу такое мнение: Несколько assert'ов - это плохо. В тесте должен быть один assert.
Эта идея редко оказывается полезной.
Давайте попробуем разобраться на реалистичном примере, и затем попытаться определить, откуда это мнение могло возникнуть.
Outside-in TDD
Представим REST API, который позволяет создавать и отменять бронь в ресторане. Сперва HTTP-запрос POST
создает бронь:
POST /restaurants/1/reservations?sig=epi301tdlc57d0HwLCz[...] HTTP/1.1
Content-Type: application/json
{
"at": "2023-09-22 18:47",
"name": "Teri Bell",
"email": "terrible@example.org",
"quantity": 1
}
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /restaurants/1/reservations/971167d4c79441b78fe70cc702[...]
{
"id": "971167d4c79441b78fe70cc702d3e1f6",
"at": "2023-09-22T18:47:00.0000000",
"email": "terrible@example.org",
"name": "Teri Bell",
"quantity": 1
}
Обратите внимание, что, как принято в настоящем REST, ответ содержит ссылку на созданную бронь в заголовке Location
.
Если вы передумаете, вы можете отменить бронь запросом DELETE
:
DELETE /restaurants/1/reservations/971167d4c79441b78fe70cc702[...] HTTP/1.1
HTTP/1.1 200 OK
Представим, что это желаемое взаимодействие. Используя подход Outside-In TDD, вы пишете тест:
[Theory]
[InlineData(884, 18, 47, "c@example.net", "Nick Klimenko", 2)]
[InlineData(902, 18, 50, "emot@example.gov", "Emma Otting", 5)]
public async Task DeleteReservation(
int days, int hours, int minutes,
string email, string name, int quantity)
{
using var api = new LegacyApi();
var at = DateTime.Today.AddDays(days).At(hours, minutes)
.ToIso8601DateTimeString();
var dto = Create.ReservationDto(at, email, name, quantity);
var postResp = await api.PostReservation(dto);
Uri address = FindReservationAddress(postResp);
var deleteResp = await api.CreateClient().DeleteAsync(address);
Assert.True(
deleteResp.IsSuccessStatusCode,
$"Actual status code: {deleteResp.StatusCode}.");
}
Этот пример написан на С# и xUnit.net, потому что нам нужен какой-нибудь язык и фреймворк для демонстрации на реалистичном коде. Но идея этой статьи применима для всех языков и фреймворков. Код в этой статье написан на основе примеров в моей книге Code That Fits in Your Head.
Чтобы тест прошел, вы пишете такой код на стороне сервера:
[HttpDelete("restaurants/{restaurantId}/reservations/{id}")]
public void Delete(int restaurantId, string id)
{
}
Очевидно, что это no-op, но он проходит тест, который подтверждает, что возвращенный ответ HTTP содержит статус в успешном диапазоне 200
. Это часть протокола REST API, поэтому это важно. Нужно иметь этот assert
в тестах как регрессионный тест. Если API однажды начнет возвращать статус в диапазоне 400
или 500
, это будет breaking change - разрушительное изменение.
Пока что выглядит хорошо. Целая фича не строится на одном тесте.
Так как все тесты проходят, вы можете закоммитить правки в систему контроля версий и перейти к следующей итерации.
Усиливаем пост-проверки
Можно проверить, была ли бронь на самом деле удалена, сделав GET
-запрос:
GET /restaurants/1/reservations/971167d4c79441b78fe70cc702[...] HTTP/1.1
HTTP/1.1 404 Not Found
Однако, это не сработает для нашей текущей реализации Delete
, которая ничего не делает. Похоже, что нам нужен еще один тест.
Или нет?
Мы могли бы скопировать существующий тест и изменить assert, чтобы он выполнял GET
-запрос выше и проверял, что статус равен 404
:
[Theory]
[InlineData(884, 18, 47, "c@example.net", "Nick Klimenko", 2)]
[InlineData(902, 18, 50, "emot@example.gov", "Emma Otting", 5)]
public async Task DeleteReservationActuallyDeletes(
int days, int hours, int minutes,
string email, string name, int quantity)
{
using var api = new LegacyApi();
var at = DateTime.Today.AddDays(days).At(hours, minutes)
.ToIso8601DateTimeString();
var dto = Create.ReservationDto(at, email, name, quantity);
var postResp = await api.PostReservation(dto);
Uri address = FindReservationAddress(postResp);
var deleteResp = await api.CreateClient().DeleteAsync(address);
var getResp = await api.CreateClient().GetAsync(address);
Assert.Equal(HttpStatusCode.NotFound, getResp.StatusCode);
}
Этот тест в самом деле подтолкнет вас реализовать Delete
по-настоящему.
Но такая ли это хорошая идея? Легко ли будет поддерживать этот тест?
Тестовый код - это тоже код, и его надо поддерживать. Копирование и вставка проблематичны в тестовом коде по тем же причинам, почему они проблематичны в рабочем коде. Если вам потребуется в будущем что-то изменить, вам нужно будет найти все места, которые надо отредактировать. Легко пропустить одно из них и оставить баг в проекте. Эта истина применима и к тестовому коду.
Одно действие, больше assert'ов
Вместо копирования прошлого теста, как насчет усиления его пост-проверок?
Просто добавьте новые assert'ы после первого:
[Theory]
[InlineData(884, 18, 47, "c@example.net", "Nick Klimenko", 2)]
[InlineData(902, 18, 50, "emot@example.gov", "Emma Otting", 5)]
public async Task DeleteReservation(
int days, int hours, int minutes,
string email, string name, int quantity)
{
using var api = new LegacyApi();
var at = DateTime.Today.AddDays(days).At(hours, minutes)
.ToIso8601DateTimeString();
var dto = Create.ReservationDto(at, email, name, quantity);
var postResp = await api.PostReservation(dto);
Uri address = FindReservationAddress(postResp);
var deleteResp = await api.CreateClient().DeleteAsync(address);
Assert.True(
deleteResp.IsSuccessStatusCode,
$"Actual status code: {deleteResp.StatusCode}.");
var getResp = await api.CreateClient().GetAsync(address);
Assert.Equal(HttpStatusCode.NotFound, getResp.StatusCode);
}
Так вам нужно поддерживать только один тестовый метод вместо двух практически идентичных дубликатов.
Но, - скажут некоторые люди, которых я консультировал, - в этом тесте два assert'а!
Да. Ну и что? Это один test case, одна тестовая ситуация: отмена брони.
В то время как отмена брони - это одно действие, нам важны несколько его результатов:
- Статус после успешного запроса DELETE должен быть в диапазоне 200.
- Бронь должна исчезнуть.
Продолжая разработку нашей системы, мы можем добавить сюда новое поведение. Может быть, система должна еще отсылать email об отмене брони? Это тоже нужно проверить assert'ом. Но это все еще один и тот же test case: успешная отмена брони.
Нет ничего плохого в том, чтобы в тесте было несколько assert'ов. Пример выше иллюстрирует плюсы такого подхода. В одном test case может быть несколько результатов, все из которых надо проверить.
Происхождение мнения о едином assert'е
Откуда произошло это мнение о том, что assert должен быть один? Я не знаю, но я могу предположить.
Прекрасная книга "Шаблоны тестирования xUnit" описывает "запах кода" (code smell) под названием "Рулетка утверждений" (Assertion Roulette). Она описывает ситуации, в которых бывает трудно определить, какой именно assert (утверждение) привело к падению теста.
Как мне кажется, "правило" одного assert'а возникает из-за неправильной интерпретации описания "Рулетки утверждений". (Возможно, я и сам частично был причиной этой ошибочной интерпретации. Я такого не помню, но, честно говоря, я написал столько контента про unit-тестирование за десятилетия, что не хочу снимать с себя всю вину.)
"Шаблоны тестирования xUnit" перечисляет две причины "Рулетки утверждений":
- "Нетерпеливый тест" (Eager test): один тест проверяет слишком много функциональности.
- Недостающее сообщение в assert'е.
Вы пишете нетерпеливый тест, если пытаетесь тестировать несколько тестовых ситуаций (test case) одновременно. Возможно, вы пытаетесь симулировать "сессию", в ходе которой клиент выполняет несколько действий, чтобы достичь чего-либо. Как пишет Джирард Месарош об этом запахе, это уместно для ручных тестов, но не для автоматизированных. Не количество assert'ов приводит к проблеме, а то, что тест слишком много на себя берет.
Другая причина бывает, когда assert'ы достаточно похожи, так что вы не можете понять, который провалился, и у них нет сопроводительных сообщений.
В примере выше этой проблемы нет. Если свалится Assert.True
, вы получите сообщение:
Actual status code: NotFound.
Expected: True
Actual: False
Аналогично, если свалится Assert.Equal
, вывод тоже будет понятен:
Assert.Equal() Failure
Expected: NotFound
Actual: OK
Никакой многозначительности.
Один assert на тест
Теперь когда вы знаете, что необязательно писать один assert в каждом тесте, вы можете почувствовать желание набивать тесты assert'ами выше крыши.
Тем не менее, обычно в таких настойчивых убеждениях как "один тест - один assert" есть доля истины. Придерживайтесь здравого смысла.
Если вы задумаетесь, что такое есть автоматизированный тест - это по сути предикат. Это утверждение, что мы ожидает определенный результат. Мы затем берем и сравниваем действительный результат и ожидаемый. Идеальный assert выглядит так:
Assert.Equal(expected, actual);
У меня не всегда получается достичь этого идеала, но когда получается, я чувствую удовлетворение. Иногда expected
(ожидаемое) и actual
(действительное) - примитивные значения, как integer или string. Но они могут быть и сложными значениями, представляющими те кусочки состояния программы, в которых заинтересован тест. До тех пор, пока эти значения структурно равны, этот assert имеет смысл.
Но иногда я не могу сделать проверку настолько кратко. В таком случае, если мне нужно добавить еще один или пару assert'ов, я их добавлю.
Заключение
Существует мнение, что нужно писать не больше одного assert'а на каждый тест. Возможно, оно возникло из плохо написанного тестового кода, но с течением лет тонкий запах "Рулетка утверждений" исказился в более простое, но менее полезное "правило".
Это "правило" часто снижает качество тестового кода. Программисты, следующие этому "правилу", бездумно копируют тесты вместо того, чтобы добавить assert'ы к существующим.
Если добавление уместного assert'а в существующий тест - лучшее решение, не позволяйте этому неправильно понятому "правилу" вас остановить.
Материал подготовлен с ❤️ редакцией Кухни IT.