ConstructiCat Logo
CodeBust.
Browse section ▾

Таинственный гость.

Тест, чьи входные данные или ожидаемые результаты находятся во внешнем ресурсе — файле, сидовых данных БД или общей фикстуре, — поэтому понять такой тест и доверять ему, читая только его, невозможно.

##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

Сделайте значимый вход видимым внутри теста, рядом с зависящей от него проверкой (свежая фикстура / встроенная подготовка). Цель в том, чтобы ожидаемое значение становилось самоочевидным.

Конкретные шаги:

  1. Внесите значимые данные прямо в тест. Сконструируйте в теле теста те несколько объектов/строк, которые важны тесту, чтобы ожидаемое значение проверки было очевидно верным.
  2. Если файл действительно нужен, постройте его в тесте. Используйте помощник, который принимает только существенные параметры и пишет во временный путь, а затем прибирает за собой, — так значимые значения окажутся в тесте, а не в закоммиченном коме.
  3. Замените общие/«универсальные» фикстуры фикстурами на каждый тест или предоставьте создатели/искатели, раскрывающие намерение (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 активных" обосновано тем, что видно в тесте, а не скрытым файлом