В ходе очередного ревью толстого 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:
[конкретная сущность] должна [ожидаемое действие / поведение] при (в случае | если) [название сценария или краткое описание условия]


или в виде кода:


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] — itexpect.


Спасибо за внимание!

Комментарии (2)


  1. TimurNes
    05.06.2019 13:06
    +1

    describe('discoveryService => initDiscoveries',()=> {});
    тоже плохой пример. Зачем в описании теста название тестируемого сервиса и метода?
    Название сервиса можно вынести и в родительский describe, а в этом написать примерно такое:
    describe('when initializing discoveries',()=> {
        beforeEach(
            // Setup success request mock
        )
    
        it('should clear existent discoveries', () => {
            discoveryService.initDiscoveries();
            expect(...).to...;
        });
    
        it('should load new discoveries', () => {
            discoveryService.initDiscoveries();
            expect(...).to...;
        }); 
    });


    следующий тест, на неуспешную загрузку:
    describe('when initializing discoveries failed',()=> {
        beforeEach(
            // Setup failed request mock
        )
    
        it('should show error message', () => {
            discoveryService.initDiscoveries();
            expect(toastr.error)
                .toHaveBeenCalledWith('Failed to initialize discoveries');
        });
    });

    Гораздо же понятнее, когда предназначение теста и его поведение описывается «людским» языком, а не просто «сервис — метод»


    1. HomoLuden Автор
      05.06.2019 13:26

      Согласен с Вами, однако мне кажется в Ваших примерах стоит все-таки добавить родительский describe. А то можно не вчитываясь прийти к неправильным выводам. А еще текстовочку чуть благозвучнее по-английски подправить.
      Как-то так…

      describe('discoveryService', () => {
      describe('when discoveries initialization failed',()=> {
          beforeEach(
              // Setup failed request mock
          )
      
          it('should show error message', () => {
              discoveryService.initDiscoveries();
              expect(toastr.error)
                  .toHaveBeenCalledWith('Failed to initialize discoveries');
          });
      });
      

      Данный тест в случае ошибки в консоль выведет:
      />
      discoveryService
      when discoveries initialization failed
      should show error message


      Читается естественным образом.