[Перевод] Об удобочитаемом именовании тестов в JS и поведенческом паттерне
В ходе очередного ревью толстого Pull Request’а наткнулся на Unit Test’ы с некорректным именованием тест-кейсов. Обсуждение формулировок в тест-кейсах получилось похожим на разговор Янычара и Легкоступова в к/ф »72 метра» («если б мне в школе так доходчиво…»). В разговоре прозвучала мысль, что в рускоязычных ресурсах трудно найти толковый гайд именно по текстовым формулировкам. Решил искать самолично на русском (обычно я пользуюсь только англоязычными источниками). На хабре нашел несколько мануалов про юнит-тесты, но все они обходят стороной детали формулировок в тест-кейсах. Под катом моя попытка восполнить данный пробел.
Дисклэмер
Есть шанс, что я плохо искал / слишком по диагонали читал. Вот пример того как тема этой статьи освещена в тех статьях, что попадались мне на глаза.
TDD для начинающих
По просьбе коллег, которым на английском языке руководства читать не комфортно, решил перевести и скомпилировать англоязычные мануалы.
От переводчика
За основу для статьи взял эти два материала:
Еще вынужден заметить, что в некоторых примерах тестов пришлось сделать частичный перевод на русский. Формулировки в блоках «describe» умышленно остаются на английском, т.к. с большой вероятностью будут содержать имя функций, модулей JS или других сущностей в коде, а вот в блоках «it» текст уже переводится для удобства чтения.
Мое личное предпочтение — в коде все должно быть на английском языке.
Именование тестов
Имя теста должно максимально кратко и явным образом описывать свое предназначение. Название и описание теста — это первое, что должно указывать на причину неисправности. Результат теста в консоли должен читаться корректно с точки зрения грамматики. Сторонние разработчики не должны в голове решать ребусы, пытаясь догадаться о чем думал автор теста. Тесты являются частью документации к программе и они также должны быть грамотно написаны.
ПЛОХОЙ пример:
describe('discoveryService => initDiscoveries', () => {
it('инициализируем discoveries (дергаем очистку, загрузку данных и т.д.)', () => {
// ...
});
});
describe('MyGallery', () => {
it('init при вызове задает корректные свойства (размер иконки, кол-во иконок)', () => {
});
// ...
});
Из примеров выше трудно понять какое конкретно действие (действия) совершается и к какому конкретному результату действие должно приводить.
ХОРОШИЙ пример:
describe('discoveryService => initDiscoveries', () => {
it('должна очистить данные discoveries', () => {
// ...
});
it('должна получить новые данные для discoveries', () => {
// ...
});
});
describe('Экземпляр Gallery', () => {
it('должен правильно вычислять размер иконки при вызове инициализации', () => {
});
it('должен правильно вычислять количество иконок при вызове инициализации', () => {
});
// ...
});
Прим. перев. #1: обратите внимание, блок текста в it начинается с прописной, т.к. является продолжением предложения, начавшегося в descibe.
Прим. перев. #2: в примерах выше «discoveryService => initDiscoveries» корректнее все-таки разбить на два блока descibe (один вложен в другой).
Прим. перев. #3: обратите внимание, в примерах про discovery выше нет второй части описания тест-кейса; там подразумевается текст вида «при ее вызове», что не очень хорошо с точки зрения явственности; в простых случаях копипастить «при ее вызове» не особо профитно, ИМХО.
В блок describe обычно помещают описание элементарной работы (Unit of Work, UoW). В блоке it формулировка должна иметь паттерн »unit of work — scenario/context — expected behaviour»: [конкретная сущность] должна [ожидаемое действие / поведение] при (в случае | если) [название сценария или краткое описание условия]
или в виде кода:
describe('[unit of work]', () => {
it('должна [ожидаемое поведение] когда/если [сценарий/контекст]', () => {
});
});
Если несколько групп тестов следуют одному сценарию или укладываются в один контекст, то можно использовать вложенные блоки describe.
describe('[unit of work]', () => {
describe('когда/при/если [scenario/context]', () => {
it('дожнен/должна [expected behaviour]', () => {
});
});
});
describe('Экземпляр Gallery', () => {
describe('при инициализации', () => {
it('должен корректно вычислять размер иконки', () => {
});
it('должен корректно вычислять количество иконок', () => {
});
});
// ...
});
ОДИН ТЕСТ — ОДНА ПРОБЛЕМА
Каждый тест должен фокусироваться на одном конкретном сценарии в работе приложения. Тест, ответственный за один конкретный аспект, способен выявить конкретную причину неисправности. Чем конкретнее тест, тем меньше шансов, что причин некорректного поведения может оказаться несколько. Старайтесь размещать в одном блоке it лишь один блок expect.
ПЛОХОЙ пример:
describe('isUndefined function', ()=> {
it('должна возвращать true or false когда аргумент является undefined', () => {
expect(isUndefined(undefined)).toEqual(true);
expect(isUndefined(true)).toEqual(false);
});
});
Блок it содержит два блока expect. Это означает, что разработчик увидев отрицательный результат выполнения данного теста не сможет точно определить, что конкретно в его коде некорректно и как это исправить.
ХОРОШИЙ пример:
describe('isUndefined function', ()=> {
it('должна вернуть true, если аргумент является undefined', () => {
expect(isUndefined(undefined)).toEqual(true);
});
it('должна вернуть false если аргумент имеет значение логического типа', () => {
expect(isUndefined(true)).toEqual(false);
});
});
Каждый тест в примере выше оценивает одну конкретную проблему. Кроме того, в описании теста четко описано в каком случае он будет пройден. В обоих кейсах в консоли разработчик прочитает в виде списка какие результаты при каких действиях / условиях ожидаются от тестируемой пользовательской функциональности.
Тестируем поведение
Смотрите на картину, не разглядывайте мазки. Тестируйте пользовательский сценарий / поведение, а не детали реализации. Тогда изменение деталей реализации не повлияют на результаты тестирования. Отрицательный результат теста должен говорить о том, корректно или нет ведет себя программа с точки зрения пользователя. Тест не должен контролировать / ограничивать детали реализации.
ПЛОХОЙ пример:
it('должна добавить данные discovery в кэш', () => {
discoveriesCache.addDiscovery('57463', 'John');
expect(discoveriesCache._discoveries[0].id).toBe('57463');
expect(discoveriesCache._discoveries[0].name).toBe('John');
});
Что здесь плохо? Во-первых, два блока expect, но не это главное. Во-вторых, тестируется не поведение, а детали реализации. Детали реализации поменяются (переименованы приватные поля) — тест станет не валидным и его нужно будет переписывать.
ХОРОШИЙ пример:
it('должна добавить данные discovery в кэш', () => {
discoveriesCache.addDiscovery('57463', 'John');
expect(discoveriesCache.isDiscoveryExist('57463', 'John')).toBe(true);
});
В этом примере тестируется публичное API, которое должно быть максимально стабильным.
ЗАКЛЮЧЕНИЕ ОТ ПЕРЕВОДЧИКА
«Онегин был педант…» У меня складывается впечатление, что большинство разработчиков уделяют точности и удобочитаемости названий тестов недостаточно много внимания. Часто наблюдаю довольно длительные обсуждения вида «А что же делает этот код» или «А зачем этот код». Это касается как основного кода в JS (неясные, нечеткие названия модулей, сервисов, функций и переменных), так и тестов (размытые кейсы, тестирование деталей реализации, нечеткие описания). Все это ведет к тому, что код делает не совсем то, что ожидается.
В одном из своих интервью Дэвид Хайнмейер Хэнссон (David Heinemeier Hansson, создатель фреймворка Rails) сказал что-то вроде следующего:
«Юнит тесты показывают лишь то, что ваша программа ожидаемым образом делает го%: о».
Он имел в виду то, что тестировать надо поведение, а не юниты кода. И текстовые формулировки должны иметь поведенческий паттерн. Т.е. «Сущность А должна вести себя так-то при таких-то условиях». В такую складную формулировку должна превращаться цепочка вида describe [- describe] — it — expect.
Спасибо за внимание!