Как отслеживать и перехватывать сетевые запросы и ответы
Обзор
Данный рецепт работает только при использовании 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.