Написание чистых тестов на JavaScript с использованием принципов BASIC

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Почему многие команды отказываются от тестирования

Продакшен код — основной код, в котором находятся функции — может быть неидеальным. Это подразумевает риск или долг, которым команды управляют различными способами. Для кода тестов риск гораздо более значителен — они могут просто исчезнуть. Сотни часов кодирования и встреч могут просто пропасть.

Вы сталкивались с подобным? Вот типичная история. Семь часов вечера, на улице холодно и темно. Сара — относительно новый член команды, и она пытается развернуть систему. Кажется, что все хорошо, только вот несколько тестов, написанных кем-то другим, не работают. Сара открывает файл с тестами, просматривает их и не верит своим глазам — каждый тест состоит из 50 строк кода (с Prettier это стало 100 loc), включая циклы, if/else, коллбэки. Это раздражает, потому что она думала, что самое сложное — кодирование фичей — уже позади. Подождите, теперь она еще и заметила, что тест вызывает другие внешние классы, которые наследуются от других классов и имеют общее состояние с другими тестами. Становится трудно смотреть трейсы — уже восемь вечера, Сара голодная, начинает уже злиться, а еще на улице хмуро и идет дождь. В отличие от тестов, новый код, похоже, работает нормально — да, она также протестировала его с помощью Postman и браузера. В отчаянии она пытается воспользоваться магическим заклинанием, которое заставит все печали закончиться. Она добавляет одно словечко — test.skip. Теперь все тесты пройдены. Она разворачивает систему!

Угадайте, что произошло?

Ничего, все работает. В продакшене все в порядке, видимо, просто тесты были не обновлены.

Какой вывод из этого можно сделать? Возможно, вы считали, что самое страшное, что может случиться с командой, — это ошибка в продакшене. Нет, это тесты, которые пропускаются (skipped) и вызывают потерю к ним доверия. Я работаю как консультант примерно с 20 различными командами в год над различными задачами в области Node.js и часто сталкиваюсь с подобным. Это замечаю не только я. Существует явление под названием «Разрушающаяся пирамида тестирования», которое описывает исчезновение тестов из кодовой базы.

Я слышу, что вы думаете: с нами такого не случится — мы дисциплинированы, у нас ежедневные скрам-митинги, мы измеряем покрытие! Ничего из этого не поможет. Тесты, как и оргазм, очень легко подделать (я что, правда это написал?). Эти иллюзорные тесты бывают разными: разработчики могут действительно их написать, но не будут им доверять. Затем они проверят код с помощью браузера или Postman, чтобы обрести настоящую уверенность. Такое ручное тестирование сводит на нет все причины проведения тестирования (скорость, автоматизация). Другие пишут тесты после написания кода, по факту, непосредственно перед продакшеном. Такой порядок работы подразумевает, что во время написания кода не было тестов, которые бы служили защитой от регрессии (p.s. Писать тесты до кода не обязательно, писать их во время написания кода — тоже нормально. Но не в самом конце). Со временем тесты становятся практически бесполезными — это налог, который приходится платить бедным разработчикам. Затем, со временем, тестирование уходит из повестки дня, оно существует только физически, как зомби.

Что стоит команде делать по-другому?

Почему случается это неприятное явление? Потому что разработчики — умные, избалованные и крайне эффективные существа, которые ищут кратчайший путь к цели. Мы все очень заняты основным кодом, и у нас не остается места для дополнительных систем, не говоря уже о системе тестирования. Если случайно и найдется дополнительный умственный потенциал для большего количества кода, который обещает ускорить их рабочий процесс, то он должен быть крошечным и очень простым. Позвольте мне сказать громко и ясно: тестирование может проявиться в наилучшем свете только тогда, когда разработчики убеждены в этом и с готовностью перенимают эту привычку. Это может произойти, если тесты не будут обузой для их занятого ума, а станут простой техникой, приносящей значительную пользу. Первый принцип тестирования — планировать выдающийся ROI (пояснение «Return On Investment», возврат инвестиций) — коэффициент рентабельности инвестиций, который помогает рассчитать окупаемость вложений), и извлекать значительные выгоды. Только в этом случае команды действительно будут его поддерживать.

