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

Как отслеживать и перехватывать сетевые запросы и ответы

Обзор

warning

Данный рецепт работает только при использовании Chrome DevTools Protocol (CDP).

Читайте подробности в разделе «Как использовать CDP в Testplane»

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

В webdriverio для работы с сетевыми запросами существует метод mock, который использует API домена Fetch.

С помощью этого метода мы можем:

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

Давайте попробуем написать тесты с использованием этого API и покрыть разные кейсы. Сразу уточним, что все графические изображения с процессом выполнения тестов замедлены в 2 раза, так как тесты локально выполняются очень быстро и что-то разглядеть довольно проблематично.

Пример 1: мокаем запрос к google.com и возвращаем свой ответ

it("should mock google.com", async function () {
// Мокаем поход на google.com
const mock = await this.browser.mock("https://google.com");

// Возвращаем строку "Hello, world!" вместо ответа от сайта.
// Опция "fetchResponse" отвечает за то, нужно ли делать запрос
// на замоканный ресурс, по умолчанию - true
mock.respond("Hello, world!", { fetchResponse: false });

await this.browser.url("https://google.com");
});

По графическому изображению видно, что мы вернули свой текст, при этом в строке браузера отображается как будто мы выполнили переход на google.com. Также видно, что мы не замокали фавиконку и она приехала снаружи. Этот же самый пример мы можем написать с использованием API puppeteer'а, для этого в webdriverio реализована команда getPuppeteer():

it("should mock google.com using puppeteer api", async function () {
// Получаем инстанс puppeteer'а
const puppeteer = await this.browser.getPuppeteer();

// Получаем первую открытую страницу (считаем, что она активная в данный момент)
const [page] = await puppeteer.pages();

// Активируем перехват всех запросов
await page.setRequestInterception(true);
page.on("request", async request => {
if (request.url() !== "https://google.com/") {
// Если урл запроса не матчится на https://google.com/,
// то выполняем запрос (т. е. не перехватываем его)
return request.continue();
}

// отвечаем своими данными
return request.respond({ body: "Hello, world!" });
});

// Здесь можно было бы вызвать и "page.goto('https://google.com')", но лучше вызывать "url",
// так как в большинстве плагинов есть обертки команды "url", добавляющие дополнительную
// логику. Например, в testplane добавляется урл в мету.
await this.browser.url("https://google.com");
});

Хардкорный вариант с использованием CDP напрямую

А теперь представим, что в puppeteer еще нет API для мока запросов, но это уже реализовано в домене Fetch CDP. В этом случае воспользуемся методом этого домена через общение с CDP-сессией напрямую. Для этого в puppeteer есть метод CDPSession.send():

it("should mock google.com using cdp fetch domain", async function () {
// Получаем инстанс puppeteer'а
const puppeteer = await this.browser.getPuppeteer();

// Получаем первую открытую страницу (считаем, что она активная в данный момент)
const [page] = await puppeteer.pages();

// Создаем CDP-сессию
const client = await page.target().createCDPSession();

// Включаем возможность перехватить запрос с помощью подписки на событие "requestPaused"
await client.send("Fetch.enable");

client.on("Fetch.requestPaused", event => {
const {
request: { url },
requestId,
responseHeaders,
} = event;

if (url !== "https://google.com/") {
// Если урл запроса не матчится на https://google.com/,
// то выполняем запрос (т. е. не перехватываем его)
return client.send("Fetch.continueRequest", { requestId });
}

// Подменяем ответ на свой и упаковываем его в base64
return client.send("Fetch.fulfillRequest", {
requestId,
responseCode: 200,
responseHeaders,
body: Buffer.from("Hello, world!", "utf8").toString("base64"),
});
});

await this.browser.url("https://google.com");
});

Очевидно, что при использовании API webdriverio для мока запросов код получается сильно короче, но API webdriverio сильно ограничен и для более сложных кейсов необходимо использовать API puppeteer'а. При этом в самом puppeteer'е тоже может не быть API для каких-то новых методов или доменов CDP. Поэтому в редких случаях может пригодиться общение по CDP напрямую с помощью CDPSession.send().

Пример 2: отменяем запрос за логотипом гугла

it("should abort request to logo on google.com", async function () {
// В качестве урла можно использовать маску
const mock = await this.browser.mock("https://www.google.com/images/**/*.png");

// Кидаем ошибку "ERR_FAILED" при загрузке ресурса, сматчившегося на маску мока
mock.abort("Failed");

await this.browser.url("https://google.com");
});

По графическому изображению видно, что логотип не отобразился и в логе присутствует ошибка net::ERR_FAILED. Такое решение может быть удобно для отключения каких-то скриптов, которые мешают быстрому выполнению теста. Например, можно отключить сбор аналитики.

Пример 3: при загрузке google.com берем ответ из фикстуры

it("should mock google.com and return answer from fixture", async function () {
// Мокаем поход на google.com
const mock = await this.browser.mock("https://google.com");

// Указываем путь, откуда нужно взять фикстуру, а с помощью
// "fetchResponse: false" говорим, что выполнять реальный поход не нужно
mock.respond("./fixtures/my-google.com.html", { fetchResponse: false });

await this.browser.url("https://google.com");
});

По графическому изображению видно, что вместо выдачи google.com отобразились данные из нашей фикстуры.

Пример 4: перенаправляем запрос с google.com на yandex.ru

