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

На чтение
9 мин
Дата обновления
05.11.2026

Чтобы проверить, как интерфейс реагирует на недоступный 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, авторизации и интеграции сохраняют отдельным контуром.