Автоматизированные тесты - качественно и непременно эффективно!

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

Тестируйте поведение, а не реализацию 

Задача: Пишите тесты для функций, используя только их выводы и параметры (сигнатуру). Эта стратегия является ключевым компонентом разработки через тестирование (TDD) и позволяет создавать более простой код приложения. Если задача окажется сложной, то есть смысл разбить функции для их упрощения и сокращения ответственности. 

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

// getNextSequence.js
import generator from "./generator";

let lastSequence = 0;
export default function getNextSequence() {
  let nextSequence;
  
  // Цикл во избежание повторений 
  do {
    nextSequence = generator.generateId();
  } while (nextSequence <= lastSequence);
  
  lastSequence = nextSequence;
  return nextSequence;
}

Тест реализации нацелен на содержимое функции, чтобы определить настройки и имитации.

import getNextSequence from "./getNextSequence";
import generator from "./generator";

// Заглушки Sinon(stubs)предоставляют фейковую реализацию для контроля ответа. 
generateIdStub = sinon.stub(generator, 'generateId');

test('that the id is deduped', function () {
  // Возвращает 0 при первом вызове и 1 - при втором. 
  // Единственный способ узнать, как вернуть 0 - посмотреть на реализацию 
  generateIdStub.onCall(0).returns(0);
  generateIdStub.onCall(1).returns(1);
 
  getNextSequence();
  
  expect(generateId.callCount).to.equal(2);
});

Поведенческий тест ориентирован на результаты выполнения, и единственное, что он может изменять, это значения параметров функции (которые в нашем случае отсутствуют). Следующий пример теста нацелен на поведение функции getNextSequence

import getNextSequence from "./getNextSequence";

