Это исчерпывающее руководство по обеспечению надёжности в JavaScript и Node.js. Здесь собраны десятки лучших постов, книг и инструментов.
Сначала разберитесь с общепринятыми методиками тестирования, которые лежат в основе любого приложения. А затем можно углубиться в интересующую вас сферу: фронтенд и интерфейсы, бэкенд, CI или всё перечисленное.
Содержание
- Раздел 0. Золотое правило. Один совет, являющийся движущей силой для всех остальных советов (1 специальная глава).
- Раздел 1. Анатомия теста. Основы: создаём понятные тесты (12 глав).
- Раздел 2. Бэкенд. Эффективно пишем тесты для бэкенда и микросервисов (8 глав).
- Раздел 3. Фронтенд, интерфейс, E2E. Пишем тесты для веб-интерфейса, включая компонентные и E2E-тесты (11 глав).
- Раздел 4. Измерение эффективности тестов. Наблюдаем за наблюдателем: оценка качества теста (4 главы).
- Раздел 5. Непрерывная интеграция. Рекомендации по CI в мире JS (9 глав).
Раздел 0. Золотое правило
0. Золотое правило: Придерживайтесь бережливого тестирования
Что сделать. Тестовый код отличается от того, что идёт в эксплуатацию. Делайте его максимально простым, коротким, свободным от абстракций, единым, замечательным в работе и бережливым. Другой человек должен посмотреть на тест и сразу понять, что он делает.
Наши головы заняты production-кодом, в них нет свободного места для дополнительной сложности. Если мы будем пихать в свой бедный разум новую порцию сложного кода, это замедлит работу всей команды над задачей, ради решения которой мы и проводим тестирование. По сути, из-за этого многие команды просто избегают тестов.
Тесты — это возможность получить дружелюбного и улыбчивого помощника, с которым очень хорошо работать и который даёт огромную отдачу при небольших вложениях. Учёные считают, что в нашем 2X(17 × 24)
.
Добиться этого можно с помощью тщательного подбора методик, инструментов и целей для тестирования, чтобы они были экономичны и давали большой ROI. Тестируйте лишь столько, сколько необходимо, старайтесь подходить гибко. Иногда стоит даже отбросить какие-то тесты и пожертвовать надёжностью ради скорости и простоты.
Большинство рекомендаций, представленных ниже, являются производными от этого принципа.
Готовы?
Раздел 1. Анатомия теста
1.1 Имя каждого теста должно состоять из трёх частей
Что сделать. Отчёт о тестировании должен свидетельствовать о том, удовлетворяет ли текущая ревизия приложения требованиям тех людей, которые не знакомы с кодом: тестировщиков, занимающихся деплоем DevOps-инженеров, а также вас самих через два года. Лучше всего будет, если тесты сообщают информацию на языке требований, а их наименования состоят из трёх частей:
- Что именно тестируется? Например, метод
ProductsService.addNewProduct
. - При каких условиях и сценарии? Например, методу не передаётся цена.
- Какой ожидается результат? Например, новый продукт не одобрен.
В противном случае. Деплой не удаётся, сбоит тест под названием «Add product». Вы понимаете, что именно работает не так?
Примечание. В каждой главе есть пример кода, а иногда и иллюстрация. См. в спойлерах.
//1. unit under test
describe('Products Service', function() {
describe('Add new product', function() {
//2. scenario and 3. expectation
it('When no price is specified, then the product status is pending approval', ()=> {
const newProduct = new ProductService().add(...);
expect(newProduct.status).to.equal('pendingApproval');
});
});
});
1.2 Структурируйте тесты согласно паттерну AAA
Что сделать. Каждый тест должен состоять из трёх чётко разделённых разделов: Arrange (подготовка), Act (действие) и Assert (результат). Соблюдение такой структуры гарантирует, что читающему ваш код не придётся задействовать мозговой процессор, чтобы понять план теста:
Arrange: весь код, который приводит систему в состояние согласно тестовому сценарию. Сюда может входить создание экземпляра модуля в конструкторе тестов, добавление записей в базу данных, создание заглушек вместо объектов, и любой другой код, подготавливающий систему к прогону теста.
Act: исполнение кода в рамках теста. Обычно всего одна строка.
Assert: убеждаемся, что полученное значение удовлетворяет ожиданиям. Обычно всего одна строка.
В противном случае. Вы не только будете тратить долгие часы на работу с основным кодом, но ваш
describe.skip('Customer classifier', () => {
test('When customer spent more than 500$, should be classified as premium', () => {
//Arrange
const customerToClassify = {spent:505, joined: new Date(), id:1}
const DBStub = sinon.stub(dataAccess, "getCustomer")
.reply({id:1, classification: 'regular'});
//Act
const receivedClassification = customerClassifier.classifyCustomer(customerToClassify);
//Assert
expect(receivedClassification).toMatch('premium');
});
});
Пример антипаттерна. Никакого разделения, одним куском, сложнее интерпретировать.
test('Should be classified as premium', () => {
const customerToClassify = {spent:505, joined: new Date(), id:1}
const DBStub = sinon.stub(dataAccess, "getCustomer")
.reply({id:1, classification: 'regular'});
const receivedClassification = customerClassifier.classifyCustomer(customerToClassify);
expect(receivedClassification).toMatch('premium');
});
1.3 Описывайте ожидания на языке продукта: констатируйте в стиле BDD
Что сделать. Программирование тестов в декларативном стиле позволяет пользователю сразу понять суть, не тратя ни одного цикла мозгового процессора. Когда вы пишете императивный код, упакованный в условную логику, читателю приходится прилагать массу усилий. С этой точки зрения нужно описывать ожидания на языке, похожем на человеческий, в декларативном BDD-стиле с использованием expect/should и не применяя кастомный код. Если в Chai и Jest нет нужной констатации (assertion), которая часто повторяется, то можно расширить сопоставитель (matcher) Jest или написать свой плагин для Chai.
В противном случае. Команда будет писать меньше тестов и декорировать раздражающие тесты with .skip()
.
Пример антипаттерна. Чтобы понять суть теста, пользователь вынужден продраться через довольно длинный императивный код.
it("When asking for an admin, ensure only ordered admins in results" , ()={
//assuming we've added here two admins "admin1", "admin2" and "user1"
const allAdmins = getUsers({adminOnly:true});
const admin1Found, adming2Found = false;
allAdmins.forEach(aSingleUser => {
if(aSingleUser === "user1"){
assert.notEqual(aSingleUser, "user1", "A user was found and not admin");
}
if(aSingleUser==="admin1"){
admin1Found = true;
}
if(aSingleUser==="admin2"){
admin2Found = true;
}
});
if(!admin1Found || !admin2Found ){
throw new Error("Not all admins were returned");
}
});
Как делать правильно. Чтение этого декларативного теста не вызывает затруднений.
it("When asking for an admin, ensure only ordered admins in results" , ()={
//assuming we've added here two admins
const allAdmins = getUsers({adminOnly:true});
expect(allAdmins).to.include.ordered.members(["admin1" , "admin2"])
.but.not.include.ordered.members(["user1"]);
});
1.4 Придерживайтесь тестирования по методу «чёрного ящика»: тестируйте только публичные методы
Что сделать. Тестирование внутренностей приведёт к огромным накладным расходам и почти ничего не даст. Если ваш код или API предоставляет правильные результаты, стоит ли тратить три часа на тестирование того, КАК это работает внутри, а затем ещё и поддерживать эти хрупкие тесты? Когда вы проверяете публичное поведение, вы одновременно неявным образом проверяете и саму реализацию, ваши тесты будут сбоить только при наличии конкретной проблемы (например, неправильные выходные данные). Этот подход также называют поведенческим тестированием. С другой стороны, если вы тестируете внутренности (метод «белого ящика»), то вместо планирования выходных данных компонентов вы сосредоточитесь на мелких подробностях, и ваши тесты могут сломаться из-за мелких переделок кода, пусть даже с результатами всё будет в порядке, а на сопровождение будет уходить гораздо больше ресурсов.
В противном случае. Ваши тесты будут вести себя как мальчик, кричавший «Волк!»: громко сообщать о ложно-положительных срабатываниях (например, тест сбоит из-за изменения имени частной переменной). Неудивительно, что скоро люди начнут игнорировать CI-уведомления, и однажды пропустят настоящий баг…
Пример с использованием Mocha.
class ProductService{
//this method is only used internally
//Change this name will make the tests fail
calculateVAT(priceWithoutVAT){
return {finalPrice: priceWithoutVAT * 1.2};
//Change the result format or key name above will make the tests fail
}
//public method
getPrice(productId){
const desiredProduct= DB.getProduct(productId);
finalPrice = this.calculateVATAdd(desiredProduct.price).finalPrice;
}
}
it("White-box test: When the internal methods get 0 vat, it return 0 response", async () => {
//There's no requirement to allow users to calculate the VAT, only show the final price. Nevertheless we falsely insist here to test the class internals
expect(new ProductService().calculateVATAdd(0).finalPrice).to.equal(0);
});
1.5 Выбирайте правильные имитированные реализации: избегайте фальшивых объектов в пользу заглушек и шпионов
Что сделать. Имитированные реализации (test doubles) — это необходимое зло, потому что они связаны с внутренностями приложения, а некоторые имеют огромную ценность (освежите в памяти информацию об имитированных реализациях: фальшивые объекты (mocks), заглушки (stubs) и объекты-шпионы (spies)). Однако не все методики эквивалентны. Шпионы и заглушки предназначены для тестирования требований, но имеют неизбежный побочный эффект — также они слегка затрагивают и внутренности. А фальшивые объекты предназначены для тестирования внутренностей, что приводит к огромным накладным расходам, как описано в главе 1.4.
Прежде чем использовать имитированные реализации, задайте себе простейший вопрос: «Я использую это для тестирования функциональности, которая появилась или может появиться в документации с требованиями?» Если нет, то попахивает тестированием по методу «белого ящика».
Например, если вы хотите выяснить, ведёт ли приложение себя как положено при недоступности платёжного сервиса, вы можете сделать вместо него заглушку и возвращать «Нет ответа», чтобы проверить возвращение правильного значения тестируемым модулем. Так можно проверять поведение/ответ/выходные данные приложения при определённых сценариях. Вы также можете с помощью шпиона подтвердить, что при недоступности сервиса письмо было отправлено, это тоже поведенческое тестирование, которое лучше отразить в документации с требованиями («Отправить письмо, если нельзя сохранить информацию о платеже»). В то же время, если вы сделаете фальшивый платёжный сервис и удостоверитесь, что он вызывался с помощью правильных JS-типов, тогда ваш тест нацелен на внутренности, не имеющие отношения к функциональности приложения и которые наверняка будут часто меняться.
В противном случае. Любой рефакторинг кода подразумевает поиск и обновление всех фальшивых объектов в коде. Тесты из друга-помощника превращаются в обузу.
Пример с использованием Sinon.
it("When a valid product is about to be deleted, ensure data access DAL was called once, with the right product and right config", async () => {
//Assume we already added a product
const dataAccessMock = sinon.mock(DAL);
//hmmm BAD: testing the internals is actually our main goal here, not just a side-effect
dataAccessMock.expects("deleteProduct").once().withArgs(DBConfig, theProductWeJustAdded, true, false);
new ProductService().deletePrice(theProductWeJustAdded);
mock.verify();
});
Как делать правильно. Шпионы предназначены для тестирования требований, но есть побочный эффект — они неизбежно затрагивают внутренности.
it("When a valid product is about to be deleted, ensure an email is sent", async () => {
//Assume we already added here a product
const spy = sinon.spy(Emailer.prototype, "sendEmail");
new ProductService().deletePrice(theProductWeJustAdded);
//hmmm OK: we deal with internals? Yes, but as a side effect of testing the requirements (sending an email)
});
1.6 Не применяйте «foo», используйте реалистичные входные данные
Что сделать. Часто production-баги проявляются при очень специфических и удивительных входных данных. Чем реалистичнее данные в ходе тестирования, тем больше шансов вовремя выловить баги. Для генерирования псевдонастоящих данных, имитирующих разнообразие и вид production-данных, используйте специальные библиотеки, например, Faker. Такие библиотеки могут генерировать реалистичные телефонные номера, ники пользователей, банковские карты, названия компаний, даже текст «lorem ipsum». Вы можете создавать тесты (поверх модульных, а не вместо них), которые рандомизируют фальшивые данные для подгонки модуля под тест, или даже импортировать настоящие данные из production-окружения. Хотите пойти ещё дальше? Читайте следующую главу (о тестировании на основе свойств).
В противном случае. Ваше тестирование в ходе разработки будет выглядеть успешным при использовании синтетических входных данных вроде «Foo», а на production-данных могут начаться сбои, когда хакер передаст хитрую строку вроде @3e2ddsf . ##’ 1 fdsfds . fds432 AAAA
.
const addProduct = (name, price) =>{
const productNameRegexNoSpace = /^S*$/;//no white-space allowed
if(!productNameRegexNoSpace.test(name))
return false;//this path never reached due to dull input
//some logic here
return true;
};
test("Wrong: When adding new product with valid properties, get successful confirmation", async () => {
//The string "Foo" which is used in all tests never triggers a false result
const addProductResult = addProduct("Foo", 5);
expect(addProductResult).to.be.true;
//Positive-false: the operation succeeded because we never tried with long
//product name including spaces
});
Как делать правильно. Рандомизируйте реалистичные входные данные.
it("Better: When adding new valid product, get successful confirmation", async () => {
const addProductResult = addProduct(faker.commerce.productName(), faker.random.number());
//Generated random input: {'Sleek Cotton Computer', 85481}
expect(addProductResult).to.be.true;
//Test failed, the random input triggered some path we never planned for.
//We discovered a bug early!
});
1.7 С помощью тестирования на основе свойств проверяйте многочисленные комбинации входных данных
Что сделать. Обычно для каждого теста мы выбираем несколько образцов входных данных. Даже если входной формат похож на настоящие данные (см. главу «Не применяйте «foo»»), мы покрываем лишь несколько комбинаций входных данных (метод (‘’, true, 1)
, метод ("string" , false" , 0)
). Но в эксплуатации API, который вызывается с пятью параметрами, может быть вызван с тысячами различных комбинаций, одна из которых может привести к падению процесса (фаззинг). Что, если вы могли бы написать один тест, автоматически отправляющий 1000 комбинаций входных данных и фиксирующий, при каких комбинациях код не возвращает правильный ответ? То же самое мы делаем при методике тестирования на основе свойств: с помощью отправки всех возможных комбинаций входных данных в тестируемый модуль мы увеличиваем шанс обнаружения бага. Например, у нас есть метод addNewProduct(id, name, isDiscount)
. Поддерживающие его библиотеки будут вызывать этот метод со многими комбинациями (числа, строкового значения, булева значения)
, например, (1, "iPhone", false)
, (2, "Galaxy", true)
и т.д. Вы можете тестировать на основе свойств с помощью своего любимого прогонщика тестов (Mocha, Jest и т.д.) и библиотек вроде js-verify или testcheck (у неё гораздо лучше документация). Ещё можете попробовать библиотеку fast-check, которая предлагает дополнительные возможности и активно сопровождается автором.
В противном случае. Вы бездумно выбираете для теста входные данные, которые покрывают лишь хорошо работающие пути исполнения кода. К сожалению, это снижает эффективность тестирования как средства выявления ошибок.
require('mocha-testcheck').install();
const {expect} = require('chai');
const faker = require('faker');
describe('Product service', () => {
describe('Adding new', () => {
//this will run 100 times with different random properties
check.it('Add new product with random yet valid properties, always successful',
gen.int, gen.string, (id, name) => {
expect(addNewProduct(id, name).status).to.equal('approved');
});
})
});
1.8 При необходимости используйте только короткие и инлайненные снимки
Что сделать. Когда нужно протестировать на основе снимков, используйте только короткие снимки безо всего лишнего (например, в 3-7 строк), включая их в качестве части теста (Inline Snapshot), а не в виде внешних файлов. Соблюдение этой рекомендации позволит сохранить ваши тесты самоочевидными и более надёжными.
С другой стороны, руководства по «классическим снимкам» и инструментарий провоцируют нас хранить большие файлы (например, разметку отрисовки компонентов или результаты API JSON) на внешнем носителе и при каждом запуске теста сравнивать результаты с сохранённой версией. Это может, скажем, неявно связать наш тест с 1000 строк, содержащих 3000 значений, которые автор теста никогда не видел о которых не предполагал. Почему это плохо? Потому что появляется 1000 причин для сбоя теста. Даже одна строка может сделать снимок недействительным, и происходить это может часто. Насколько? После каждого пробела, комментария или мелкого изменения в CSS или HTML. Кроме того, имя теста не подскажет вам о сбое, потому что он лишь проверяет, что 1000 строк не изменились, да к тому же подталкивает автора теста принять в качестве желаемого длинный документ, который он не смог бы проанализировать и сверить. Всё это является симптомами неясного и торопливого теста, не имеющего чёткой задачи и пытающегося добиться слишком многого.
Стоит отметить, что есть несколько ситуаций, в которых приемлемо использовать длинные и внешние снимки, например, при подтверждении схемы, а не данных (извлечение значений и сосредоточенность на полях), или когда получаемые документы редко изменяются.
В противном случае. UI-тесты сбоят. Код выглядит нормально, на экране отображаются идеальные пиксели, что же происходит? Ваше тестирование с помощью снимков только что выявило различие между исходным документом и только что полученным — в разметке добавился один символ пробела…
it('TestJavaScript.com is renderd correctly', () => {
//Arrange
//Act
const receivedPage = renderer
.create( <DisplayPage page = "http://www.testjavascript.com" > Test JavaScript < /DisplayPage>)
.toJSON();
//Assert
expect(receivedPage).toMatchSnapshot();
//We now implicitly maintain a 2000 lines long document
//every additional line break or comment - will break this test
});
Как делать правильно. Ожидания видимы и находятся в центре внимания.
it('When visiting TestJavaScript.com home page, a menu is displayed', () => {
//Arrange
//Act
receivedPage tree = renderer
.create( <DisplayPage page = "http://www.testjavascript.com" > Test JavaScript < /DisplayPage>)
.toJSON();
//Assert
const menu = receivedPage.content.menu;
expect(menu).toMatchInlineSnapshot(`
<ul>
<li>Home</li>
<li> About </li>
<li> Contact </li>
</ul>
`);
});
1.9 Избегайте глобальных тестовых стендов и начальных данных, добавляйте данные в каждый тест по отдельности
Что сделать. Согласно золотому правилу (глава 0), каждый тест должен добавлять и работать в рамках собственного набора строк в базе данных, чтобы избегать связываний, а пользователям было легче разобраться в работе теста. В реальности тестеры часто нарушают это правило, перед прогоном тестов заполняя БД начальными данными (seeds) (также это называют «тестовым стендом») ради повышения производительности. И хотя производительность действительно является важной задачей, ведь она может уменьшиться (см. главу «Тестирование компонентов»), однако сложность тестов куда вреднее и именно она должна чаще всего управлять нашими решениями. Практически каждый тестовый случай должен явно добавлять в БД необходимые записи и работать только с ними. Если производительность критически важна, то в качестве компромисса можно заполнять начальными данными только те тесты, которые не изменяют информацию (например, запросы).
В противном случае. Несколько тестов провалены, развёртывание прервано, теперь команда потратит драгоценное время, у нас баг? Давайте искать, блин, кажется, два теста меняли одни и те же начальные данные.
before(() => {
//adding sites and admins data to our DB. Where is the data? outside. At some external json or migration framework
await DB.AddSeedDataFromJson('seed.json');
});
it("When updating site name, get successful confirmation", async () => {
//I know that site name "portal" exists - I saw it in the seed files
const siteToUpdate = await SiteService.getSiteByName("Portal");
const updateNameResult = await SiteService.changeName(siteToUpdate, "newName");
expect(updateNameResult).to.be(true);
});
it("When querying by site name, get the right site", async () => {
//I know that site name "portal" exists - I saw it in the seed files
const siteToCheck = await SiteService.getSiteByName("Portal");
expect(siteToCheck.name).to.be.equal("Portal"); //Failure! The previous test change the name :[
});
Как делать правильно. Можно оставаться в рамках теста, каждый тест работает только со своими данными.
it("When updating site name, get successful confirmation", async () => {
//test is adding a fresh new records and acting on the records only
const siteUnderTest = await SiteService.addSite({
name: "siteForUpdateTest"
});
const updateNameResult = await SiteService.changeName(siteUnderTest, "newName");
expect(updateNameResult).to.be(true);
});
1.10 Не ловите ошибки, а ожидайте их
Что сделать. Желая подтвердить, что какие-то входные данные приводят к ошибке, вы можете прибегнуть к try-catch-finally и доказать, что было введено условие поимки ошибки. В результате получается неприглядный и громоздкий тест (пример ниже), который скрывает простое намерение проверки и ожидания результатов.
Более элегантным решением будет использование однострочной проверки Chai: expect(method).to.throw
(или в Jest: expect(method).toThrow()
). Также абсолютно необходимо убедиться в том, что исключение содержит свойство, позволяющее узнать тип ошибки. Иначе, если будет некая общая ошибка, то приложение сможет показать пользователю лишь раздражающее сообщение.
В противном случае. Из отчётов о тестировании (например, CI-отчётов) будет сложно понять, что пошло не так.
/it("When no product name, it throws error 400", async() => {
let errorWeExceptFor = null;
try {
const result = await addNewProduct({name:'nest'});}
catch (error) {
expect(error.code).to.equal('InvalidInput');
errorWeExceptFor = error;
}
expect(errorWeExceptFor).not.to.be.null;
//if this assertion fails, the tests results/reports will only show
//that some value is null, there won't be a word about a missing Exception
});
Как делать правильно. Удобный для восприятия человеком код, который легко понять, возможно, даже сотрудникам QA или техническим менеджерам проектов.
it.only("When no product name, it throws error 400", async() => {
expect(addNewProduct)).to.eventually.throw(AppError).with.property('code', "InvalidInput");
});
1.11 Размечайте свои тесты
Что сделать. Разные тесты должны прогоняться для разных сценариев:
- быстрые smoke-тесты,
- IO-less,
- тесты, которые должны выполняться, когда разработчик сохраняет или коммитит файл,
- полные сквозные тесты, которые обычно выполняются при отправке новых pull request’ов, и так далее.
Добиться этого можно, если помечать тесты ключевыми словами, например, #cold #api #sanity. Тогда вы сможете грепать свои инструменты тестирования и вызывать нужный набор тестов. Например, таким способом можно в Mocha вызывать группу тестов на работоспособность: mocha — grep ‘sanity’
.
В противном случае. Если при любом, даже самом мелком изменении, которое сделал разработчик, прогонять все тесты, включая те, что выполняют десятки запросов к базе данных, то рабочий процесс очень сильно замедлится, а разработчики будут стараться избегать тестирования.
//this test is fast (no DB) and we're tagging it correspondingly
//now the user/CI can run it frequently
describe('Order service', function() {
describe('Add new order #cold-test #sanity', function() {
it('Scenario - no currency was supplied. Expectation - Use the default currency #sanity', function() {
//code logic here
});
});
});
1.12 Другие общие правила гигиены тестирования
Что сделать. Эта статья посвящена советам, которые относятся к Node.js или могут быть иллюстрированы с его помощью. А в этой главе я опишу несколько хорошо известных советов, не связанных с Node.
Изучайте и применяйте принципы TDD. Они очень полезны для многих, но не расстраивайтесь, если они не подойдут под ваш стиль, не вы первые с этим столкнулись. Попробуйте писать тесты до написания кода в стиле красный-зелёный-рефакторинг, тогда каждый тест будет проверять только что-то одно. Когда найдёте баг, то перед его исправлением напишите тест, который определит этот баг в будущем. Позвольте тесту сбоить хотя бы один раз, прежде чем исправить баг. Начинайте модуль с быстрого написания упрощённого кода, удовлетворяющего тесту, а затем постепенно его рефакторьте, доводя до эксплуатационного уровня, избегая зависимостей от среды (путей, ОС и т.д.).
В противном случае. Вы лишитесь мудрости, накопленной программистами за десятилетия.
Раздел 2: Тестирование бэкенда
️2.1 Расширьте свой арсенал тестирования: не ограничивайтесь модульными тестами и пирамидой тестирования
Что сделать. Хотя концепции пирамиды тестирования уже 10 лет, эта прекрасная и актуальная модель предлагает три типа тестов и оказывает влияние на стратегию тестирования большинства разработчиков. Однако в тени пирамиды развилось немало других замечательных методик. Учитывая драматические изменения, которые мы наблюдали за последние 10 лет (микросервисы, облака, внесерверная обработка данных), возможно ли такое, что всего одна, довольно старая модель будет удовлетворять всем типам приложений? Не должно ли сообщество тестировщиков приветствовать новые методики тестирования?
Не поймите меня неправильно: в 2019-м пирамида тестирования, TDD и модульные тесты всё ещё остаются мощными методиками, которые, вероятно, лучше всего подходят для многих приложений. Но, как и любая другая модель, несмотря на свою пользу, иногда они могут ошибаться. Возьмём IoT-приложение, которая передаёт много событий в шину сообщений наподобие Kafka или RabbitMQ, которые затем попадают в хранилище, и в конце концов их запрашивает какой-то аналитический интерфейс. Целесообразно ли тратить половину бюджета тестирования на создание модульных тестов, проверяющих приложение, которое заточено под интеграцию и почти не содержит логики? Чем больше разнообразие типов приложений (боты, криптография, голосовые ассистенты), тем выше шанс столкнуться со сценариями, для которых пирамида тестирования не слишком-то годится.
Пришла пора расширить ваш арсенал тестирования и познакомиться с новыми видами тестирования (ниже я предложу ряд идей) и моделями вроде пирамиды, а также сопоставить виды тестирования с реальными проблемами, возникающими перед вами («У нас сломался API, давай напишем тестирование контрактов, управляемых клиентами!» (consumer-driven contracts)). Диверсифицируйте свои тесты словно инвестор, создающий портфолио на основе анализа рисков: оцените, где могут возникнуть проблемы, и решите, какие меры могут снизить риски.
Предостережение: аргумент с TDD в мире ПО обретает типичный фальшиво-дихотомичный образ. Одни проповедуют использовать TDD повсеместно, другие считают это кознями дьявола. Ошибаются все, кто занимают абсолютистские точки зрения.
В противном случае. Вы упустите некоторые инструменты с превосходным ROI, вроде Fuzz, линтинга и мутаций, которые докажут свою ценность за 10 минут.
Пример:
2.2 Компонентное тестирование может оказаться вашим лучшим решением
Что сделать. Каждый модульный тест покрывает небольшую часть приложения, а покрыть его целиком сложно. При этом сквозное тестирование охватывает большую часть приложения, но работает медленнее и неустойчиво. Почему бы не найти компромиссное решение и не написать тесты, которые крупнее модульных, но меньше сквозных? Компонентное тестирование — это недооценённый бриллиант. Эти тесты взяли всё лучшее от двух миров: приличную производительность и возможность применения TDD-паттернов, а также широкое тестовое покрытие.
Компонентные тесты сосредоточены на микросервисном «модуле», они работают через API, не имитируют то, что принадлежит самому микросервису (например, настоящую базу данных, или хотя бы её in-memory версию), однако создают заглушки для всего внешнего, скажем, вызовы других микросерисов. Таким образом мы тестируем то, что развернули, работаем с приложением по принципу «от внешнего к внутреннему», за разумное время обретая уверенность в качестве продукта.
В противном случае. Вы можете потратить много дней на создание модульных тестов, а потом обнаружить, что достигли покрытия системы всего в 20 %.
2.3 Удостоверьтесь, что новые релизы не ломают использование API
Что сделать. У вашего микросервиса много клиентов, и ради совместимости вы запускаете много версий (чтобы все были счастливы). Однажды вы меняете какое-нибудь поле, и бах! — один из важных клиентов, для которого это поле играло важную роль, разозлился. Это «Уловка-22» в мире интеграции: на серверной стороне очень трудно учесть все ожидания многочисленных клиентов, при этом клиенты не могут выполнять тестирование, потому что сервер управляет датами релизов. Для формализации этого процесса с помощью очень деструктивного подхода придумали управляемые клиентами контракты (consumer-driven contracts) и фреймворк PACT: план тестирования сервера определяется не им самим, а… клиентами! PACT может записывать клиентские ожидания и складывать их в общее хранилище — «брокер», откуда сервер может их брать и запускать при каждой сборке. С помощью PACT-библиотеки сервер может определять расторгнутые контракты — не оправдавшиеся ожидания клиентов. Таким образом, все несовпадения API между сервером и клиентом обнаруживаются на ранних стадиях сборки или CI, что может спасти вас от сильного разочарования.
В противном случае. Альтернатива — изнурительное ручное тестирование или страхи при деплое.
2.4 Тестируйте промежуточное ПО изолированно
Что сделать. Многие избегают тестирования промежуточного ПО, поскольку оно составляет лишь небольшую часть системы и требует работающего Express-сервера. Всё это заблуждения. Промежуточное ПО невелико, но влияет на все запросы или большую их часть, и его можно легко тестировать как чистые функции, получающие JS-объекты {req,res}. Чтобы протестировать такую промежуточную функцию, нужно вызвать её и отследить (например, с помощью Sinon) взаимодействия с объектами {req,res}, чтобы удостовериться, что они всё делают правильно. Библиотека node-mock-http идёт ещё дальше и индексирует объекты {req,res} с отслеживанием их поведения. Например, она может проверить, соответствует ли ожиданиям HTTP-статус, присвоенный res-объекту (см. пример ниже).
В противном случае. Баг в Express-ПО === баг во всех запросах или большинстве из них.
//the middleware we want to test
const unitUnderTest = require('./middleware')
const httpMocks = require('node-mocks-http');
//Jest syntax, equivalent to describe() & it() in Mocha
test('A request without authentication header, should return http status 403', () => {
const request = httpMocks.createRequest({
method: 'GET',
url: '/user/42',
headers: {
authentication: ''
}
});
const response = httpMocks.createResponse();
unitUnderTest(request, response);
expect(response.statusCode).toBe(403);
});
2.5 Оценивайте и рефакторьте с помощью инструментов статического анализа
Что сделать. Использование инструментов статического анализа даёт объективные способы улучшения качества кода и позволяет поддерживать его в пригодном для сопровождения виде. Можете добавить в CI-сборку инструменты статического анализа, чтобы они прерывали работу при обнаружении недостатков в коде. Главное преимущество таких инструментов перед обычным линтингом заключается в возможности оценки качества с точки зрения многочисленных файлов (например, для определения дублей), для выполнения сложного анализа (например, оценки сложности кода), а также для отслеживания истории и развития проблем в коде. Могу порекомендовать Sonarqube (2600+ звёзд) и Code Climate (1500+ звёзд). Автор:: Keith Holliday
В противном случае. При плохом коде у вас всегда будут проблемы с багами и производительностью, которые не смогут решить даже шикарные новые библиотеки и самые лучшие фичи.
2.6 Проверьте, готовы ли вы к хаосу Node
Что сделать. Как ни странно, большинство тестов программного обеспечения касаются только логики и данных. Но самые худшие проблемы (которые действительно трудно минимизировать) случаются с инфраструктурой. Например, вы когда-либо проверяли, что происходит при перегрузке памяти процесса? Или при умирании сервера или процесса? А ваша система мониторинга может понять, что API стал работать на 50 % медленнее? Чтобы проверить и минимизировать подобные проблемы, в Netflix разработали концепцию хаос-инжиниринга (Chaos Engineering). Её цель: обеспечить нашу осведомленность, а также предоставить фреймворки и инструменты для тестирования устойчивости наших приложений к хаотичным ситуациям. Например, один из знаменитых инструментов Netflix, chaos monkey, случайным образом убивает серверы, чтобы проверить, продолжает ли сервис обслуживать пользователей, а не полагается на какой-то один сервер (есть также версия Kubernetes под названием kube-monkey, которая убивает поды). Все эти инструменты работают на уровне
В противном случае. От закона Мёрфи никуда не спрятаться, он накажет вас в production безо всякой жалости.
2.7 Избегайте глобальных тестовых стендов и начальных данных, добавляйте данные в каждый тест по отдельности
Что сделать. Согласно золотому правилу (глава 0), каждый тест должен добавлять и работать в рамках собственного набора строк в базе данных, чтобы избегать связываний, а людям было легче разобраться в работе теста. В реальности тестеры часто нарушают это правило, перед прогоном тестов заполняя БД начальными данными (seeds) (также это называют «тестовым стендом») ради повышения производительности. И хотя производительность действительно является важной задачей, ведь она может уменьшиться (см. главу «Тестирование компонентов»), однако сложность тестов куда вреднее, и именно она должна чаще всего управлять нашими решениями. Практически каждый тестовый случай должен явно добавлять в БД необходимые записи и работать только с ними. Если производительность критически важна, то в качестве компромисса можно заполнять начальными данными только те тесты, которые не изменяют информацию (например, запросы).
В противном случае. Несколько тестов провалены, деплой прерван, теперь команда потратит драгоценное время, у нас баг? Давайте искать, блин, кажется, два теста меняли одни и те же начальные данные.
before(() => {
//adding sites and admins data to our DB. Where is the data? outside. At some external json or migration framework
await DB.AddSeedDataFromJson('seed.json');
});
it("When updating site name, get successful confirmation", async () => {
//I know that site name "portal" exists - I saw it in the seed files
const siteToUpdate = await SiteService.getSiteByName("Portal");
const updateNameResult = await SiteService.changeName(siteToUpdate, "newName");
expect(updateNameResult).to.be(true);
});
it("When querying by site name, get the right site", async () => {
//I know that site name "portal" exists - I saw it in the seed files
const siteToCheck = await SiteService.getSiteByName("Portal");
expect(siteToCheck.name).to.be.equal("Portal"); //Failure! The previous test change the name :[
});
Как делать правильно. Можно оставаться в рамках теста, каждый тест работает только со своими данными.
it("When updating site name, get successful confirmation", async () => {
//test is adding a fresh new records and acting on the records only
const siteUnderTest = await SiteService.addSite({
name: "siteForUpdateTest"
});
const updateNameResult = await SiteService.changeName(siteUnderTest, "newName");
expect(updateNameResult).to.be(true);
});
Раздел 3: Тестирование фронтенда
3.1. Отделяйте UI от функциональности
Что сделать. Когда сосредотачиваешься на тестировании логики компонентов, подробности пользовательского интерфейса превращаются в шум, который нужно убрать, чтобы ваши тесты могли работать с чистыми данными. Извлеките из разметки нужные данные и абстрагируйте их, чтобы они не были слишком связаны с графической реализацией, сосредоточьтесь только на чистой информации (а не на внутренностях HTML и CSS) и отключите мешающие анимации. Возможно, у вас возникнет соблазн избежать отрисовки интерфейса и тестировать только только серверной части пользовательского интерфейса (например, сервисов, действий, хранилища), но это приведет к искусственным тестам, которые не будут отражать реальность и не выявят ситуации, когда нужные данные просто не поступают в пользовательский интерфейс.
В противном случае. Чистые обработанные данные тестирования могут быть готовы уже за 10 мс, но весь тест продлится 500 мс (100 тестов = 1 минута) из-за какой-нибудь причудливой и ненужной анимации.
test('When users-list is flagged to show only VIP, should display only VIP members', () => {
// Arrange
const allUsers = [
{ id: 1, name: 'Yoni Goldberg', vip: false },
{ id: 2, name: 'John Doe', vip: true }
];
// Act
const { getAllByTestId } = render(<UsersList users={allUsers} showOnlyVIP={true}/>);
// Assert - Extract the data from the UI first
const allRenderedUsers = getAllByTestId('user').map(uiElement => uiElement.textContent);
const allRealVIPUsers = allUsers.filter((user) => user.vip).map((user) => user.name);
expect(allRenderedUsers).toEqual(allRealVIPUsers); //compare data with data, no UI here
});
Пример антипаттерна. При проверке смешиваются подробности UI и данные.
test('When flagging to show only VIP, should display only VIP members', () => {
// Arrange
const allUsers = [
{id: 1, name: 'Yoni Goldberg', vip: false },
{id: 2, name: 'John Doe', vip: true }
];
// Act
const { getAllByTestId } = render(<UsersList users={allUsers} showOnlyVIP={true}/>);
// Assert - Mix UI & data in assertion
expect(getAllByTestId('user')).toEqual('[<li data-testid="user">John Doe</li>]');
});
3.2 Запрашивайте HTML-элементы с помощью атрибутов, которые вряд ли изменятся
Что сделать. Запрашивайте HTML-элементы с помощью атрибутов, которые наверняка переживут изменения графики. Например, меток форм, а не CSS-селекторов. Если элемент не имеет таких атрибутов, создайте отдельный тестовый атрибут вроде 'test-id-submit-button'. Такой подход защитит ваши тесты логики и функций от поломок в результате изменений дизайна. И кроме того, всей команде будет очевидно, что этот элемент и атрибут используется тестом и его нельзя убирать.
В противном случае. Вы хотите протестировать функцию входа, которая охватывает многие компоненты, логику и сервисы. Всё настроено идеально — заглушки, шпионы, вызовы Ajax изолированы. Всё кажется идеальным. А потом тест провалился, потому что дизайнер изменил класс CSS с 'thick-border' на 'thin-border'
// the markup code (part of React component)
<b>
<Badge pill className="fixed_badge" variant="dark">
<span data-testid="errorsLabel">{value}</span> <!-- note the attribute data-testid -->
</Badge>
</b>
// this example is using react-testing-library
test('Whenever no data is passed to metric, show 0 as default', () => {
// Arrange
const metricValue = undefined;
// Act
const { getByTestId } = render(<dashboardMetric value={undefined}/>);
expect(getByTestId('errorsLabel')).text()).toBe("0");
});
Пример антипаттерна. Полагаться на CSS-атрибуты.
<!-- the markup code (part of React component) -->
<span id="metric" className="d-flex-column">{value}</span> <!-- what if the designer changes the classs? -->
// this exammple is using enzyme
test('Whenever no data is passed, error metric shows zero', () => {
// ...
expect(wrapper.find("[className='d-flex-column']").text()).toBe("0");
});
3.3 По мере возможности тестируйте с настоящим и полностью отрисованным компонентом
Что сделать. Если размер приемлемый, тестируйте компонент снаружи, как это сделал бы пользователь. Полностью отрисуйте интерфейс, воспользуйтесь им и убедитесь, что он ведёт себя как ожидается. Избегайте любых заглушек, частичной или упрощённой отрисовки — иначе из-за нехватки подробностей пропустите баги и затрудните сопровождение, поскольку тесты окажутся смешаны с внутренностями (см. главу Придерживайтесь тестирования по методу «чёрного ящика»). Если один из дочерних компонентов (например, анимация) значительно замедляет или усложняет систему, попробуйте явно заменить его заглушкой.
Учитывая сказанное, хочу предупредить: эта методика подходит для маленьких и средних компонентов, которые содержат дочерние компоненты приемлемого размера. Полностью отрисованный компонент с многочисленными дочерними компонентами затруднит оценку сбоев тестов (анализ исходных причин) и может работать слишком медленно. В таких случаях пишите немного тестов для такого толстого родительского компонента, и побольше тестов для его дочерних компонентов.
В противном случае. Если вы исследуете внутренности компонента с помощью вызова его приватных методов и проверки внутреннего состояния, то при рефакторинге реализации компонентов вам придётся рефакторить и все тесты. У вас правда есть на это время и силы?
class Calendar extends React.Component {
static defaultProps = {showFilters: false}
render() {
return (
<div>
A filters panel with a button to hide/show filters
<FiltersPanel showFilter={showFilters} title='Choose Filters'/>
</div>
)
}
}
//Examples use React & Enzyme
test('Realistic approach: When clicked to show filters, filters are displayed', () => {
// Arrange
const wrapper = mount(<Calendar showFilters={false} />)
// Act
wrapper.find('button').simulate('click');
// Assert
expect(wrapper.text().includes('Choose Filter'));
// This is how the user will approach this element: by text
})
Пример антипаттерна. Подделка реальности с помощью упрощённой отрисовки.
test('Shallow/mocked approach: When clicked to show filters, filters are displayed', () => {
// Arrange
const wrapper = shallow(<Calendar showFilters={false} title='Choose Filter'/>)
// Act
wrapper.find('filtersPanel').instance().showFilters();
// Tap into the internals, bypass the UI and invoke a method. White-box approach
// Assert
expect(wrapper.find('Filter').props()).toEqual({title: 'Choose Filter'});
// what if we change the prop name or don't pass anything relevant?
})
3.4 Избегайте засыпания с помощью встроенной во фреймворки поддержки асинхронных событий. И попробуйте ускорить процесс
Что сделать. Чаще всего время завершения тестирования просто неизвестно (например, анимация задерживает появление элементов). В таких случаях избегайте засыпания (например, setTimeOut
) и применяйте более детерминистские методы, предоставляемые большинством платформ. Некоторые библиотеки позволяют ожидать выполнения операций (например, Cypress cy.request('url')), другие предоставляют для ожидания API, вроде метода wait(expect(element)) из @testing-library/DOM. Иногда лучше сделать заглушки для медленных ресурсов, вроде того же API, а когда момент отклика становится детерминистским, компонент можно явно перерисовать. Если вы зависите от внешнего компонента, который может заснуть, то обратите внимание на hurry-up the clock. Сон — это паттерн, которого нужно избегать, поскольку он замедляет ваши тесты или делает их сомнительными (когда нужно ожидать завершения слишком короткого периода). Если же не получается избежать засыпания и поллинга, а помощи от тестового фреймворка не получить, то могут помочь какие-нибудь npm-библиотеки с полудетерминистским решением, например, wait-for-expect.
В противном случае. Если сон долгий, тесты замедлятся на порядок. При попытке заснуть на короткий срок тест не будет пройден, если тестируемый модуль не отреагировал своевременно. Так что всё сводится к компромиссу между хрупкостью и плохой производительностью.
// using Cypress
cy.get('#show-products').click()// navigate
cy.wait('@products')// wait for route to appear
// this line will get executed only when the route is ready
Как делать правильно. Тестовая библиотека, ожидающая DOM-элементы (@testing-library/dom).
// @testing-library/dom
test('movie title appears', async () => {
// element is initially not present...
// wait for appearance
await wait(() => {
expect(getByText('the lion king')).toBeInTheDocument()
})
// wait for appearance and return the element
const movie = await waitForElement(() => getByText('the lion king'))
})
Пример антипаттерна. Кастомный код спящего режима.
test('movie title appears', async () => {
// element is initially not present...
// custom wait logic (caution: simplistic, no timeout)
const interval = setInterval(() => {
const found = getByText('the lion king');
if(found){
clearInterval(interval);
expect(getByText('the lion king')).toBeInTheDocument();
}
}, 100);
// wait for appearance and return the element
const movie = await waitForElement(() => getByText('the lion king'))
})
3.5. Отслеживайте передачу данных по сети
Что сделать. С помощью какого-нибудь активного мониторинга отслеживайте, чтобы загрузка страницы в реальной сети была оптимизирована. Сюда относятся любые задачи, связанные с пользовательским опытом, вроде медленной загрузки или неминифицированного бандла. На рынке есть достаточно подходящих инструментов: базовые средства вроде pingdom, AWS CloudWatch или gcp StackDriver можно легко сконфигурировать так, чтобы следить, жив ли сервер и отвечает ли с приемлемым уровнем SLA. Но так вы получите очень мало информации о возникающих проблемах, так что желательно найти инструменты, предназначенные для фронтенда (например, lighthouse, pagespeed), и анализировать подробнее. Сосредоточьтесь на симптомах — метриках, которые напрямую влияют на пользовательский опыт: длительность загрузки страницы и отрисовки минимального необходимых данных, а также насколько быстро страница становится интерактивной (TTI). Кроме того, вы можете захотеть отслеживать технические причины, вроде компрессии данных, времени до получения первого байта, оптимизации изображений, приемлемости размера DOM, SSL и много другого. Рекомендую применять обширный мониторинг при разработке, как часть CI, так и в режиме 24х7 на боевых серверах и CDN.
В противном случае. Вы будете разочарованы, когда поймёте, что после всех усилий по разработке интерфейса, полного прохождения функциональных тестов и сложной упаковки, пользовательский опыт оказался ужасным и медленным из-за проблем с конфигурированием CDN.
3.6 Делайте заглушки вместо неконтролируемых и медленных ресурсов вроде API бэкенда
Что сделать. Когда пишете основные тесты (не Е2Е), избегайте использования любых ресурсов, за которые вы не отвечаете или которые не контролируете, а вместо них применяйте заглушки (то есть имитируйте). Вместо реальных сетевых обращений к API используйте имитационную библиотеку (например, Sinon, Test doubles), чтобы имитировать ответы API. Главное преимущество этого подхода заключается в избавлении от неконтролируемости. Тестирование или стейджинг API само по себе не слишком стабильно, иногда ваши тесты будут сбоить при идеальном поведении ВАШИХ компонентов (эксплуатационная среда не предназначена для тестирования и обычно троттлит запросы). Это позволит симулировать разное поведение API, от которого зависит поведение компонента. Например, когда нужные данные не обнаружен, или когда API выдаёт ошибку. И последнее, но не менее важное: сетевые вызовы значительно замедляют тесты.
В противном случае. Среднестатистический тест исполняется не дольше нескольких миллисекунд, типичный вызов API длится от 100 мс, то есть примерно в 20 раз дольше.
// unit under test
export default function ProductsList() {
const [products, setProducts] = useState(false)
const fetchProducts = async() => {
const products = await axios.get('api/products')
setProducts(products);
}
useEffect(() => {
fetchProducts();
}, []);
return products ? <div>{products}</div> : <div data-testid='no-products-message'>No products</div>
}
// test
test('When no products exist, show the appropriate message', () => {
// Arrange
nock("api")
.get(`/products`)
.reply(404);
// Act
const {getByTestId} = render(<ProductsList/>);
// Assert
expect(getByTestId('no-products-message')).toBeTruthy();
});
3.7 Используйте очень мало сквозных тестов, охватывающих всю систему
Что сделать. Хотя E2E (end-to-end, сквозное тестирование) обычно подразумевает лишь тестирование через UI в браузере (см. главу 3.6). В остальных случаях речь идёт о тестах, которые охватывают всю систему, включая бэкенд. Тесты второго вида очень важны, поскольку они покрывают баги интеграции между фронтендом и бэкендом, которые могут возникать из-за неправильного понимания схемы обмена данными. Кроме того, такие тесты — эффективный способ определения интеграционных проблем между бэкендами (например, микросервис А отправляет неправильное сообщение в микросервис Б), и даже для выявления сбоев развёртывания. Не существует бэкенд-фреймворков для сквозного тестирования, которые столь же удобны и развиты, как UI-фреймворки наподобие Cypress и Pupeteer. Недостатком таких тестов является высокая стоимость конфигурирования среды со множеством компонентов, а также их хрупкость: если у вас 50 микросервисов, один из которых сбоит, то будет провален и весь сквозной тест. Поэтому не стоит увлекаться этой методикой и применять не больше 10 таких тестов. Тем не менее, даже небольшое количество сквозных тестов наверняка позволят выявить проблемы, для которых они предназначены, — ошибки деплоя и интеграции. Желательно прогонять тесты стейджинговой среде, похожей на эксплуатационную.
В противном случае. UI может вложить много сил в тестирование своей функциональности, пока слишком поздно не выяснится, что возвращаемая бэкендом полезная нагрузка (схема данных, с которой должен работать UI) сильно отличается от ожидаемой.
3.8 Ускорьте сквозные тесты за счёт повторного использования учётных данных для входа
Что сделать. В сквозных тестах, охватывающих бэкенд и использующих для вызовов API валидные пользовательские токены, не стоит изолировать тест уровнем, на котором при каждом запросе пользователь создаётся и входит в систему. Вместо этого зайдите только один раз перед запуском теста (before-all), сохраните токен в каком-нибудь локальном хранилище и используйте его для последующих запросов. На первый взгляд, это нарушает один из ключевых принципов тестирования: сохранять автономность тестов без привязки к ресурсам. И хотя это обоснованное опасение, главной проблемой сквозных тестов является производительность. Создание одного-трёх API-запросов перед началом каждого теста может катастрофически увеличить длительность исполнения. Повторное использование учётных данных не означает, что должны работать с одной и той же пользовательской записью. Если полагаться на пользовательские записи (например, при тестировании истории платежей), то удостоверьтесь, что генерирование записей является частью теста, и не делитесь ими с другими тестами. Также помните, что можно имитировать бэкенд: если ваши тесты предназначены для фронтенда, то лучше изолировать их и имитировать API бэкенда (см. главу 3.6).
В противном случае. Предположим, у вас 200 тестовых сценариев, и процедура входа занимает 100 мс, тогда мы раз за разом будем тратить по 20 секунд только на вход.
Пример с использованием Cypress.
let authenticationToken;
// happens before ALL tests run
before(() => {
cy.request('POST', 'http://localhost:3000/login', {
username: Cypress.env('username'),
password: Cypress.env('password'),
})
.its('body')
.then((responseFromLogin) => {
authenticationToken = responseFromLogin.token;
})
})
// happens before EACH test
beforeEach(setUser => () {
cy.visit('/home', {
onBeforeLoad (win) {
win.localStorage.setItem('token', JSON.stringify(authenticationToken))
},
})
})
3.9 Используйте один сквозной smoke-тест, который просто проходит по схеме сайта
Что сделать. Для production-мониторинга и проверки работоспособности в ходе разработки применяйте один сквозной тест, который проходит по всем или большинству страниц и убеждается, что все они работают. Подобный тест очень полезен, поскольку его очень легко написать и сопровождать, при этом он способен определять любые сбои, включая функциональные и сетевые, а также проблемы при деплое. Другие виды smoke-тестирования и проверки работоспособности не такие надёжные и исчерпывающие. Некоторые эксплуатационные команды просто пингуют главные страницы в production, а разработчики, запускающие много интеграционных тестов, не обнаруживают проблемы с пакетами и браузерами. Разумеется, smoke-тест не заменяет функциональные тесты, он служит лишь быстрым детектором дыма.
В противном случае. Всё может выглядеть идеально, все тесты проходятся, проверка состояния в production тоже положительная. А у платёжного компонента проблема с пакетом и не отрисовывается только маршрут /Payment.
it('When doing smoke testing over all page, should load them all successfully', () => {
// exemplified using Cypress but can be implemented easily
// using any E2E suite
cy.visit('https://mysite.com/home');
cy.contains('Home');
cy.contains('https://mysite.com/Login');
cy.contains('Login');
cy.contains('https://mysite.com/About');
cy.contains('About');
})
3.10 Откройте тесты в виде совместного живого документа
Что сделать. Кроме повышения надёжности приложения, тесты играют роль живой документации. Поскольку тесты намеренно «говорят» на менее техническом, более близком к продукту и пользовательскому опыту языке, то, применяя правильные инструменты, тесты можно использовать как средство связи между разработчиками и заказчиками. Например, некоторые фреймворки позволяют описывать процесс и ожидания (то есть план тестирования) на удобочитаемом языке, так что все заинтересованные участники, включая продакт-менеджеров, могут читать, одобрять и совместно работать над тестами, которые превращаются в живые техзадания. Эта методика известна под названием «приёмочного тестирования», поскольку позволяет заказчикам определять критерии соответствия на обычном языке. Тестирование на основе поведения в чистейшем виде. Применять его позволяет один из популярных фреймворков, Cucumber со вкусом JavaScript. А StoryBook позволяет представлять UI-компоненты в виде графического каталога, в котором вы можете проходит по разным состояниям каждого компонента (например, отобразить сетку с фильтрами и без, отобразить сетку с несколькими строками или без них, и т.д.) и понять, как инициировать эти состояния. Это также может быть интересно продуктологам, но в основном фреймворк служит в качестве живой документации для разработчиков, использующих эти компоненты.
В противном случае. Вложив кучу сил в тестирование, просто стыдно не получить в ответ большую отдачу.
// this is how one can describe tests using cucumber: plain language that allows anyone to understand and collaborate
Feature: Twitter new tweet
I want to tweet something in Twitter
@focus
Scenario: Tweeting from the home page
Given I open Twitter home
Given I click on "New tweet" button
Given I type "Hello followers!" in the textbox
Given I click on "Submit" button
Then I see message "Tweet saved"
Как делать правильно. С помощью Storybook визуализируйте компоненты, их разные состояния и входные данные.
3.11 Определяйте визуальные проблемы с помощью автоматизированных инструментов
Что сделать. Настройте автоматизированные инструменты, чтобы делать скриншоты при каждом изменении пользовательского интерфейса и определять визуальные проблемы наподобие наложения или смещения содержимого. Так вы не просто подготовите нужные данные, а ещё хорошо их отобразите. Такая методика применяется нечасто, мы склонны к функциональным тестам. Но пользователи оценивают продукт в первую очередь визуально и на множестве устройств, поэтому очень легко пропустить какой-нибудь некрасивый баг в интерфейсе. Некоторые бесплатные инструменты позволяют просто генерировать и сохранять скриншоты, чтобы потом их просмотрел человек. Для маленьких приложений этого вполне достаточно, но, как и любое другое ручное тестирование, требует вмешательства человека при каждом изменении проекта. С другой стороны, довольно сложно автоматически определять проблемы с интерфейсом из-за отсутствия чёткого определения. Решить задачу сравнения старой версии UI с последними изменениями и поиском различий помогает «визуальная регрессия». Такой возможностью обладают некоторые инструменты, бесплатные или с открытым кодом (например, wraith, PhantomCSS), но для их настройки может понадобиться много времени. Коммерческие инструменты (например, Applitools, Perci.io) настраиваются удобнее и обладают более широкими возможностями по управлению интерфейсом, оповещениями, умным захватом с подавлением «визуального шума» (баннеров, анимации), и даже анализом изменений в DOM/CSS, которые привели к проблемам.
В противном случае. Насколько хороша страница, отображающая отличный контент (и прошедшая все тесты) и мгновенно загружающаяся, но при этом половина контентной области скрыта?
Как делать правильно. Сконфигурируйте wraith для захвата и сравнения снимков UI.
# Add as many domains as necessary. Key will act as a label
domains:
english: "http://www.mysite.com"
# Type screen widths below, here are a couple of examples
screen_widths:
- 600
- 768
- 1024
- 1280
# Type page URL paths below, here are a couple of examples
paths:
about:
path: /about
selector: '.about'
subscribe:
selector: '.subscribe'
path: /subscribe
Раздел 4: Оценка эффективности тестов
4.1 Обеспечьте достаточное покрытие (~80 %), чтобы быть уверенным в качестве
Что сделать. Цель тестирования — обрести достаточно уверенности, чтобы разрабатывать быстро. Чем больше кода протестировано, тем увереннее чувствует себя команда. Покрытие — это количество строк кода (ветвей, выражений и так далее), доступных тестам. Какое покрытие считается достаточным? Очевидно, что 10-30 % слишком мало для получения корректности сборки. А 100 % слишком дорого, да к тому же может сместить ваше внимание с критических частей кода на его экзотические участки. Достаточность покрытия зависит от многих факторов. Например, от типа приложения: если вы создаёте ПО для нового поколения авиалайнеров Airbus, то должно быть стопроцентное покрытие; если для сайта мультипликационной компании, то и 50 % будет слишком много. Хотя большинство энтузиастов тестирования считают, что необходимый рубеж покрытия зависит от контекста, многие из них называют эмпирическое число 80 % (Fowler: «in the upper 80s or 90s»), которое, скорее всего, подойдёт для большинства приложений.
Советы: вы можете настроить непрерывную интеграцию (CI), чтобы достигать порога покрытия (Jest) и прекращать работу над сборкой, не удовлетворяющей этому стандарту. Можно даже задать пороги для каждого компонента, как показано ниже. Кроме того, подумайте об определении снижения покрытия сборки (когда у свежего кода покрытие оказывается ниже) — это заставит разработчиков повысить или хотя бы сохранить количество протестированного кода. В общем, покрытие — лишь численная метрика, и её одной недостаточно для того, чтобы оценить надёжность вашего тестирования. Эта метрика может вводить вас в заблуждение, как я покажу ниже.
В противном случае. Уверенность и числа идут рука об руку. Если вы не уверены, что протестировали большую часть системы, то у вас останутся опасения. А опасения замедляют вашу работу.
Как делать правильно. Задайте порог покрытия для каждого компонента (с помощью Jest).
4.2 Анализируйте отчёты о покрытии, чтобы выявлять непротестированные области и другие странности
Что сделать. Некоторые проблемы остаются незамеченными, их трудно выявить с помощью традиционных инструментов. Это не баги, а, скорее, необычное поведение приложения, которое может оказывать нежелательное влияние. Например, часто бывает так, что какие-то области кода никогда не вызываются, или вызываются редко. Вы думали что класс PricingCalculator
всегда задаёт цену товара, но выясняется, что он вообще не вызывается, когда в базе данных 10 000 товаров и многочисленные продажи… Отчёты о покрытии помогут понять, ведёт ли себя приложение так, как задумано. Кроме того, отчёты помогают выявить непротестированные участки кода. Знание о 80-процентном покрытии не говорит о том, покрыты ли критические участки. Сгенерировать отчёт легко: просто запустите приложение с отслеживанием тестирования в эксплуатационной среде или при прогоне тестов, и наслаждайтесь разноцветными отчётами, которые покажут, насколько часто вызывался каждый участок кода. И если потратите время на анализ, то можете найти что-нибудь интересное.
В противном случае. Если вы не знаете, какие части кода остались непротестированными, то вы не знаете, где могут возникнуть проблемы.
4.3 Измерение логического покрытия с помощью мутационного тестирования
Что сделать. Традиционная метрика покрытия часто врёт: она может показать вам покрытие в 100 %, но ни одна функция не даёт правильного ответа. Как же так? Просто метрика измеряет количество строк кода, посещённых тестом, но не проверяет, действительно ли они протестированы. То есть метрика не проверяет точность ответа. Как если бы кто-то поехал в командировку и по возвращении показывал штампы в паспорте: они доказывают не то, что задание было выполнено, а лишь то, что человек посетил несколько аэропортов и отелей.
Мутационное тестирование помогает оценить количество действительно ПРОТЕСТИРОВАННОГО кода, а не просто ПОСЕЩЁННОГО. Для этого вам пригодится очень удобно реализованная JavaScript-библиотека Stryker:
- Она намеренно меняет код и «вставляет баги». Например, код
newOrder.price===0
превращается вnewOrder.price!=0
. Такие «баги» называются мутациями. - Затем библиотека запускает тесты, и если те проходят успешно, то у вас проблема: тесты не выполняют функцию обнаружения багов, мутации выжили. Если же тесты сбоят, то всё отлично, мутации были уничтожены.
Если вы знаете, что все мутации были уничтожены, то будете более уверены в традиционном покрытии, а времени потратите столько же.
В противном случае. Вы будете заблуждаться, что 85-процентное покрытие означает выявление тестами 85 % багов в коде.
function addNewOrder(newOrder) {
logger.log(`Adding new order ${newOrder}`);
DB.save(newOrder);
Mailer.sendMail(newOrder.assignee, `A new order was places ${newOrder}`);
return {approved: true};
}
it("Test addNewOrder, don't use such test names", () => {
addNewOrder({asignee: "John@mailer.com",price: 120});
});//Triggers 100% code coverage, but it doesn't check anything
Как делать правильно. Stryker reports, инструмент мутационного тестирования, определяет и подсчитывает количество непротестированного кода (мутаций).
4.4 Предотвращение проблем тестирования с помощью тест-линтеров
Что сделать. Для анализа шаблонов тестирования кода и выявления проблем был создан набор плагинов ESLint. К примеру, eslint-plugin-mocha предупредит вас, если тест оперирует на глобальном уровне (не является дочерним элементом выражения describe()
), или если тесты пропустили то, что может привести к ложной уверенности в успешном прохождении всех тестов. А eslint-plugin-jest может предупредить, если тесты вообще ничего не подтвердили (то есть ничего не проверили).
В противном случае. Глядя на 90-процентное покрытие и все зелёные тесты, вы будете широко улыбаться, пока не поймёте, что многие тесты ничего не подтверждают или вообще были пропущены. Надеюсь, вы ничего не отправили в эксплуатацию после такого ложного тестирования.
describe("Too short description", () => {
const userToken = userService.getDefaultToken() // *error:no-setup-in-describe, use hooks (sparingly) instead
it("Some description", () => {});//* error: valid-test-description. Must include the word "Should" + at least 5 words
});
it.skip("Test name", () => {// *error:no-skipped-tests, error:error:no-global-tests. Put tests only under describe or suite
expect("somevalue"); // error:no-assert
});
it("Test name", () => {*//error:no-identical-title. Assign unique titles to tests
});
Раздел 5: CI и другие оценки качества
5.1 Расширьте набор линтеров и исключите сборки, у которы проблемы с линтингом
Что сделать. Линтеры — это бесплатный обед. Через пять минут настройки вы получаете бесплатный автопилот, охраняющий ваш код и определяющий существенные проблемы по мере ввода кода. Прошли времена, когда линтинг был своего рода косметическим улучшением (не точка с запятой!). Сегодня линтеры могут вылавливать ошибки, которые не были корректно выданы системой с потерей информации. К своему базовому набору правил (вроде ESLint standard или стиля Airbnb), вы можете добавить специализированные линтеры. Например, eslint-plugin-chai-expect способен определять тесты, которые ничего не проверяют. Eslint-plugin-promise может определять неразрешённые промисы (код не будет продолжать исполняться). Eslint-plugin-security может определять жадные регулярные выражения, которые могут использоваться в DOS-атаках. А eslint-plugin-you-dont-need-lodash-underscore способен предупредить, если код использует методы из библиотеки, которые являются частью основных методов V8, например, Lodash._map(…)
.
В противном случае. Представьте себе дождливый день, в который падают ваши боевые серверы, но в логах нет ошибок. Что происходит? А просто ваш код кинул объекты, не являющиеся ошибками, и трассировка стека была потеряна. Хороший повод подолбиться головой о стену. А если бы вы потратили пять минут на настройку линтера, который определяет такие опечатки, то сэкономили бы себе целый день.
5.2 Укоротите цикл обратной связи с помощью локальной непрерывной интеграции для разработчиков
Что сделать. Вы используете CI в сочетании с тщательным анализом качества в виде тестирования, линтинга, проверки уязвимости и т.д.? Помогите разработчикам запускать этот конвейер локально, чтобы сразу получать обратную связь и укоротить её цикл. Зачем? Эффективный процесс тестирования состоит из множества итеративных циклов: (1) попытка -> (2) обратная связь -> (3) рефакторинг. Чем быстрее обратная связь, тем больше итераций улучшения в каждом модуле может сделать разработчик, и тем лучше будет результат.
Если обратная связь приходит достаточно поздно и за день можно сделать меньше улучшений, то команда может перейти к другой теме, задаче или модулю, и может уже не быть готова к доработке модуля, по которому наконец-то пришла обратная связь.
Некоторые CI-вендоры (к примеру, CircleCI local CLI) позволяют запускать вышеописанный конвейер локально. Некоторые коммерческие инструменты, вроде wallaby, предоставляют в виде прототип разработки (безо всякой аффилированности) ценные данные о тестировании. Или можете добавить npm-скрипт в package.json, который исполняет все команды, относящиеся к проверке качества (например, тесты, линты, уязвимости). Для распараллеливания и ненулевого выходного кода (non-zero exit code) в случае сбоя одного из инструментов можете использовать средство наподобие concurrently. Теперь разработчику для получения немедленной обратной связи достаточно просто вызвать одну команду — скажем, npm run quality
. А на случай сбоя проверки качества подумайте об отмене коммита с помощью githook (husky поможет).
В противном случае. Когда результаты проверки качества приходят на следующий день после получения кода, тестирование перестаёт быть органичной частью процесс разработки и превращается в формальность.
"scripts": {
"inspect:sanity-testing": "mocha **/**--test.js --grep "sanity"",
"inspect:lint": "eslint .",
"inspect:vulnerabilities": "npm audit",
"inspect:license": "license-checker --failOn GPLv2",
"inspect:complexity": "plato .",
"inspect:all": "concurrently -c "bgBlue.bold,bgMagenta.bold,yellow" "npm:inspect:quick-testing" "npm:inspect:lint" "npm:inspect:vulnerabilities" "npm:inspect:license""
},
"husky": {
"hooks": {
"precommit": "npm run inspect:all",
"prepush": "npm run inspect:all"
}
}
5.3 Выполняйте сквозное тестирование на production-зеркале
Что сделать. Сквозное тестирование — это главная трудность любого CI-конвейера. Создать на лету идентичное кратковременное зеркало со всеми сопутствующими облачными сервисами может быть утомительно и дорого. Наилучший компромисс — Docker-compose позволяет с помощью простого текстового создать изолированную среду с одинаковыми контейнерами. При этом серверная технология (сеть, модель развёртывания) будет отличаться от настоящей production-среды. Можете добавить AWS Local и работать с заглушками настоящих AWS-сервисов. Если вы запустите во внесерверном режиме несколько фреймворков, то с помощью serverless и AWS SAM сможете локально вызывать Faas-код.
Огромной экосистеме Kubernetes ещё предстоит выбрать стандартный удобный инструмент для создания локальных и CI-зеркал, хотя на рынке уже представлено много новых решений. Например, можно запускать «минимизированный Kubernetes» с помощью инструментов вроде Minikube или MicroK8s, которые похожи на настоящие, только требуют меньше накладных расходов. Или можно тестировать с помощью удалённого «настоящего Kubernetes»: у некоторых CI-провайдеров (например, Codefresh) есть нативная интеграция с Kubernetes-средой, что позволит легко запускать CI-конвейер на реальных системах; а другие провайдеры позволяют запускать в удалённой среде свои скрипты.
В противном случае. Использование разных технологий в проде и тестировании требует поддержки двух моделей развёртывания и приводит к разделению команд разработки и эксплуатации.
deploy:
stage: deploy
image: registry.gitlab.com/gitlab-examples/kubernetes-deploy
script:
- ./configureCluster.sh $KUBE_CA_PEM_FILE $KUBE_URL $KUBE_TOKEN
- kubectl create ns $NAMESPACE
- kubectl create secret -n $NAMESPACE docker-registry gitlab-registry --docker-server="$CI_REGISTRY" --docker-username="$CI_REGISTRY_USER" --docker-password="$CI_REGISTRY_PASSWORD" --docker-email="$GITLAB_USER_EMAIL"
- mkdir .generated
- echo "$CI_BUILD_REF_NAME-$CI_BUILD_REF"
- sed -e "s/TAG/$CI_BUILD_REF_NAME-$CI_BUILD_REF/g" templates/deals.yaml | tee ".generated/deals.yaml"
- kubectl apply --namespace $NAMESPACE -f .generated/deals.yaml
- kubectl apply --namespace $NAMESPACE -f templates/my-sock-shop.yaml
environment:
name: test-for-ci
5.4 Распараллеливайте исполнение тестов
Что сделать. Если всё сделано правильно, то тестирование станет вашим круглосуточным другом, предоставляя практически мгновенную обратную связь. На практике, исполнение в одном потоке 500 модульных тестов, ограниченных по процессору, может занять слишком много времени. К счастью, современные средства запуска тестов и CI-платформы (вроде Jest, AVA и расширений Mocha) могут распараллеливать тесты по многочисленным процессам, значительно ускоряя получение обратной связи. Некоторые CI-вендоры тоже распараллеливают тесты по контейнерам (!), что ещё больше укорачивает цикл обратной связи. Не важно, распараллеливаете вы локально по многочисленным процессам или в облачном CLI с многочисленными машинами, это требует автономности тестов, поскольку все они могут исполняться в разных процессах.
В противном случае. Отличный способ уменьшения релевантности тестирования — получение результатов тестирования через час после отправки нового кода, когда вы уже начали программировать следующие фичи.
5.5 Избегайте юридических проблем с помощью проверки на необходимость лицензирования и наличие плагиата
Что сделать. Вероятно, для вас сейчас не слишком актуальн проблемы с лицензированием и плагиатом. Но почему бы не закрыть этот вопрос за 10 минут? Вы легко можете встроить в свой CI-контейнер кучу npm-пакетов вроде license check и plagiarism check (коммерческий с бесплатным планом), чтобы анализировать на наличие зависимостей с ограничительными лицензиями, или на наличие кода, содранного со Stackoveflow и явно нарушающего авторские права.
В противном случае. К сожалению, разработчики могут использовать пакеты с неподходящими лицензиями, или могут копипастить коммерческий код.
//install license-checker in your CI environment or also locally
npm install -g license-checker
//ask it to scan all licenses and fail with exit code other than 0 if it found unauthorized license. The CI system should catch this failure and stop the build
license-checker --summary --failOn BSD
5.6 Постоянно отслеживайте на наличие зависимостей с уязвимостями
Что сделать. Даже самые уважаемые зависимости, вроде Express, имеют известные уязвимости. С этим легко можно справиться благодаря созданным сообществом инструментам вроде npm audit, либо с помощью коммерческих инструментов вроде snyk (есть также бесплатная версия от сообщества). Вы можете вызывать их из своего CI при каждой сборке.
В противном случае. Поддержка чистоты кода без использования специализированных инструментов потребует постоянного отслеживания публикаций о новых угрозах. Это весьма утомительно.
5.7 Автоматизируйте обновление зависимостей
Что сделать. С package-lock.json в Yarn и npm связаны большие затруднения (дорога в ад выложена благими намерениями): по умолчанию пакеты больше не получают обновления. Даже если команда запускает много свежих развёртываний с помощью команд npm install
и npm update
, обновлений всё равно не будет. В лучшем случае у вас будут неактуальные версии пакетов, а в худшем — уязвимости в коде. Теперь команды полагаются на добрую волю и память разработчиков, которые вручную обновляют package.json или запускают инструменты вроде ncu.
Лучше автоматизировать получение самых надёжных версий зависимостей, хотя идеального решения нет. Возможных подходов два:
- CI может отменить сборки, имеющие абсолютные зависимости, с помощью инструментов вроде npm outdated или npm-check-updates (ncu). Это заставит разработчиков обновить зависимости.
- Можно использовать коммерческие инструменты, которые сканируют код автоматически отправляют pull request’ы с обновлёнными зависимостями.
Но остаётся открытым вопрос: какой должна быть политика обновления зависимостей? Обновлять их при каждом патче слишком накладно, а обновление с основными релизами может порождать нестабильные версии (во многих пакетах зависимости находят в самые первые дни после релиза, как это видно на примере инцидента с eslint-scope). Эффективная политика обновления может потребовать внедрения «переходного периода»: пусть код на несколько версий отстаёт от latest, прежде чем вы решите, что локальная копия устарела (например, локальная версия 1.3.1, а версия в репозитории — 1.3.8).
В противном случае. В проде будут крутиться пакеты, явно помеченные авторами как рискованные.
5.8 Прочие CI-советы, не относящиеся к Node
Что сделать. Эта статья посвящена советам по тестированию, связанным с Node или проиллюстрированным с его помощью. А в этой главе я приведу несколько известных советов, не относящихся к Node.
- Пользуйтесь декларативным синтаксисом. Для большинства вендоров это единственная опция, но старые версии Jenkins позволяют использовать код или интерфейс.
- Выбирайте вендора с нативной поддержкой Docker.
- Чем раньше будут сбоить тесты, тем лучше. Запускайте сначала самые быстрые тесты. Создайте группу smoke-тестирования, в которой объедините различные быстрые проверки (например, линтинг, модульные тесты) и стремительно предоставляйте автору кода обратную связь.
- Сделайте так, чтобы было легко просматривать все артефакты сборки, включая отчёты о тестировании, отчёты о покрытии, отчёты о мутация, логи и так далее.
- Для каждого события создавайте многочисленные конвейеры и задачи, повторно используя в них различные этапы. Например, сконфигурируйте задачу для коммита в ветку фичи и ещё одну для мастер-ветки. И в каждой из них повторно используйте универсальные этапы с какой-то логикой (у большинства вендоров для этого есть свои механизмы).
- Никогда не добавляйте чувствительные данные в описание задачи. Берите их из хранилища или из конфигурации задачи.
- Явным образом меняйте версию в сборке, или хотя бы убедитесь, что это сделал разработчик.
- Собирайте только один раз и выполняйте все проверки одного артефакта сборки (например, Docker-образа).
- Тестируйте в кратковременной среде, состояние которой между сборками не меняется. Единственным исключением может быть кэширование
node_modules
.
В противном случае. Вы лишите себя мудрости, накопленную поколениями программистов.
5.9 Матрица сборки: исполняйте одни и те же CI-этапы, используя разные версии Node
Что сделать. Проверка качества требует интуиции, чем больше вы этим занимаетесь, тем успешнее заранее выявляется проблемы. При разработке многоразовых пакетов или запуске в эксплуатацию многопользовательских продуктов с разными конфигурациями и версиями Node, в рамках CI нужно прогонять конвейер тестов по всем вариациям конфигураций. Допустим, для одних пользователей мы применяем MySQL, а для других Postgres. Некоторые CI-вендоры поддерживают функцию под названием «матрица», позволяющую запускать набор тестов для всех вариаций MySQl, Postgres и разных версий Node. Делается это только с помощью конфигурации, без каких-либо дополнительных усилий (учитывая, что вы проводите тестирование или другие проверки качества). А те CI, что не поддерживают матрицу, могут иметь соответствующие расширения или доработки.
В противном случае. Неужели после всех трудов по написанию тестов мы позволим ошибкам пройти незамеченными только из-за проблем с конфигурацией?
language: node_js
node_js:
- "7"
- "6"
- "5"
- "4"
install:
- npm install
script:
- npm run test
Автор: Макс