- Зачем тестировать приложение
- Тестирование и его разновидности
- Подходы к написанию тестов
- Тестовые объекты
- Паттерны тестирования
- Качество тестов
У каждого программиста (неважно, профессионал он или любитель) бывают случаи, когда его приложение ломается. Очень здорово, когда проблема ему хорошо знакома и решить её можно достаточно быстро, но иногда даже маленькое, незаметное изменение в коде может привести к часам, а иногда и дням безудержного дебага.
В такие моменты хочется знать, какие части приложения точно работают, а какие нет. Существует множество путей решения этой проблемы.
Например, системы контроля версий (такие, как Git) позволяют просмотреть лишь те изменения, которые были внесены после выхода в свет последней рабочей версии приложения. Это позволяет не проверять всё приложение целиком и искать ошибку среди последних изменений.
Если же система контроля версий не используется или изменений после последней рабочей версии было слишком много, то найти ошибку всё ещё затруднительно.
На помощь приходит тестирование.
Качественное тестирование очень важно для приложения. Хороший тестировщик продумывает все возможные и невозможные исходы и проверяет каждый из них. Без такого тестирования невозможно гарантировать корректность работы приложения, а значит оно может сломаться в любом месте и в любой момент.
В идеале это работает так, что некоторый готовый блок кода проверяется тестами и в случае успешного прохождения он больше не трогается. На практике же часто приходится затрагивать один и тот же код, при этом именно тесты могут показать, что ничего не сломалось после внесения изменений.
Чем больше становится приложение и команда, которая над ним работает, тем больше вероятность возникновения ошибок и сложность их исправления. Тесты помогут избежать попадения этих ошибок в production, а также локализовать эти ошибки, что явно поможет их устранить.
Ещё одно применение тестов: описание требований к приложению. Заказчик описывает, чего он ожидает от некоторой функциональности, программист запускает тесты ( тестировщик тестирует готовый продукт) и таким образом показывает, что все требования заказчика были соблюдены.
- Ручное и автоматизированное тестирование
- Уровни тестирования
- Тестирование чёрного и белого ящиков
- Функциональное и нефункциональное тестирование
- Регрессионное тестирование
- Статическое тестирование
- Контрактное тестирование
- Пирамида тестирования
Обеспечение качества (англ. Quality assurance, QA) — способ предотвращения ошибок и дефектов в производимых продуктах, а также предотвращения проблем при доставке продуктов или услуг клиенту.
Тестируемая система (англ. System under test, SUT) — система, которая тестируется на корректность работы.
Виды тестирования делят по разным признакам: кто проверяет (человек или код), насколько крупную часть системы охватывает тест (уровни), знает ли тестировщик устройство кода (чёрный и белый ящик), что именно проверяется (функции или свойства — скорость, надёжность). Один и тот же тест попадает сразу в несколько категорий: например, автоматизированный модульный функциональный тест белого ящика.
Ручное тестирование — прямое взаимодействие QA-инженера с приложением.
QA ищет неисправности и недостатки, а затем информирует о них программиста.
- Отчёт тестировщика — первый отзыв потенциального пользователя, позволяющий увидеть приложение его глазами.
- Обратная связь (англ.
feedback) по UI. Протестировать UI приложения и выявить его недостатки пока позволяет только ручное тестирование. - Отсутствие затрат на написание тестов. Это важно при быстром внедрении новой функциональности. На малых проектах написание и поддержка тестов могут оказаться достаточно затратными.
- Человеческий фактор. Часть ошибок может упускаться, некоторые результаты субъективны.
- Трудозатраты и продолжительность. Ручное тестирование обычно занимает много времени. Часто требуется тесное взаимодействие QA с программистом во время тестирования для разъяснения многих вопросов.
- Отсутствие возможности моделирования большой нагрузки. При ручном тестировании невозможно смоделировать большое количество пользователей.
Автоматизированное тестирование — написание кода для тестов.
Ожидаемый сценарий описывается кодом, затем при запуске тестов он сравнивается с реальным и программа указывает расхождения.
- Отсутствие человеческого фактора. Хорошо написанный тест не может забыть что-то проверить, он каждый раз последовательно проверяет действия.
- Скорость выполнения. Код для сценария пишется один раз, а его запуск обычно занимает несколько секунд.
- Переиспользуемость. Код автотестов может быть использован неоднократно, особенно при внедрении новой функциональности.
- Возможность моделирования большой нагрузки. Приближает тесты к реальной ситуации использования приложения.
- Отсутствие тестирования глазами пользователя (UX). Не всё можно покрыть автотестами.
- Отсутствие обратной связи. Автоматизированные тесты не предоставляют обратную связь о качестве продукта, они лишь выполняют запрограммированные сценарии.
- Затраты на написание тестов и их поддержку. Автоматизированные тесты требуют написания большого количества кода. При изменении тестируемых частей приложения, нужно изменять и их тесты (иначе они станут бесполезными). Это может быть губительно для маленьких проектов и проектов, где всё меняется довольно быстро и непредсказуемо.
Уровни различаются тем, насколько большую часть системы проверяет один тест:
- модульное (компонентное) (англ.
unit,component testing) — одна функция, класс или модуль; - интеграционное (англ.
integration testing) — несколько частей вместе или код вместе с настоящей базой, очередью, внешним сервисом; - системное (англ.
system testing) — вся система целиком, как её видит пользователь; - приёмочное (англ.
acceptance testing) — соответствует ли система требованиям заказчика.
Примеры ниже написаны на встроенном в Node.js модуле node:test (документация): каждый test — один тест, а assert сравнивает ожидаемое с полученным.
Модульный тест (англ. unit test) проверяет маленькую часть кода в отрыве от остальной системы. Мартин Фаулер выделяет три черты: такие тесты пишут сами программисты, они проверяют небольшую часть программы и работают намного быстрее остальных (UnitTest).
Тест бывает одиночным (англ. solitary), когда соседей тестируемого модуля заменяют тестовыми объектами, и общительным (англ. sociable), когда модуль работает с настоящими соседями. Термины придумал Джей Филдс (Фаулер).
import { test } from 'node:test';
import assert from 'node:assert/strict';
/* проверяемый модуль: цена со скидкой */
const priceWithDiscount = (price: number, percent: number): number => {
if (percent < 0 || percent > 100) throw new RangeError('скидка от 0 до 100 %');
return Math.round(price * (100 - percent)) / 100;
};
test('скидка 10 % от 200 — это 180', () => {
assert.equal(priceWithDiscount(200, 10), 180);
});
test('скидка больше 100 % — ошибка', () => {
assert.throws(() => priceWithDiscount(200, 150), RangeError);
});
/* ✔ скидка 10 % от 200 — это 180
✔ скидка больше 100 % — ошибка
ℹ pass 2
ℹ fail 0 */Интеграционный тест (англ. integration test) проверяет, как приложение работает со всем, что живёт вне его: с базой данных, файлами, другими сервисами. Значит, для теста работы с базой нужно запустить базу (Хэм Вокке, The Practical Test Pyramid).
Фаулер различает два вида (IntegrationTest):
- узкие (англ.
narrow) — проверяют только код, который общается с внешней частью; другие сервисы заменяют тестовыми объектами. По размеру они близки к модульным и запускаются тем же фреймворком; - широкие (англ.
broad) — требуют живых версий всех сервисов и проходят через всю систему.
Тест работы с базой по схеме Вокке: запустить базу, подключить к ней код, вызвать функцию, которая пишет в базу, и прочитать из базы, что данные там. База — настоящая SQLite в памяти (модуль node:sqlite встроен в Node.js):
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
const createOrderRepository = (db: DatabaseSync) => {
db.exec('CREATE TABLE orders (id INTEGER PRIMARY KEY, total INTEGER NOT NULL)');
return {
add: (total: number) => {
db.prepare('INSERT INTO orders (total) VALUES (?)').run(total);
},
revenue: () => (db.prepare('SELECT COALESCE(SUM(total), 0) AS sum FROM orders').get() as { sum: number }).sum,
};
};
test('выручка — сумма заказов в настоящей базе', () => {
const orders = createOrderRepository(new DatabaseSync(':memory:'));
orders.add(100);
orders.add(70);
assert.equal(orders.revenue(), 170);
});Модульный тест с фейком вместо базы не заметил бы ошибку в SQL-запросе, а этот — заметит.
Системное тестирование проверяет систему целиком: запрос проходит через маршрутизацию, логику и хранилище, как у настоящего пользователя. Такие тесты часто называют сквозными (англ. end-to-end, E2E). Через интерфейс их пишут инструментами, которые управляют браузером; через API — обычными HTTP-запросами:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
/* всё приложение: маршруты, логика и хранилище */
const orderTotals: number[] = [];
const app = createServer(async (request, response) => {
if (request.method === 'POST' && request.url === '/orders') {
let body = '';
for await (const chunk of request) body += chunk;
orderTotals.push(JSON.parse(body).total);
response.writeHead(201).end();
return;
}
if (request.method === 'GET' && request.url === '/revenue') {
const revenue = orderTotals.reduce((sum, total) => sum + total, 0);
response.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ revenue }));
return;
}
response.writeHead(404).end();
});
test('оформленные заказы попадают в выручку', async () => {
await new Promise<void>((resolve) => app.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${(app.address() as AddressInfo).port}`;
try {
await fetch(`${base}/orders`, { method: 'POST', body: JSON.stringify({ total: 100 }) });
await fetch(`${base}/orders`, { method: 'POST', body: JSON.stringify({ total: 70 }) });
const response = await fetch(`${base}/revenue`);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { revenue: 170 });
} finally {
app.close();
}
});Сквозные тесты ловят то, что пропускают остальные, но они медленнее и ломаются от любой мелочи — поэтому их держат немного (пирамида).
Приёмочное тестирование отвечает на вопрос «делает ли система то, что от неё хотел заказчик?». Критерии приёмки формулируют вместе с заказчиком, часто на понятном ему языке. Как это встраивают в разработку — в разделах ATDD и BDD.
Тестирование чёрного ящика (англ. black-box testing), тестирование на основе спецификации (англ. specification-based testing) — метод тестирования ПО, при котором проверка функциональности приложения происходит, не вдаваясь во внутреннюю структуру приложения. При определённых входных данных должен быть определённый результат.
Специфических знаний о коде приложения, внутренней структуре проекта и языках программирования в целом не требуется. Тестировщик должен знать, что должно делать приложение (из спецификации) и что оно делает на самом деле (проверяет сам), но может не знать, как оно это реализует.
Метод может быть применён практически на всех уровнях тестирования: Unit, Integration, System, Acceptance.
Пример: системный тест выше знает только адреса /orders и /revenue и что они должны вернуть, — это чёрный ящик.
Тестирование белого ящика (англ. white-box testing), структурное тестирование (англ. structural testing) — метод тестирования, при котором тестируются внутренние структуры приложения.
Тестирование белого ящика — метод тестирования приложения на уровне исходного кода.
Метод применяется на следующих уровнях тестирования: Unit, Integration, System.
Тестирование белого ящика позволяет выявить множество проблем в приложении, но может пропустить нереализованные части спецификации.
Пример: тест, который знает, что в функции есть ветка if для скидки больше 100 %, и специально её проверяет. Какие ветки не проверены, подсказывает покрытие кода.
Функциональное тестирование — процесс обеспечения качества (англ. QA process) и подтип тестирования чёрного ящика.
Функциональное тестирование проводится для проверки соответствия системы или её компонентов функциональным требованиям, описывающим функции системы и её компонентов.
Функциональное тестирование обычно описывает, что система делает.
Нефункциональное тестирование — тестирование системы (приложения) на предмет её нефункциональных требований: способа работы, а не конкретного поведения.
- Стресс-тестирование (англ.
stress testing) - Тестирование безопасности (англ.
security testing) - Тестирование производительности (англ.
performance testing) - Тестирование масштабируемости (англ.
scalability testing) - Тестирование выносливости (англ.
endurance testing) - Тестирование восстановления (англ.
recovery testing) - Тестирование документации (англ.
documentation testing)
Многие из этих понятий пересекаются или даже взаимозаменяются в зависимости от приложения.
Нагрузочное тестирование (англ. load testing) проверяет, как система ведёт себя под нагрузкой: сколько запросов в секунду она выдерживает и какая при этом задержка. Смотрят не на среднее, а на перцентили. Виды нагрузочных тестов из документации инструмента k6 (документация):
| Вид | Что проверяет |
|---|---|
Дымовой (англ. smoke) |
что сценарий теста работает, а система справляется с минимальной нагрузкой |
Средняя нагрузка (англ. average-load) |
как система работает в обычных ожидаемых условиях |
Стресс (англ. stress) |
как система работает на пределе, когда нагрузка выше ожидаемой |
Выносливость (англ. soak) |
надёжность и производительность за долгое время |
Всплеск (англ. spike) |
переживёт ли система внезапный, короткий и резкий рост нагрузки |
Точка отказа (англ. breakpoint) |
где предел: нагрузку плавно наращивают, пока система не сломается |
Суть любого нагрузочного теста — много одновременных запросов и измерение задержки:
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
/* сервер, которому каждый запрос обходится примерно в 5 мс работы */
const server = createServer((_request, response) => {
setTimeout(() => response.end('ok'), 5);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/`;
/* 20 «пользователей» одновременно, у каждого 20 запросов подряд */
const latencies: number[] = [];
let failed = 0;
const virtualUser = async (requests: number) => {
for (let i = 0; i < requests; i++) {
const start = performance.now();
const response = await fetch(url);
await response.text();
if (!response.ok) failed++;
latencies.push(performance.now() - start);
}
};
const started = performance.now();
await Promise.all(Array.from({ length: 20 }, () => virtualUser(20)));
const seconds = (performance.now() - started) / 1000;
server.close();
latencies.sort((a, b) => a - b);
const percentile = (p: number) => latencies[Math.ceil((p / 100) * latencies.length) - 1].toFixed(1);
console.log(`${latencies.length} запросов, ошибок: ${failed}`); // 400 запросов, ошибок: 0
console.log(`пропускная способность: ${Math.round(latencies.length / seconds)} запросов/с`);
console.log(`задержка: p50 ${percentile(50)} мс, p99 ${percentile(99)} мс`);Числа пропускной способности и задержки зависят от машины. На ноутбуке, где проверялся пример, в двух запусках вышло 2300–2400 запросов в секунду, медиана — 7,5–8 мс, а 99-й перцентиль — 18–19 мс: даже на простом сервере хвост задержки заметно длиннее медианы. Настоящие инструменты — k6 и подобные — делают то же самое, но с настраиваемыми сценариями нагрузки (документация k6).
Регрессионное тестирование (англ. regression testing) — повторно запускающиеся функциональные и нефункциональные тесты, предназначенные для проверки того, что ранее написанный и протестированный код не сломался после новых изменений.
Чем больше автоматизированных тестов, тем дешевле регрессионное тестирование: их запускают на каждое изменение, а не проверяют всё руками перед каждым релизом.
Статическое тестирование проверяет код, не запуская его: проверка типов (например, TypeScript), линтеры (ESLint), форматирование. Кент Си Доддс ставит статические проверки в основание своей схемы тестов (кубок тестирования), ведь они ловят целый класс ошибок ещё до запуска:
const priceWithDiscount = (price: number, percent: number): number => (price * (100 - percent)) / 100;
priceWithDiscount('200', 10);
// error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.Динамическое тестирование — всё остальное: код запускают и сравнивают результат с ожидаемым.
Когда сервис вызывает другой сервис, в тестах его обычно заменяют тестовым объектом. Но что, если настоящий сервис изменит формат ответа? Тесты будут зелёными, а в продакшене всё сломается. Фаулер предлагает периодически запускать отдельные контрактные тесты (англ. contract tests): они проверяют, что настоящий сервис отвечает так же, как тестовый объект (ContractTest).
На этом построен инструмент Pact: контрактные тесты проверяют, что сообщения между потребителем (англ. consumer) и поставщиком (англ. provider) соответствуют общему пониманию, записанному в контракте (документация).
/* контракт: что потребитель ждёт от ответа поставщика */
type Contract = { path: string; fields: Record<string, 'number' | 'string'> };
const userContract: Contract = { path: '/users/1', fields: { id: 'number', name: 'string' } };
type Provider = (path: string) => Record<string, unknown>;
const providerV1: Provider = () => ({ id: 1, name: 'Max' });
/* новая версия поставщика переименовала поле */
const providerV2: Provider = () => ({ id: 1, fullName: 'Max' });
const verify = (contract: Contract, provider: Provider): string => {
const body = provider(contract.path);
for (const [field, type] of Object.entries(contract.fields)) {
if (typeof body[field] !== type) return `нарушен: поле ${field} должно быть ${type}`;
}
return 'соблюдён';
};
console.log(verify(userContract, providerV1)); // соблюдён
console.log(verify(userContract, providerV2)); // нарушен: поле name должно быть stringПирамида тестирования (англ. test pyramid) — способ думать о том, как сочетать разные автоматизированные тесты. Главная мысль: модульных тестов должно быть намного больше, чем медленных тестов через интерфейс. Идею популяризировал Майк Кон в книге Succeeding with Agile (2009), где назвал её «пирамидой автоматизации тестов» (Фаулер, TestPyramid).
У Кона три слоя (Майк Кон):
/\
/UI\ — тесты через интерфейс: их должно быть мало
/----\
/Сервис\ — тесты на уровне сервисов, в обход интерфейса
/--------\
/ Модуль \ — модульные тесты: основание и самая большая часть
/------------\
Чем выше по пирамиде, тем тесты медленнее, дороже в написании и хрупче.
Сколько каких тестов нужно? В блоге Google о тестировании как хорошее первое приближение предлагают соотношение 70/20/10: 70 % модульных, 20 % интеграционных и 10 % сквозных. У каждой команды точная смесь своя, но форма пирамиды должна сохраняться (Just Say No to More End-to-End Tests, 2015).
Главный антипаттерн — перевёрнутая пирамида, или рожок мороженого (англ. ice-cream cone): команда полагается в основном на сквозные тесты, а интеграционных и модульных у неё мало (Google). Тесты через интерфейс медленные, и их трудно встроить в нормальный конвейер выкладки (Фаулер).
Есть и другой взгляд. Кент Си Доддс предложил кубок тестирования (англ. testing trophy): снизу вверх — статические проверки, модульные, интеграционные и сквозные тесты, с упором на интеграционные. Его девиз повторяет твит Гильермо Рауха: «Пишите тесты. Не слишком много. В основном интеграционные» (The Testing Trophy and Testing Classifications).
Test-Driven Development (TDD) — разработка через тестирование; подход к разработке и тестированию, при котором сначала создаются тесты, которым должен удовлетворять код, затем его реализация.
TDD имеет итеративный процесс. Сперва пишется тест на новый, ещё не реализованный функционал, а затем пишется минимальное количество кода (ничего лишнего) для его реализации. При успешном прохождении теста, можно задуматься о качестве кода и сделать его рефакторинг.
- Полное покрытие кода тестами.
- Заранее задумываемся об использовании кода.
- Хорошие тесты являются неплохим примером использования кода (что-то вроде документации).
Например, напишем тест для функции, которая должна возводить двойку в степень.
const runTests = () => {
assert.equal(pow2(0), 1, '2^0 = 1');
assert.equal(pow2(1), 2, '2^1 = 2');
assert.equal(pow2(2), 4, '2^2 = 4');
};Теперь на основании теста пишется сама функция.
const pow2 = (power) => {
let result = 1;
for (let i = 0; i < power; i++) {
result *= 2;
}
return result;
}Функция удовлетворяет тестам выше, добавим новые тесты.
assert.equal(pow2(-1), 0.5, '2^(-1) = 0.5');
assert.equal(pow2(-2), 0.25, '2^(-2) = 0.25');
assert.equal(pow2(-3), 0.125, '2^(-3) = 0.125');Дописываем нужный функционал.
const pow2 = (power) => {
let result = 1;
if (power > 0) {
for (let i = 0; i < power; i++) {
result *= 2;
}
} else {
for (let i = 0; i > power; i--) {
result /= 2;
}
}
return result;
}Далее снова пишем тесты.
Приёмочное тестирование (англ. acceptance testing) — тестирование, направленное на проверку соответствия системы требованиям.
Acceptance TDD (ATDD) - разработка через приёмочные тесты. Подход близок к TDD, но отличается тем, что привлекает тестировщиков, программистов и сторону заказчика к совместному написанию критериев принятия тестов до начала написания кода.
Сперва придумываются критерий выполненной работы и критерий того, что она выполнена правильно. Эти критерии описываются доступным для понимания нетехническому человеку языком.
ATDD вносит ясность, что все участники проекта точно понимают, что необходимо сделать и реализовать.
Behavior-Driven Development (BDD) — разработка, основанная на поведении; расширение подхода TDD, где особое внимание уделяется поведению системы в терминах бизнеса. Такие тесты обычно иллюстрируют и тестируют сценарии, интересные заказчику системы. Поэтому для BDD-тестов используются фреймворки (Chai, Mocha, Jest) с синтаксисом, понятным не только программисту, но и представителю заказчика. Обычно этот синтаксис похож на английский язык.
const createArticle = title => ({ title, date: new Date() });
describe('createArticle', () => {
const article = createArticle('Weather');
it('should return an object', () => expect(article).to.be.an('object'));
it('the object should have a "date" and "title" properties', () => {
expect(article).to.have.property('date');
expect(article).to.have.property('title');
});
});TDD-тесты пишутся программистами для программистов. Они не указывают, что конкретно нужно тестировать и как именно должны выглядеть и называться тесты.
BDD вдохновлено архитектурой DDD (Domain-Driven Design) и фокусируется на предметной области приложения, в то время как TDD фокусируется на самом коде, реализацию.
ATDD фокусируется на отображении требований в приёмочных тестах и использует эти тесты для разработки. Вопрос, определяющий ATDD-тесты: "Система делает то, что от неё требуется?".
BDD ориентирован на клиента (customer-focused), в то время как ATDD больше ориентирован на разработчика (developer-focused) и часто использует те же технологии, что и TDD.
BDD-тесты могут быть написаны и поняты не только программистом, но и техническими менеджерами или тестировщиками, что позволяет убрать языковой барьер между всеми ними.
Data-Driven Testing (DDT) — тестирование, управляемое данными; подход к архитектуре автоматизированных тестов, при котором тестовые данные хранятся отдельно от тестов (в файле или базе данных).
- Часть тестовых данных извлекается из хранилища.
- Выполняется скрипт, в котором вызывается обычный тест с извлечёнными тестовыми данными.
- Сравниваются полученные (actual) результаты с ожидаемыми (expected).
- Алгоритм повторяется со следующим набором входных данных.
Перепишем функцию pow2 из раздела TDD.
const runTests = () => {
assert.equal(pow2(0), 1, '2^0 = 1');
assert.equal(pow2(1), 2, '2^1 = 2');
assert.equal(pow2(2), 4, '2^2 = 4');
/* ... */
};Возводить двойку в степень можно до бесконечности (на самом деле не совсем, поскольку в JavaScript MAX_SAFE_INTEGER = 2^53 - 1), но писать все эти степени руками не хочется.
Что можно сделать?
Можно взять файл, содержащий степени двоек и подгружать данные из него.
/* pow-data.txt */
1 2 4 8 16 32 64 128 256 512 1024 2048 4096 ...
Пишем функционал, загружающий данные из файла и возвращающий их в виде массива.
const fs = require('node:fs/promises'); // версия fs, методы которой возвращают промисы
const readPowData = async (filename) => {
const powData = await fs.readFile(filename, 'utf8');
return powData.split(' ').map(Number);
};Пишем функционал для тестов таким образом, чтобы можно было запускать их с массивом данных.
const runTest = (power, expectedValue) => assert.equal(pow2(power), expectedValue, `2^${power} = ${expectedValue}`);
const runTests = (powDataArray) => {
powDataArray.forEach((item, index) => {
runTest(index, item);
});
};Считываем данные из файла и запускаем тесты.
let powDataArray = await readPowData('pow-data.txt');
runTests(powDataArray);Загружаем данные из другого файла или же из другого источника, снова запускаем тесты.
powDataArray = await readPowData('pow-data-2.txt');
runTests(powDataArray);
powDataArray = await getPowDataFromDB();
runTests(powDataArray);Keyword-Driven Testing (KDT) — тестирование, управляемое ключевыми словами; подход, использующий ключевые слова, описывающие набор действий, необходимых для выполнения определённого шага тестового сценария.
Для использования подхода нужно определить набор ключевых слов и сопоставить им действия (функции).
В KDT используется что-то вроде таблиц, чтобы ключевые слова могли иметь параметры, поэтому подход иногда называют Table-Driven Testing (TDT).
- Считываем ключевые слова вместе с их параметрами из таблицы.
- Последовательно вызываем связанные с ключевыми словами функции.
Пусть наш тестовый сценарий требует входа в пользовательский аккаунт, отправки двух сообщений на разные адреса и выхода из аккаунта.
Определяем ключевые слова: Login, Send Email, Logout.
Сопоставим им функции.
/* keyword "Login" */
const login = (username, password) => { /* ... */ };
/* keyword "Send Email" */
const sendEmail = (receiver, message) => { /* ... */ };
/* keyword "Logout" */
const logout = () => { /* ... */ };Теперь можно описать тестовый сценарий, например, следующим файлом (одна строка - одно ключевое слово).
/* user-test.txt */
Login | user | pass123
Send Email | another-user@mail.com | Hello!
Send Email | my-friend@mail.com | Hi!
Logout
Обработаем этот файл.
const fs = require('node:fs/promises');
const removeExtraSpaces = str => str.replace(/ +\| +/g, '|').trim();
const readTestData = async (filename) => {
const testData = await fs.readFile(filename, 'utf8');
// возвращаем массив строк файла без лишних пробелов
return testData.split('\n').map(removeExtraSpaces).filter(Boolean);
};Чтобы выполнять действие в зависимости от ключевого слова, можно использовать простую функцию с конструкцией switch.
const makeAction = (keyword, params) => {
switch (keyword) {
case 'Login': return login(...params);
case 'Send Email': return sendEmail(...params);
case 'Logout': return logout();
};
};Осталось только считать строки из файла и выполнить то, что описано в каждой.
let rows = await readTestData('user-test.txt');
rows.forEach((row) => {
// выделяем ключевое слово и параметры в строке
const [keyword, ...params] = row.split('|');
// выполняем действие
makeAction(keyword, params);
});Тут можно сделать вспомогательную функцию.
const executeRows = (rows) => {
rows.forEach((row) => {
const [keyword, ...params] = row.split('|');
makeAction(keyword, params);
});
};Теперь можно выполнять любые последовательности, состоящие из определённых нами ключевых слов.
let rows = await readTestData('user-test.txt');
executeRows(rows);
rows = await readTestData('another-test.txt');
executeRows(rows);
rows = ['Login|admin|admin', 'Logout'];
executeRows(rows);Для достижения изолированности какого-то блока тестируемого кода внешние зависимости при его тестировании заменяются тестовыми объектами.
Общее название всех таких объектов — тестовый двойник (англ. test double), как дублёр в кино. Джерард Месарош, работая над книгой о паттернах для xUnit-фреймворков, предложил словарь из пяти видов двойников; их определения ниже — по Мартину Фаулеру (TestDouble):
| Вид | Что делает |
|---|---|
Пустышка (англ. dummy) |
передаётся, но никогда не используется: нужна, чтобы заполнить список параметров |
Фейк (англ. fake) |
работает по-настоящему, но срезает углы: например, база данных в памяти |
Заглушка (англ. stub) |
возвращает заранее заготовленные ответы на вызовы в тесте |
Шпион (англ. spy) |
заглушка, которая ещё и записывает, как её вызывали |
Макет (англ. mock) |
заранее запрограммирован ожиданиями: какие вызовы он должен получить; на неожиданный вызов может бросить исключение, а в конце проверяет, что все ожидаемые вызовы были |
Все виды — на одном примере: регистрация пользователя сохраняет его в хранилище, отправляет письмо и пишет в журнал о повторной регистрации.
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
type Logger = { info: (message: string) => void };
type Mailer = { send: (to: string, text: string) => void };
type UserRepository = {
save: (email: string) => number;
findByEmail: (email: string) => number | undefined;
};
const registerUser = (email: string, users: UserRepository, mailer: Mailer, logger: Logger): number => {
if (users.findByEmail(email) !== undefined) {
logger.info(`повторная регистрация: ${email}`);
throw new Error('пользователь уже есть');
}
const id = users.save(email);
mailer.send(email, 'Добро пожаловать!');
return id;
};Пустышка (англ. dummy) нужна, только чтобы передать параметр: в этом тесте её не вызовут. Если вызовут — тест упадёт, и это тоже полезно.
/* журнал нужен только при повторной регистрации — здесь его не вызывают */
const dummyLogger: Logger = {
info: () => {
throw new Error('пустышка не должна вызываться');
},
};Фейк (англ. fake) — настоящая, работающая реализация, которая не годится для продакшена: например, хранилище в памяти вместо базы данных.
const createFakeUsers = (): UserRepository => {
const emails: string[] = [];
return {
save: (email) => emails.push(email), /* push возвращает новую длину — это и есть id */
findByEmail: (email) => {
const index = emails.indexOf(email);
return index === -1 ? undefined : index + 1;
},
};
};Заглушка (англ. stub) возвращает заготовленные ответы и больше ничего не делает. Например, проверка авторизации в тесте не важна, и её заменяют функцией, которая всегда говорит «да»:
const checkAuth = () => true;
checkAuth(); // trueВ нашем примере заглушка хранилища всегда отвечает, что пользователь уже есть:
const stubUsers: UserRepository = {
save: () => 1,
findByEmail: () => 1, /* заготовленный ответ: такой пользователь уже есть */
};Шпион (англ. spy) — объект-обёртка (по типу прокси), слушающий вызовы и сохраняющий информацию об этих вызовах (аргументы, количество вызовов, контекст). Сохранённые шпионом данные используются в тестах. В node:test шпиона создаёт mock.fn(): у него есть список вызовов mock.calls и их число mock.callCount() (документация).
Соберём всё вместе:
test('новый пользователь получает письмо', () => {
const mailer = { send: mock.fn((_to: string, _text: string) => {}) }; /* шпион */
const id = registerUser('max@example.com', createFakeUsers(), mailer, dummyLogger);
assert.equal(id, 1);
assert.equal(mailer.send.mock.callCount(), 1);
assert.deepEqual(mailer.send.mock.calls[0].arguments, ['max@example.com', 'Добро пожаловать!']);
});
test('повторная регистрация не отправляет письмо', () => {
const mailer = { send: mock.fn((_to: string, _text: string) => {}) };
const logger = { info: mock.fn((_message: string) => {}) };
assert.throws(() => registerUser('max@example.com', stubUsers, mailer, logger), /уже есть/);
assert.equal(mailer.send.mock.callCount(), 0);
assert.equal(logger.info.mock.callCount(), 1);
});
/* ✔ новый пользователь получает письмо
✔ повторная регистрация не отправляет письмо */Макет (англ. mock) отличается от шпиона тем, что ожидания задают заранее: какие вызовы он должен получить. Неожиданный вызов сразу бросает исключение, а в конце теста макет проверяет, что все ожидаемые вызовы произошли. Шпион проверяют после действия, а макет — описывают до.
/* макет почтового сервиса: ждёт ровно одно письмо ровно этому адресату */
const createMailerMock = (expected: [string, string][]) => {
const remaining = [...expected];
return {
send: (to: string, text: string) => {
const next = remaining.shift();
if (!next || next[0] !== to || next[1] !== text) throw new Error(`неожиданный вызов send(${to})`);
},
verify: () => {
if (remaining.length > 0) throw new Error(`не было ${remaining.length} ожидаемых вызовов`);
},
};
};
test('макет получает ровно ожидаемое письмо', () => {
const mailer = createMailerMock([['anna@example.com', 'Добро пожаловать!']]);
registerUser('anna@example.com', createFakeUsers(), mailer, dummyLogger);
mailer.verify();
});Объект-страница (англ. Page Object Model, POM) — модуль (класс), представляющий интерфейс отдельной страницы тестируемой системы.
При использовании POM тесты оперируют с абстрактными объектами страниц и не завязаны на конкретную реализацию пользовательского интерфейса (его дизайн и вёрстку).
Тесты используют методы POM каждый раз, когда требуется взаимодействие с UI страницы.
Преимущества Page Object
- Изменение дизайна и вёрстки страниц может потребовать изменения только во внутренней реализации объекта страницы, но оно не затрагивает тестовые сценарии, оперирующие данной страницей.
- Абстрактность интерфейса позволяет переиспользовать тесты для различных устройств и разрешений экранов, что очень удобно.
Испытательная Платформа (англ. TestBed) — это специально воссозданная тестовая среда, платформа для тестирования (например, комплекс Макетов, Заглушек и Шпионов).
Испытательная Платформа применяется для комплексного тестирования отдельных связок системы или её компонентов.
Фикстура (англ. fixture) — механизм, позволяющий привести объект или всю систему в определённое состояние и зафиксировать это состояние для тестов.
Под фикстурой чаще всего понимают тестовые данные необходимые для корректного запуска тестов, а также механизмы загрузки/выгрузки этих данных в хранилище.
Основное назначение фикстуры: привести данные системы к определённому (фиксированному) состоянию, которое будет точно известно во время выполнения тестов.
Тесты тоже бывают плохими: проходят, когда код сломан, или падают, когда всё в порядке. Ниже — способы это заметить.
Покрытие кода (англ. code coverage, test coverage) показывает, какие строки и ветки кода выполнялись во время тестов. Фаулер формулирует двумя тезисами: покрытие — полезный инструмент, чтобы найти непроверенные части кода, но как число, которое говорит, насколько хороши тесты, оно почти бесполезно (TestCoverage). Тест может выполнить строку и ничего не проверить.
В Node.js покрытие считает сам тестовый раннер — флаг --experimental-test-coverage (документация). Пусть тест проверяет только бесплатную доставку. Файл shipping.mjs:
export const shippingCost = (total) => {
if (total > 5000) {
return 0;
}
return 300;
};Тест shipping.test.mjs:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shippingCost } from './shipping.mjs';
test('доставка заказа дороже 5000 бесплатная', () => {
assert.equal(shippingCost(6000), 0);
});$ node --test --experimental-test-coverage shipping.test.mjs
ℹ file | line % | branch % | funcs % | uncovered lines
ℹ shipping.mjs | 83.33 | 66.67 | 100.00 | 5
Строка 5 — return 300 — ни разу не выполнилась: платную доставку никто не проверил.
Мутационное тестирование (англ. mutation testing) проверяет сами тесты: инструмент вносит в код маленькие ошибки — мутанты (англ. mutants) — и запускает тесты для каждого. Если тесты упали, мутант убит; если прошли — мутант выжил, и тесты недостаточно проверяют этот код. Чем больше доля убитых мутантов, тем эффективнее тесты (Stryker).
const isAdult = (age: number) => age >= 18;
/* мутант: >= заменили на > */
const isAdultMutant = (age: number) => age > 18;
type Case = [age: number, expected: boolean];
const weakTests: Case[] = [[30, true], [5, false]];
const strongTests: Case[] = [...weakTests, [18, true]]; /* добавили граничный случай */
const passes = (isAdultImpl: (age: number) => boolean, cases: Case[]) =>
cases.every(([age, expected]) => isAdultImpl(age) === expected);
console.log(passes(isAdult, strongTests)); // true
console.log(passes(isAdultMutant, weakTests)); // true
console.log(passes(isAdultMutant, strongTests)); // falseСлабые тесты не заметили мутанта — он выжил. Граничный случай 18 его убил. Покрытие строк в обоих случаях — 100 %.
Обычный тест проверяет конкретные примеры. Тестирование на основе свойств (англ. property-based testing) проверяет свойства, которые должны выполняться для любых данных, на множестве случайных входов. Идея пришла из инструмента QuickCheck для Haskell: свойства описываются функциями и автоматически проверяются на случайных данных (Claessen, Hughes).
Свойства сортировки: длина не меняется, элементы идут по возрастанию. Проверим их на сортировке с ошибкой — она теряет повторы:
/* повторяемые «случайные» числа */
let seed = 7;
const random = () => (seed = (seed * 1_103_515_245 + 12_345) % 2 ** 31) / 2 ** 31;
const randomArray = () => Array.from({ length: Math.floor(random() * 8) }, () => Math.floor(random() * 5));
const buggySort = (items: number[]) => [...new Set(items)].sort((a, b) => a - b);
const properties: [string, (input: number[], output: number[]) => boolean][] = [
['длина не меняется', (input, output) => output.length === input.length],
['элементы по возрастанию', (_input, output) => output.every((value, i) => i === 0 || output[i - 1] <= value)],
];
for (let run = 1; run <= 100; run++) {
const input = randomArray();
const broken = properties.find(([, holds]) => !holds(input, buggySort(input)));
if (broken) {
console.log(`прогон ${run}: свойство «${broken[0]}» нарушено на`, input); // прогон 2: свойство «длина не меняется» нарушено на [ 4, 4, 0, 4 ]
break;
}
}Примеры, придуманные вручную ([3, 1, 2]), могли бы не содержать повторов, и ошибка прошла бы незамеченной.
Нестабильный тест (англ. flaky test) то проходит, то падает на одном и том же коде. В Google около 1,5 % всех прогонов тестов дают нестабильный результат, а почти 16 % тестов хоть иногда нестабильны. Причины: конкурентность, расчёт на недетерминированное или неопределённое поведение, нестабильный сторонний код, проблемы инфраструктуры (Flaky Tests at Google and How We Mitigate Them, 2016).
Частый источник — зависимость от текущего времени, случайных чисел или порядка запуска. Лечится тем, что такие значения передают явно:
const isWorkingHours = (now: Date) => now.getHours() >= 9 && now.getHours() < 18;
/* нестабильно: результат зависит от того, когда запустили тест */
/* assert.equal(isWorkingHours(new Date()), true); */
/* стабильно: время передаём явно */
console.log(isWorkingHours(new Date(2026, 8, 30, 10, 0))); // true
console.log(isWorkingHours(new Date(2026, 8, 30, 22, 0))); // falseТак же поступают со случайностью: генератор с фиксированным начальным значением, как в примере выше.