Какие громкие слова, но как этого добиться? Следуя трем принципам. Во-первых, стремиться к минимальному объему тестирования, необходимому для получения уверенности (об этом говорил еще Кент Бек, «отец» TDD). Во-вторых, тесты должны отражать условия в продакшене, чтобы обеспечить уверенность (см. концепции shift-right и testing-in-production). Третий принцип заключается в написании предельно простых тестов — это тема данной статьи.

Говоря «простой тест», мы имеем в виду действительно элементарный код. Абсолютно плоский, уровень сложности равен единице, автономный, почти без зависимостей, который содержит 7-10 операторов и который написан на человекопонятном языке. Если получится соблюсти эти условия, то у Сары не останется шанса пропустить этот тест — ведь он слишком прост. Однако в реальности многие силы заставляют разработчиков отклоняться от золотого пути простоты: тесты будут включать циклы и логику, потому что не было найдено другого способа проверить то или иное поведение. Тесты станут длинными, хотя автор действительно старался этого избежать — просто слишком много чего нужно подготовить для теста. Другие задачи будут побуждать к написанию императивного и не очень простого кода. Когда это начинает происходить, остановитесь. Высокая сложность тестов случается с теми, кто готов ее принять. Эти негодяи придут, чтобы измолоть вас, но вы должны дать им отпор и уничтожить их на ранней стадии. Чтобы быть готовым к этой битве, я собрал несколько принципов и приемов под акронимом BASIC. Давайте рассмотрим эти принципы, а затем приведем пример неприятного реального теста, наполненного ненужной сложностью. Затем применим к нему принципы BASIC и посмотрим, как он уменьшится и превратится в красивый короткий тест.

Акроним BASIC

Каждая из букв представляет собой принцип, который необходимо учитывать при тестировании. Далее мы применим эти BASIC-принципы к длинному и громоздкому тесту и превратим его в красивый короткий тест.

Черный ящик (Black-box, B) — нас волнует только то, что производит ящик, наш компонент или тестируемый объект, и что видно вызывающей стороне. Будь то API (интеграция) или объекты кода (юнит). Если ошибка не видна извне, она не будет иметь значения для наших пользователей, то не все ли равно? Избегайте тестирования того, КАК работают вещи, сосредоточьтесь на том, ЧТО они делают. Неважно, какие функции были вызваны, важен только результат: извне заметные вещи. Например, ответ тестируемой части, наблюдаемое новое состояние, значимые обращения к внешнему коду и др. Когда автор теста сосредотачивается на внешнем слое, количество деталей резко сокращается, и ему приходится расставлять приоритеты в отношении важных факторов, которые могут повлиять на UX. Неизбежно снижается продолжительность и сложность теста.

Аннотативность (Annotative, A) — тест должен иметь предсказуемую структуру с декларативным языком. Он должен быть больше похож на аннотации, чем на код. Просмотр продакшен кода — это путешествие с четким началом и неизвестным концом. Стек-трейс может разворачиваться бесконечно долго и привести сосредоточенного читателя во многие уголки приложения. За это время может потребоваться напрячь мозги, чтобы уловить смысл. Тестирование не должно быть таким, скорее, оно должно напоминать декларативный синтаксис, например... HTML. При чтении HTML-документации наш мозг без труда перебирает различные теги, почти ожидая, что будет дальше. Мало кто из разработчиков избегает или не читает фрагменты HTML из-за их сложности.

Как сделать тестирование более похожим на декларативное и структурированное? Используйте 6 повторяющихся частей в каждом тесте с использованием паттерна AAA (см. ниже), декларативные утверждения, до 7 утверждений. О других подобных паттернах читайте в статье JavaScript Testing Best Practices.

