Таинственный гость.
Тест, чьи входные данные или ожидаемые результаты находятся во внешнем ресурсе — файле, сидовых данных БД или общей фикстуре, — поэтому понять такой тест и доверять ему, читая только его, невозможно.
##Signs and Symptoms
Тест невозможно понять, просто прочитав его: данные, которые им управляют (и обосновывают его проверки), находятся где-то за кадром — в файле CSV/JSON, сид-скрипте БД, общем модуле фикстур или аннотации фреймворка с данными-фикстурами. Тест ссылается на непрозрачный ресурс, а затем проверяет «магические» значения, смысл которых спрятан в этом ресурсе.
Характерные признаки:
- В теле вызывается что-то вроде
readFileSync('fixtures/subscribers.csv'),loadSeed('invoices.sql'),getResource(...)или глобальныйbeforeAll, который засевает общую базу данных. - Ожидаемые значения выглядят произвольными (
toHaveLength(3), конкретный id/email), и приходится открывать другой файл, чтобы узнать, почему. - Общая/«универсальная» фикстура переиспользуется во многих тестах, поэтому входные данные, важные для этого теста, погребены среди данных, которые ему безразличны.
- Тесты ломаются, когда коллега правит общую фикстуру ради не связанного с ними теста.
test('returns active subscribers', async () => {
// Таинственный гость: какие строки в этом файле? почему 3 — это правильно?
const subscribers = await loadSubscribersFromCsv('./fixtures/subscribers.csv');
const result = filterActive(subscribers);
expect(result).toHaveLength(3); // обоснование живёт вне теста
});
Исходный пример из каталога Месароша / Test Smells имеет ту же форму: loadAirportsAndFlightsFromFile("test-flights.csv"), за которым следует assertEquals(1, flightsAtOrigin.size()), — это «1» обретает смысл, только если прочитать test-flights.csv.
##Reasons for the Problem
Почему это происходит
- DRY, доведённый до крайности. Тестовые данные выносят в общий файл или «универсальную фикстуру», чтобы избежать дублирования, разменивая читаемость на переиспользование.
- Удобство работы с реальными данными. Сбросить продакшен-CSV/JSON или
seed.sqlкажется проще, чем конструировать объекты прямо в тесте. - Легаси/интеграционная подготовка. Наборы тестов, которые поднимают общую базу данных или опираются на аннотации фреймворка с данными-фикстурами (
@magentoDataFixture …), по умолчанию наследуют скрытое состояние.
Чем это вредит
- Читаемость / причинно-следственная связь. Связь между входом и ожидаемым выходом разорвана. Читатель не может понять, почему проверка верна, не покидая теста, а это сводит на нет роль теста как исполняемой документации.
- Ложная уверенность. Вы на самом деле не знаете, что именно проверяет тест; файл может содержать больше (или меньше), чем вы предполагаете, поэтому зелёный прогон доказывает меньше, чем кажется.
- Надёжность / детерминизм. Внешний ресурс может отсутствовать, быть переименован, переформатирован или различаться по окружению, ОС, кодировке или локали — и тогда падения отражают состояние фикстуры, а не реальный дефект (флакающие тесты).
- Сопровождаемость / связанность. Когда несколько тестов делят один ресурс, любой, кто правит его ради одного теста, может незаметно сломать остальные, и никто не знает, от каких полей зависит каждый тест.
##Treatment
Сделайте значимый вход видимым внутри теста, рядом с зависящей от него проверкой (свежая фикстура / встроенная подготовка). Цель в том, чтобы ожидаемое значение становилось самоочевидным.
Конкретные шаги:
- Внесите значимые данные прямо в тест. Сконструируйте в теле теста те несколько объектов/строк, которые важны тесту, чтобы ожидаемое значение проверки было очевидно верным.
- Если файл действительно нужен, постройте его в тесте. Используйте помощник, который принимает только существенные параметры и пишет во временный путь, а затем прибирает за собой, — так значимые значения окажутся в тесте, а не в закоммиченном коме.
- Замените общие/«универсальные» фикстуры фикстурами на каждый тест или предоставьте создатели/искатели, раскрывающие намерение (
createProductWithName('Simple Product'),getRecentlyAddedProduct()), чтобы проверяемые атрибуты были явными, а несущественная подготовка пряталась за хорошо названным билдером, а не за непрозрачным ресурсом.
До → после:
// До — Таинственный гость: данные и "2" спрятаны в сид-файле
test('flags overdue invoices', async () => {
await seedDatabaseFromFixture('invoices.sql');
const overdue = await findOverdueInvoices();
expect(overdue).toHaveLength(2);
});
// После — входы видны; ожидаемый результат очевиден
test('flags overdue invoices', async () => {
await insertInvoice({ id: 1, dueDate: '2020-01-01', paid: false }); // просрочен
await insertInvoice({ id: 2, dueDate: '2099-01-01', paid: false }); // срок ещё не наступил
const overdue = await findOverdueInvoices();
expect(overdue.map(i => i.id)).toEqual([1]);
});
Для случая с файлом предпочтите узкоспециализированный помощник закоммиченному файлу:
const csv = makeSubscriberCsv(tmpFile, 'active@x.com', 'active@y.com', 'active@z.com');
// теперь "3 активных" обосновано тем, что видно в тесте, а не скрытым файлом