it("should redirect from google.com to yandex.ru", async function () {
// Мокаем поход на google.com
const mock = await this.browser.mock("https://google.com");

// Для редиректа необходимо просто указать урл
mock.respond("https://yandex.ru");

await this.browser.url("https://google.com");
});

Пример 5: модифицируем ответ от google.com в реальном времени

В puppeteer все еще не реализован API для удобного изменения ответа. Про это есть issue#1191. Но такая возможность уже поддержана в CDP. Webdriverio использует CDP напрямую через [puppeteer][puppeteer] и, соответственно, в webdriverio это работает.

Заменим в ответе от google.com все строки содержащие Google на Yandex:

it("should modify response from google.com", async function () {
// Тут нужно мокать именно с www, так как переход на google.com
// возвращает ответ 301 без body и перенаправляет нас на www
const mock = await this.browser.mock("https://www.google.com");

mock.respond(req => {
// С помощью регулярки заменяем "Google" на "Yandex"
return req.body.replace(/Google/g, "Yandex");
});

await this.browser.url("https://google.com");
});

Кроме этого, мы можем видоизменять ответы от заранее неизвестных источников. Например, давайте модифицируем все скрипты, загружаемые на google.com:

it("should modify response from google.com", async function () {
// Первым аргументом указываем, что будем перехватывать абсолютно все запросы
const mock = await this.browser.mock("**", {
headers: headers => {
// Фильтруем только запросы, в которых заголовок "content-type"
// содержит значения "text/javascript" или "application/javascript"
return (
headers["content-type"] &&
/^(text|application)\/javascript/.test(headers["content-type"])
);
},
});

mock.respond(req => {
// В конец каждого скрипта добавляем свой console.log
return (req.body += `\nconsole.log("This script was modified in realtime");`);
});

await this.browser.url("https://google.com");
});

Пример 6: перехватываем все запросы к ресурсу yandex.ru и собираем список всех загружаемых урлов

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

it("should mock yandex.ru and log all loaded urls", async function () {
// Перехватываем абсолютно все запросы
const mock = await this.browser.mock("**");

await this.browser.url("https://yandex.ru");

// mock.calls содержит не только информацию о посещенном урле,
// Но также ответ от источника, хэдеры запроса, хэдеры ответа и т. д.
const urls = mock.calls.map(({ url }) => url);

console.log("visited urls:", JSON.stringify(urls, null, "\t"));
console.log("count of visited urls:", urls.length);
});

Скорее всего, ваши тесты сложнее этих примеров и у вас будут различные клики в элементы, которые открываются в новых табах. В таких случаях предыдущий код ничего не узнает об открытии новых вкладок, а также о том, что в них тоже нужно собрать список урлов. Поэтому для такого случая нужно использовать API puppeteer'а:

it("should mock yandex.ru and log all loaded urls (using puppeteer)", async function () {
// В этой переменной будем накапливать список всех урлов
const urls = [];

// Хелпер, который на вход ожидает инстанс страницы из puppeteer:
// https://pptr.dev/#?product=Puppeteer&version=v10.1.0&show=api-class-page
function urlsHandler(page) {
// Подписываемся на все запросы, которые будут происходить на переданной странице
page.on("request", req => {
urls.push(req.url());
});
}

// Получаем инстанс puppeteer'а
const puppeteer = await this.browser.getPuppeteer();

// Получаем все открытые страницы на текущий момент
const pages = await puppeteer.pages();

// Подписываемся на все запросы, которые будут происходить на этих страницах
await Promise.all(pages.map(p => urlsHandler(p)));

// Подписываемся на открытие новой страницы
puppeteer.on("targetcreated", async target => {
// Проверяем, что открытый таргет, действительно, является новой страницей
const page = await target.page();

if (!page) {
return;
}

// Так как новая страница открывается уже с каким-то урлом,
// то его необходимо записывать явно (подписка на запрос его не обнаружит)
urls.push(target.url());

// Подписываемся на все запросы, которые будут происходить на новой вкладке
urlsHandler(page);
});

await this.browser.url("https://yandex.ru");

// Находим первым элемент в списке сервисов (на тот момент была страница с футболом)
const elem = await this.browser.$(".services-new__list-item > a");

// Выполняем клик в сервис, который открывается в новой вкладке
await elem.click();

console.log("visited urls:", JSON.stringify(urls, null, "\t"));
console.log("count of visited urls:", urls.length);
});

Пример 7: мокаем ресурс google.com во всех тестах Хрома

Для того, чтобы не мокать одни и те же ресурсы явно во всех тестах, можно воспользоваться плагином testplane-global-hook. Настроив его соответствующим образом в конфиге testplane:

// .testplane.conf.js
module.exports = {
plugins: {
"testplane-global-hook": {
enabled: true,

beforeEach: async function () {
// Проверяем, что имя браузера не начинается на "chrome"
if (!/^chrome$/i.test(this.browser.capabilities.browserName)) {
return;
}

// Мокаем поход на google.com
const mock = await this.browser.mock("https://google.com");
mock.respond("hello world", { fetchResponse: false });
},

afterEach: function () {
// Очищаем все моки в текущей сессии
this.browser.mockRestoreAll();
},
},

// другие плагины testplane...
},

// другие настройки testplane...
};

А код теста теперь будет содержать только переход по URL:

// Явно укажем, чтобы тест выполнялся только в браузерах, название которых начинается с chrome
testplane.only.in(/^chrome/);
it("should mock google.com inside global before each", async function () {
await this.browser.url("https://google.com");
});

Ещё примеры использования можно посмотреть в руководстве "Mocks and Spies" на сайте webdriverio.