6 повторяющихся частей в каждом тесте
6 повторяющихся частей в каждом тесте

Один тест — одна проверка (Single Door, S) — каждый тест должен быть сфокусирован на чем-то одном. Обычно он должен вызывать одно действие в приложении и проверять один результат, который произошел в ответ на это действие. Мой друг Шай Резник (Shai Reznik) называет это «реакцией». Действием может быть вызов функции, нажатие кнопки, вызов REST-маршрута, новое сообщение в очереди, запланированное время начала работы или любое другое системное событие. После выполнения этого действия может произойти до трех событий: ответ, изменение состояния (в памяти или в БД) или обращение к стороннему сервису (Credit: Roy Osherove entry and exit point). В интеграционных тестах может быть шесть типов исходов, подробнее см. мой чеклист. Эти потенциальные исходы проверяются с помощью утверждений. В чем заключается выгода? Сужение каждого теста до одного действия-результата способствует сокращению времени тестирования и лучшему анализу первопричин — в случае неудачи становится ясно, что именно не работает. Это хорошее эмпирическое правило, которому нужно следовать, но не слишком жестко — можно протестировать 2 результата в одном тесте, можно 3, но ни в коем случае не 10. Сосредоточьтесь на цели, а не на количестве.

Независимость (Independent, I). Тест — это «проблема» длиной в 7-10 строк, крошечная вселенная, которая не делит ничего с другими. Если тест независим, краток и декларативен — его приятно читать случайному читателю. Однако следует помнить, что если просто связать тест с каким-то глобальным объектом, то он внезапно подвергнется побочным эффектам со стороны многих других тестов, и сложность резко возрастет. Связь также может возникнуть из-за общих записей в БД, зависимости от порядка выполнения, состояния пользовательского интерфейса, несброшенных ожиданий на тестовых моках и т.д. Избегайте всего этого. Вместо этого позвольте каждому тесту учитывать свои зависимости, потенциально используя хелперы, включая собственное состояние БД, чтобы сохранить его как интуитивно понятную вселенную.

Копируйте только необходимое (Copy only what’s necessary, C). Включайте в тест все необходимые детали, которые должны быть понятны читателю в пределах теста. Ничего лишнего, только необходимое. Излишнее дублирование приведет к тому, что тесты станут неуправляемыми; с другой стороны, извлечение важных деталей наружу заставит читателя искать информацию в разных файлах. В качестве примера можно привести тест, который должен обработать 100 строк входного JSON — вставлять это в каждый тест очень утомительно. Извлечение его наружу в transferFactory.getJSON() оставит тест неясным — без данных трудно соотнести результат теста с причиной («почему он должен вернуть статус 400?»). В классической книге по паттернам x-unit этот паттерн назван «таинственный гость» — что-то невидимое повлияло на результаты тестирования, но мы не знаем, что именно.

Чтобы добиться большего, необходимо извлекать повторяющиеся большие части наружу и явно указывать, какие именно детали важны для теста. В соответствии с приведенным выше примером, тест может передавать параметры, которые подчеркивают, что именно важно: transferFactory.getJSON({sender: undefined}). В этом примере читатель должен сразу понять, что пустое поле отправителя — это причина, по которой тест должен ожидать ошибку валидации или любой другой подобный адекватный результат.

Пример из реальной жизни: как неприятный тест превращается в красивый

Если отбросить теорию, то давайте превратим неприятный тест высокой сложности в простой, применив пять принципов BASIC

Тестируемый код

Это приложение, которое мы будем тестировать: сервис для денежных переводов. Код будет подтверждать запрос на перевод денег, применять некоторую логику, запрашивать банковский HTTP-сервис для перевода денег и, наконец, сохраняться в базе данных. Это намеренно не совсем «тестируемый» код, если не сказать больше — вероятно, вы хотите научиться тестировать любой код, а не только «идеальный».

