Перехват и подмена HTTP-запросов в UI-автотестах через Chrome DevTools Protocol

Чтобы проверить, как интерфейс реагирует на недоступный API, не обязательно останавливать общий тестовый backend. В браузерном тесте можно перехватить конкретный запрос и вернуть фикстуру, HTTP-ошибку или сетевой сбой. Важно при этом понимать, какая часть системы проверяется: подмена позволяет исследовать фронтенд, но исключает реальную обработку запроса сервером.
Ниже — авторский локальный стенд на JavaScript, Playwright и raw Chrome DevTools Protocol. Он поднимает свою страницу на свободном локальном порту и запускает отдельный headless Chromium. Чужие сервисы, учётные записи и открытые вкладки для упражнения не нужны.
Network наблюдает, Fetch приостанавливает
CDP разделён на домены. Network помогает управлять сетевыми условиями и наблюдать события, а Fetch предоставляет перехват с requestPaused. После паузы клиент должен выбрать дальнейшее действие: continueRequest, fulfillRequest или failRequest. Если оставить запрос без решения, страница может продолжать ждать.
| Команда | Смысл в тесте |
|---|---|
| Fetch.continueRequest | Продолжить запрос; API также допускает изменение поддерживаемых параметров, например заголовков или метода |
| Fetch.fulfillRequest | Предоставить свой ответ: статус, заголовки и тело |
| Fetch.failRequest | Завершить запрос сетевой ошибкой выбранного типа |
| Fetch.getResponseBody | Получить тело у запроса, приостановленного на Response stage |
Используйте requestId из Fetch.requestPaused. Не подставляйте вместо него наугад идентификатор другого домена: событие также может содержать отдельный networkId. В нашем примере выбрана Request stage, поэтому ответ создаётся до обращения к реальному обработчику API.
Когда перехват лучше отдельного mock-сервера
Для точечного UI-сценария удобно заменить один адрес в одном браузерном контексте: показать пустое состояние, ошибку или заранее известную цену. Если нужно совместно проверить несколько клиентов, последовательности серверных операций или согласованный API-контракт между командами, отдельный тестовый сервер может быть понятнее. Это авторский критерий выбора, а не правило CDP.
Не превращайте перехватчик в копию бизнес-backend. Чем больше в нём самостоятельных правил, тем легче проверить поведение выдуманного API и пропустить изменение настоящего. Храните фикстуры небольшими и связывайте их с конкретным ожиданием интерфейса.
Подготовить среду
В отдельной папке создайте Node-проект, установите @playwright/test и браузер Chromium. Например:
npm init -y
npm install --save-dev @playwright/test
npx playwright install chromium
Сохраните lockfile и согласуйте версию браузера с CI. CDPSession в Playwright поддерживается для Chromium-браузеров; этот пример не обещает аналогичный raw CDP-сценарий в Firefox или WebKit. Для полного браузерного покрытия нужны отдельные решения и проверки.
Сохраните следующий код как cdp-example.cjs и выполните node cdp-example.cjs. По умолчанию запускается установленный Playwright Chromium. Необязательная переменная CDP_TEST_BROWSER в примере позволяет явно выбрать отдельный исполняемый Chrome для контролируемой лаборатории; её не нужно задавать для обычного запуска.
Полный локальный пример четырёх сценариев
const http = require('node:http');
const assert = require('node:assert/strict');
const { chromium, expect } = require('@playwright/test');
const html = `<!doctype html><meta charset="utf-8">
<button id="load">Получить цену</button><p id="state">Готово</p>
<script>
const button = document.getElementById('load');
const state = document.getElementById('state');
button.addEventListener('click', async () => {
button.disabled = true;
state.textContent = 'Загрузка';
try {
const response = await fetch('/api/price');
if (!response.ok) {
state.textContent = 'Ошибка ' + response.status;
return;
}
const data = await response.json();
state.textContent = 'Цена: ' + data.price + ' ' + data.currency;
} catch {
state.textContent = 'Нет соединения';
} finally {
button.disabled = false;
}
});
</script>`;
async function main() {
let backendHits = 0;
const server = http.createServer((req, res) => {
if (req.url === '/api/price') {
backendHits += 1;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ price: 9999, currency: 'RUB' }));
} else if (req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
} else {
res.writeHead(404);
res.end();
}
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
let browser;
let releaseDelay;
try {
const options = { headless: true };
if (process.env.CDP_TEST_BROWSER) options.executablePath = process.env.CDP_TEST_BROWSER;
browser = await chromium.launch(options);
const context = await browser.newContext();
const page = await context.newPage();
const cdp = await context.newCDPSession(page);
await cdp.send('Network.enable');
await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
await cdp.send('Network.setBypassServiceWorker', { bypass: true });
let mode = 'fixture';
let delayGate = Promise.resolve();
let intercepted = 0;
const handlerErrors = [];
const fixtureBody = Buffer.from(JSON.stringify({ price: 1290, currency: 'RUB' })).toString('base64');
cdp.on('Fetch.requestPaused', event => {
(async () => {
if (event.request.method !== 'GET' || new URL(event.request.url).pathname !== '/api/price') {
await cdp.send('Fetch.continueRequest', { requestId: event.requestId });
return;
}
intercepted += 1;
const selectedMode = mode;
if (selectedMode === 'network-error') {
await cdp.send('Fetch.failRequest', { requestId: event.requestId, errorReason: 'Failed' });
return;
}
if (selectedMode === 'delay') await delayGate;
await cdp.send('Fetch.fulfillRequest', {
requestId: event.requestId,
responseCode: selectedMode === 'http-error' ? 503 : 200,
responseHeaders: [{ name: 'Content-Type', value: 'application/json; charset=utf-8' }],
body: selectedMode === 'http-error'
? Buffer.from(JSON.stringify({ error: 'unavailable' })).toString('base64')
: fixtureBody
});
})().catch(async error => {
handlerErrors.push(error.message);
try { await cdp.send('Fetch.failRequest', { requestId: event.requestId, errorReason: 'Failed' }); }
catch { /* A completed request cannot be resolved twice. */ }
});
});
await cdp.send('Fetch.enable', {
patterns: [{ urlPattern: base + '/api/price*', requestStage: 'Request' }]
});
await page.goto(base);
const state = page.locator('#state');
const button = page.locator('#load');
await button.click();
await expect(state).toHaveText('Цена: 1290 RUB');
mode = 'http-error';
await button.click();
await expect(state).toHaveText('Ошибка 503');
mode = 'network-error';
await button.click();
await expect(state).toHaveText('Нет соединения');
mode = 'delay';
delayGate = new Promise(resolve => { releaseDelay = resolve; });
await button.click();
await expect(state).toHaveText('Загрузка');
await expect(button).toBeDisabled();
releaseDelay();
await expect(state).toHaveText('Цена: 1290 RUB');
await expect(button).toBeEnabled();
assert.equal(intercepted, 4);
assert.equal(backendHits, 0);
assert.deepEqual(handlerErrors, []);
console.log(JSON.stringify({
browserVersion: browser.version(),
intercepted,
backendHits,
handlerErrors,
scenarios: ['fixture', 'HTTP503', 'network failure', 'controlled delay'],
result: 'passed'
}, null, 2));
await cdp.send('Fetch.disable');
await cdp.detach();
} finally {
if (releaseDelay) releaseDelay();
if (browser) await browser.close();
await new Promise(resolve => server.close(resolve));
}
}
main().catch(error => { console.error(error); process.exitCode = 1; });
Что именно подтверждают проверки
Настоящий обработчик нашего стенда возвращает цену 9 999. Перехватчик выдаёт 1 290, поэтому проверка текста «Цена: 1290 RUB» различает реальные данные и фикстуру. Дополнительный счётчик backendHits должен остаться нулём: все четыре API-запроса разрешились в перехватчике. Это ожидание конкретного упражнения, не общее свойство любых браузерных запросов.
HTTP 503 и сетевой сбой намеренно различаются. В первом случае fetch получает Response с неуспешным status; во втором завершается ошибкой соединения. Наша страница показывает разные сообщения. Проверка только факта «что-то красное появилось» не объяснила бы, обработан ли нужный сценарий.
Задержка управляется promise-барьером: сначала тест видит «Загрузка» и недоступную кнопку, затем явно разрешает завершение. Здесь не нужен произвольный sleep на несколько секунд. После освобождения барьера проверяем и результат, и восстановление кнопки. Асинхронные утверждения Playwright ожидают нужное состояние в пределах своего тайм-аута.
Как перейти к изменению реального ответа
Если нужно сначала получить ответ backend и затем его изменить, выбирают перехват на Response stage. У такого paused-запроса можно вызвать Fetch.getResponseBody, учесть признак base64Encoded и сформировать новый fulfillRequest. Это другой режим, а не добавление getResponseBody в наш Request-stage обработчик.
При переписывании тела согласуйте статус и заголовки с новыми данными. Не переносите старые Content-Length или Content-Encoding к другому набору байтов без проверки. В нашем синтетическом ответе задаётся только нужный Content-Type и собственное base64-тело. Для реального API также важны cookies, перенаправления и другие условия контракта.
Кэш, Service Worker и CORS задают границу результата
В примере явно отключён сетевой кэш и включён bypass Service Worker. Это условия нашей лаборатории, выбранные для воспроизводимого перехвата. Они не подтверждают работу производственного кэширования или офлайн-режима. Если проверяется Service Worker, нужен отдельный сценарий с соответствующей конфигурацией, а не механическое включение bypass во всех тестах.
Страница и API стенда имеют один origin. Для cross-origin запроса действуют правила CORS, включая возможный preflight. Нельзя по успешной выдаче локальной фикстуры заключить, что реальный сервер правильно настроил CORS. Не отключайте защиту браузера просто ради зелёного теста; отдельно проверяйте нужные заголовки и фактический сетевой путь.
Сужайте паттерн по origin и адресу, проверяйте метод и продолжайте нецелевые запросы. Шаблон на все URL способен случайно подменить документ, аналитику или авторизацию вместо нужного endpoint. В параллельных тестах используйте отдельные контексты и состояние: глобальное mode из нашего последовательного упражнения не подходит как общая переменная всего набора.
Что переносить в CI
Зафиксируйте версии, адрес стенда, фикстуру и ожидаемую реакцию UI. Приложите к падению ошибки обработчика и сведения о том, был ли нужный запрос перехвачен. В finally закрываются браузер и локальный сервер; для незавершённой задержки также освобождается барьер.
Схема CDP в официальном репозитории регулярно обновляется, поэтому проверяйте поддержку используемых команд в выбранном браузере. Не используйте свежий протокол как обещание совместимости с любой старой сборкой. Полезный результат такого теста — проверенная реакция интерфейса на точно заданные условия. Проверку настоящего backend, авторизации и интеграции сохраняют отдельным контуром.