test('that the sequence increments', function () {
  const firstSequence = getNextSequence();
  const secondSequence = getNextSequence();
  
  expect(firstSequence).to.be.lessThan(secondSequence);
}

Оба теста проходят, но тот, который был ориентирован на реализацию, не выдерживает следующего рефакторинга. 

// getNextSequence.js
import generator from "./generator";

let currentSequence = 0;
export default function getNextSequence() {
  currentSequence += 1;
  return currentSequence;
}

Поведение функции getNextSequence не изменилось, и приложение по-прежнему работает. Однако тест реализации нацелен на скрытую деталь, которой больше не существует, и настраивает уже не приемлемую имитацию. Тест не пройдет, что свидетельствует о ложноотрицательном результате

Что такое ложноотрицательный результат? 

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

Негативные последствия ложноотрицательных результатов 

  • Разработчики перестают доверять тестам. Изменения кода часто тестируются вручную одновременно с их написанием (не обязательно всегда использовать TDD). Разработчик сочтет ненадежным кем-то созданный провальный тест и не захочет тратить время на его понимание, обновление или удаление. 
  • Разработчики утрачивают доверие к приложению. Когда база кода “славится” некачественными тестами, то становится рискованно проводить радикальные изменения, например обновление фреймворков. В итоге такие изменения откладываются в долгий ящик или никогда не осуществляются. 
  • Нерациональная трата времени. Сотни строк настройки могут усложнить анализ первопричин. Время, потраченное на диагностику ошибки в тестовом коде, с большей пользой можно было бы уделить устранению ошибки в приложении. Самым оптимальным решением было бы отказаться от этих неисправных тестов, но это привело бы к меньшему покрытию кода и снижению его надежности.
  • Увеличение времени сборки в два или три раза. Тест считается нестабильным, если он периодически не проходит. Когда же вам удается отладить сборки, выполнив перезапуск, то можете считать это первым шагом по устранению неисправностей. Если разработчики учитывают эту норму, то они могут не обращать внимания на ошибку сборки при первом выводе, если только она не повторится во второй или даже третий раз.
  • Увеличение доли ручного тестирования. При потере доверия к автоматизированным тестам команда разработчиков вернется к ручному тестированию. Однако оно медленнее, более подвержено воздействию человеческого фактора и требует больших затрат, но при этом ему не откажешь в надежности. 

Что такое ложноположительный результат? 

Ложноположительный результат возникает, когда тест проходит, но при этом приложение не работает.  

В предыдущем примере функция generateId заглушена. Представим, что реальная функция имеет следующее определение. 

let counter = 0;
function generateId() {
  // Отсчет на убывание, а не на возрастание! 
  counter -= 1;
  return counter;
}

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

Дополнительный пример: 

test('create user', function () {
  createUsers()
    .then((user) => {
      expect(user.id).to.not.be.null();
    });
});

Этот тест не ожидает окончания асинхронного вызова createUsers, поэтому он проходит независимо от результата. Есть несколько способов решения проблемы в зависимости от фреймворка для тестирования. 

// Включение выполненного обратного вызова 
test('create user', function (done) {
  createUser()
    .then((user) => {
      expect(user.id).to.not.be.null();
      done();
    });
});

// Возврат промиса createUser() 
test('create user', function () {
  return createUser()
    .then((user) => {
      expect(user.id).to.not.be.null();
    });
});

// Использование async/await. Тест перехватит необработанные исключения.  
test('create user', async function () {
  const user = await createUser();
  expect(user.id).to.not.be.null();
});

К сожалению, эти виды ложноположительных результатов могут остаться незамеченными во время ревью кода. 

Предотвратить их можно следующими способами: 

  • Использовать плагины Lint, такие как prefer-expect-assertions
  • При TDD тесты запускаются в состоянии сбоя и не проходят до тех пор, пока не будет написана реализация. 
  • Некоторые фреймворки, например ava, не пройдут тест без использования утверждения. 

Негативные последствия ложноположительных результатов 

  • Разработчики перестают доверять тестам. Тесты не справились с единственной задачей, ради которой они проводились. Что это за тесты, которые на самом деле ничего не проверяют?
  • Специалисты по обеспечению качества (QA) утрачивают доверие к разработчикам. Ни один QA-специалист не обрадуется явным ошибкам, допущенным в среде тестирования, несмотря на то, что сборка прошла автоматизированные тесты. Они с трудом будут привыкать к идее автоматизации и испытывать потребность вручную проверять каждый тикет. 
  • Ошибки отправляются в производственную среду. Преимущество автоматизированных тестов заключается в своевременной проверке каждой части системы. QA-специалисты прежде всего люди, кроме того не все компании пользуются их услугами. Ответственность за сообщение об ошибках возлагается на клиентов, которые часто не утруждают себя подобной обязанностью и либо посетуют на недостатки приложения, либо перейдут на другую платформу. 

Покрытие кода  —  это ориентир, а не показатель качества 

Инструменты по типу nyc, сообщающие о непокрытых тестом строках кода, негативно сказались на рассуждениях некоторых менеджеров. 100% покрытие тестов приводит к излишним расходам. Такой подход подразумевает тестирование всех мельчайших деталей реализации, включая каждую инструкцию if, независимо от ее влияния на поведение. В примере с getNextSequence время разработчика было впустую потрачено сначала на настройку имитаций для необязательного теста реализации, а затем на диагностику ложноотрицательного результата, полученного в результате правильного рефакторинга. 

Из этого вовсе не следует вывод о бесполезности инструментов покрытия кода. Их назначение  —  помогать, а не указывать. Они могут обнаружить ошибки в случаях, когда тесты совсем не применяются, или предоставить владельцу продукта весомые аргументы, свидетельствующие о надежности кода. 

Не рассчитывайте на библиотеки для имитаций  

Многие разработчики считают библиотеки для имитаций, такие как sinon, кодом с душком. 

“Код с душком  —  это внешний признак, свидетельствующий о более глубокой проблеме в системе”,  —  Мартин Фаулер. 

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

// getNextSequence.js
let lastSequence = 0;
export default function getNextSequence(generatorIdFunction) {
  let nextSequence;
  
  // Цикл во избежание повторений 
  do {
    nextSequence = generatorIdFunction(lastSequence);
  } while (nextSequence <= lastSequence);
  
  lastSequence = nextSequence;
  return nextSequence;
}

Вместо того, чтобы заглушить generatorIdFunction, функцию можно было бы передать напрямую: getNextSequence((lastSequence) => { return lastSequence + 1; });.

Хотя имитации могут и не потребоваться, надо сказать, что у них действительно удобный и выразительный API: expect(callback).to.be.calledOnce();. Однако у них должен быть только ограниченный доступ через параметры. 

Работа со сторонними библиотеками 

Сторонние библиотеки не являются оправданием библиотек для имитаций. Используя внедрение зависимостей, интерфейс легко можно заменить на фейковую реализацию. 

import serverUploader from "./serverUploader";

const uploadToServer(file) {
  if (!file) {
    // Это то, что мы тестируем 
    throw new Error("You didn't include a file!"); 
  }
  
  serverUploader.upload(file);
}

Вместо того, чтобы импортировать serverUpload, включите его в качестве внедренной зависимости. 

const uploadToServer(serverUploader, file) {
  if (!file) {
    throw new Error("You didn't include a file!"); 
  }
  
  serverUploader.upload(file);
}

Или:

class ServerUploader {
  constructor(serverUploader) {
    this.serverUploader = serverUploader;
  }  

  uploadToServer(file} {
    if (!file) {
      throw new Error("You didn't include a file!"); 
    }

    this.serverUploader.upload(file);
  }
}

С помощью расширенного доступа к serverUploader можно легко и просто обеспечить фейковую реализацию () => { // do nothing }.

По возможности тестируйте сторонний код 

Внешние библиотеки, к примеру moment.js, включают свои собственные тесты, поэтому нет необходимости тестировать все случаи, а разумнее будет охватить только те, которые используются в приложении, или, по крайней мере, не исключать их намеренно. С помощью этих тестов можно перехватить ошибки при обновлении версий и гарантировать сохранение функциональности при переключении на такие библиотеки, как luxon

Не тестируйте константы и присваивание переменной

 

function getTodoAction(title) {
  return { type: 'ADD_TODO', title };
}

test('returns todo action', function () {
  const todo = getTodoAction('Test');
  expect(todo.type).to.equal('ADD_TODO');
  expect(todo.title).to.equal('Test');
});
test('returns todo action', function () {
  const todo = getTodoAction('Test');
  expect(todo.type).to.equal('ADD_TODO');
  expect(todo.title).to.equal('Test');
});

Этот пример может показаться невероятным, но он довольно распространен среди разработчиков, которые идут на поводу у инструментов покрытия кода. 

Тестировать здесь просто нечего, так как отсутствует поведение. Вышеуказанный тест проверяет факт присваивания значения переменной, т. е. одну из самых основных функциональностей языка. Если он не пройдет, то возникнут более глобальные проблемы. 

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

Эти строки будут неявно проверены интеграционными и сквозными тестами. 

Извлекайте и тестируйте устаревший код небольшими блоками 

Иногда написание тестов становится тяжким бременем, особенно при работе с устаревшим кодом, создание которого проходило без учета тестирования. Разработчики, с опаской относящиеся к такому коду, будут полагаться на ручную верификацию ради экономии сил и времени.  

Разделение кода на логические управляемые блоки поможет облегчить это бремя: возьмите 100 взаимосвязанных строк в 2000 строчной функции и извлеките их в отдельный файл. При таком разделении можно легко добавлять шаблоны наподобие внедрения зависимости. 

Отделив один блок, остановитесь. Не следует менять устаревший код слишком часто, к тому же найдется не так много программистов, которые понимают его в полной мере. В отсутствие автоматизации рискованно проводить большие изменения. Лучше начать с небольших, в дальнейшем постепенно разбивая код на части. 

Но такой вариант развития событий маловероятен в том случае, если менеджер/коллега настаивает на радикальном изменении. При этом есть один способ с ним договориться  —  секрет заключается в двух словах: минимизировать риск. 

Так как многие базы кода устарели, стоит отметить еще одну проблему  —  такие же старые и некачественные тесты. Довольно сложно работать с 2000 строчным тестовым файлом, особенно если тесты взаимосвязаны между собой. К счастью, у нас есть разумное решение  —  использовать для одной и той же цели 2 тестовых файла. Создайте новый файл для новых тестов, а старый переименуйте в filename.deprecated.test.js.

Пишите независимые друг от друга тесты 

Изоляционное тестирование вселяет в разработчиков больше уверенности в том, что данные другого теста не покажут ложноотрицательных и ложноположительных результатов. Рассмотрим следующий пример: 

test('user is created', async function () {
  for (let i = 0; i < 10; i += 1) {
    await createUser();
  }  

  const users = await getUsers();
  expect(users).to.have.lengthOf(10);
});

Данный тест не выполняет очистку после завершения. Это значит, что каждый выполняющийся после него тест будет иметь 10 пользователей в базе данных. Или же он сам станет “жертвой” аналогичного теста, и в этом случае он может неожиданно не пройти, к тому же возникнут проблемы с его диагностикой, особенно если пользователь был создан в другом файле. 

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

beforeEach(function () {
  cleanDatabase();
});

Используйте TDD 

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

TDD помогает разработчикам: 

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

TDD не используется в следующих случаях: 

  • когда разрабатывается доказательство концепции. Речь идет о коде, в котором допускается наличие явных ошибок. Действуйте быстро и не утруждайте себя написанием тестов, так как это просто эксперимент, который вам в дальнейшем не пригодится. Можно использовать факт отсутствия автоматизации как весомый аргумент для уничтожения кода, так как владельцы продукта, возможно, захотят запустить его в производственную среду.  
  • когда неясна окончательная архитектура. Иногда разработчики не знают, что включает в себя реализация, пока не начнут над ней работать. Они не могут рассматривать какие-либо классы и сигнатуры функций, поскольку все еще не уверены в правильности выбранного подхода. Написание тестов на этом этапе  —  это пустая трата времени, поскольку, вероятнее всего, вы их удалите/перепишите.

Устраняйте случайность в тестах 

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

test('planet is created', function () {
  planets = ['mars', 'pluto', ...theRestOfThem];
  randomPlanet = selectRandomFrom(planets);  

  const planet = createPlanet(randomPlanet);  

  expect(planet.name).to.be.oneOf(planets);
}

Рассмотрим ряд недостатков этого теста: 

  • Если этот тест не пройдет из-за Плутона, т. е. planets.filter(p => p !== “pluto"), то причину будет сложно диагностировать, особенно при включении планет, находящихся за пределами Млечного Пути. Результат будет считаться ложноотрицательным, поскольку образец теста не соответствует бизнес-требованию об исключении Плутона.  
  • Без помощи логов планета, ставшая причиной тестового сбоя, так и останется неизвестной. Использование же логирования для теста является признаком кода с душком. 
  • Вариант локального воссоздания сбоя маловероятен, и сборка перейдет на следующий этап выполнения непрерывной интеграции (CI). Тест сочтут нестабильным и нестоящим внимания, что сведет на нет желание изначально добавлять случайность.
  • При каждом выполнении теста проверяется лишь один случай. Если нужно проверить все планеты, то большинство фреймворков предоставляют возможность разбить их на отдельные тесты. 
test.each(planets, 'planet x is created', function () { ... }
  • Представьте, что вызов createPlanet усекает имя: planet.name.slice(0,4). Данный тест не пройдет во всех случаях, кроме Марса (а это только Млечный Путь), и в каждом из них мы получим на выводе ложноположительный результат.

Что-то похожее случилось и со мной. Кто-то включил случайность в тест, затрагивая измененный мною код. Чудесным образом тесты прошли локально (а это 1 шанс из 10) и еще раз в моем PR. Однако после его слияния лимит моей удачливости иссяк, и все сборки стали сбоить. Тонны времени были потрачены на диагностику различий переменных среды, прежде чем я обнаружил, что истинной причиной проблемы был тест. 

Пишите больше интеграционных тестов 

Пишите тесты. Не слишком много. Больше интеграции,  —  Гильермо Раух. 

Соотношение интеграционных тестов > модульных тестов > сквозных тестов с точки зрения значимости, а не частоты использования

Вырисовывается новая парадигма, в которой интеграционные тесты занимают самый значимый уровень в пирамиде тестов. Они обеспечивают связанность функций и внешних систем, выполняя код точно таким же образом, как и пользователь. Тот факт, что они могут перекрывать покрытие некоторых модульных тестов, совсем не умаляет ценности последних. 

Интеграционные тесты…

  • гарантируют большую достоверность, используя код свойственным для пользователя способом; 
  • скрывают детали реализации. Допустимо обращение только к вводам и выводам; 
  • надежнее и быстрее, чем сквозные тесты, которые вводят внешние переменные, такие как браузеры, время ожидания и операционные системы;
  • выдают меньшее число ложноположительных и ложноотрицательных результатов; 
  • требуют больше времени на выполнение, чем модульные тесты, поскольку взаимодействуют с внешними системами, например с базой данных.  

Использование современных процессоров превращает последний пункт лишь в мелкий недочет. Если вас прежде всего волнует скорость разработки, то поразмышляйте вот над чем: 

  • Сбавьте обороты. Код на скорую руку чреват ошибками. Неужели вы готовы из-за ошибок потерять клиентов, лишь бы уложиться во временные рамки? 
  • При ограничении области применения появится больше времени для тестирования. 
  • Многие фреймворки для тестирования включают команду --watch, которая выполняет только тесты, связанные с изменениями. Этот флаг не будет работать в конвейере CI.
  • Рассмотрите использование фреймворка, способного выполнять тесты асинхронно или в распределенной среде. Не стоит забывать, что тесты не должны быть взаимозависимыми
  • Отключайте несоответствующие тесты локально. Большинство фреймворков предоставляют возможность выполнять только находящийся в разработке файл или тест, т. е. describe.only('....

Заключение 

Пишите тесты также качественно, как и код приложения. Если тест не представляет никакой ценности, не бойтесь его переписать или полностью удалить. Не относитесь к тесту покрытия слишком серьезно. Тесты должны предоставлять достоверные данные, а не превращаться в бремя или тяжелую обязанность. 

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Kevin Fawcett: Effective Automation Tests

Предыдущая статьяGo на пороге третьего десятилетия 21 века: язык программирования для искусственного интеллекта и науки о данных
Следующая статьяПроблема эйджизма в IT-сфере