class TransferService {
async transfer({ id, sender, receiver, transferAmount, bankName }) {
    // Validation
    if (!sender || !receiver || !transferAmount || !bankName) {
      throw new Error("Some mandatory property was not provided");
    }
  
    // Handle insufficient credit
    if (this.options.creditPolicy === "zero" && sender.credit < transferAmount) {
      this.numberOfDeclined++; //incrementing interal metric
      return {id, status: "declined",date};
    }

    // All good, let's transfer using the bank 3rd party service + save in DB
    await this.bankProviderService.transfer(sender, receiver, transferAmount, bankName);
    await this.repository.save({id,sender,receiver,transferAmount,bankName});

    return {id,status: "approved", date: new Date()};
  }  
  
  getTransfers(senderName){
    // Query the DB
  }
}

Неприятный тест

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

// ❌
  test('Should fail', () => {
    const transferRequest = testHelpers.factorMoneyTransfer({}); // ❌
    serviceUnderTest.options.creditPolicy = 'zero'; // ❌
    transferRequest.howMuch = 110; // ❌
    // Let's use the library sinon to listen to calls to the DB save function
    const databaseRepositoryMock = sinon.stub(dbRepository, 'save');//❌
    const transferResponse = serviceUnderTest.transfer(transferRequest);
    expect(transferResponse.currency).toBe('dollar'); // ❌
    expect(transferResponse.id).not.toBeNull(); // ❌
    expect(transferResponse.date.getDay()).toBe(new Date().getDay()); // ❌
    expect(serviceUnderTest.numberOfDeclined).toBe(1); // ❌
    expect(databaseRepositoryMock.calledOnce).toBe(false); // ❌

    // Let's get the user transfer history
    const allUserTransfers = serviceUnderTest.getTransfers(transferRequest.sender.name);
    expect(allUserTransfers).not.toBeNull(); // ❌ Overlapping
    expect(allUserTransfers).toBeType('array'); // ❌ Overlapping

    // check that declined transfer is not in user history array ❌
    let transferFound = false;
    allUserTransfers.forEach((transferToCheck) => {
      if (transferToCheck.id === transferRequest.id) {
        transferFound = true;
      }
    });
    expect(transferFound).toBe(false);

    // Let's check that an email was sent ❌
    if (transferRequest.options.sendMailOnDecline && transferResponse.status === 'declined') {
      const wasMailSent = testHelpers.verifyIfMailWasSentToTransfer(transferResponse.id);
      expect(wasMailSent).toBe(true);
    }
  });

Улучшаем тест с помощью принципов BASIC

Давайте пройдем от строки к строке и исправим их, применяя принципы BASIC

Строка 2: двусмысленность

❌ Плохо (строка 2):

test(‘Should fail’, () => {

✅ Лучше:

describe(‘transferMoney’)// The operation under test
{  test(‘When the user has not enough credit, then decline the   
request’, () => 
}

Источник: https://habr.com/ru/companies/otus/articles/769362/


Интересные статьи

Интересные статьи

В современной бизнес-среде далеко не только технические навыки имеют значение. Soft skills или «мягкие навыки», такие как коммуникабельность, эмоциональный интеллект, и адаптивность, становятся все бо...
Небольшая заметка о быстром вычислении свёртки одномерных и двумерных функций на основе быстрого преобразования Фурье с использованием пакета SciPy.
Оптимизация функций — это область исследований, где поставлена задача найти некое входное значение [аргумент функции], результат которого — максимум или минимум данной фу...
Недавно в продаже появились светодиодные лампы Gauss Basic. Я протестировал и изучил семь моделей. Читать дальше →
Сегодня мы публикуем заметку, посвящённую особенностям использования JavaScript-консоли в браузерах, лежащим за пределами широко известной команды console.log(). Собственно говоря, эта команда пр...