Один из лучших способов по-настоящему разобраться в инструменте — понять, как он устроен изнутри. С большинством JavaScript-библиотек у меня работает так: мне не нужно заглядывать в исходники, потому что по дизайну API уже можно примерно представить его реализацию. Но API фикстур в Playwright поставил меня в тупик. Минимальный тест выглядит следующим образом:
import { test, expect } from "@playwright/test";test("basic test", async ({ page }) => { await page.goto("https://playwright.dev/"); await expect(page).toHaveTitle(/Playwright/);});
В этом примере мы запрашиваем у Playwright фикстуру page и используем её в тесте. На первый взгляд ничего необычного: Playwright передаёт нам объект с набором фикстур, включая page. Вот как можно упрощённо представить реализацию функции test:
async function test(title, body) { const browser = await firefox.launch(); const context = await browser.newContext(); const page = await context.newPage(); const fixtures = { page, context, browser }; body(fixtures); // ...teardown code here...}
Но если бы всё было так просто! В документации можно прочесть следующее:
Fixtures are on-demand — you can define as many fixtures as you’d like, and Playwright Test will setup only the ones needed by your test and nothing else.
То есть фикстуры в Playwright ленивые. Если page не используется, Playwright не будет её инициализировать и тем самым сократит время выполнения теста. Но в нашем примере выше мы всегда инициализируем все поля объекта fixtures до запуска теста. Как этого избежать? Как Playwright удаётся сделать фикстуры ленивыми?
Решение на основе Proxy
Один из вариантов — использовать Proxy для отслеживания тех полей, к которым обращаются внутри тестового сценария. Это даст нам возможность инициализировать только нужные поля и пропускать остальные. Для простоты приведём пример с использованием геттеров. Код с Proxy был бы более общим вариантом той же идеи:
async function test(title, body) { const browser = await firefox.launch(); const context = await browser.newContext(); const fixtures = { get page() { return context.newPage(); }, context, browser }; body(fixtures); // ...teardown code here...}
Теперь page инициализируется только в том случае, если тест явно обратится к этому полю. Кажется, проблема решена и фикстуры стали ленивыми. Но наш API больше не совпадает с тем, что предоставляет Playwright. Метод context.newPage() асинхронный и возвращает Promise. В таком случае пользователю придётся писать await перед использованием page, а это уже не самый удобный API:
import { test, expect } from "@playwright/test";test("basic test", async (fixtures) => { const page = await fixtures.page; await page.goto("https://playwright.dev/"); // Здесь требуется вызов await await expect(page).toHaveTitle(/Playwright/);});
Но в Playwright фикстура page уже инициализирована до запуска тела теста, поэтому await не нужен. Как добиться такого же поведения? Нам нужно заранее понять, использует ли тест page. Но если мы определяем это по обращению к полю, сначала придётся запустить сам тест. Получается классическая проблема курицы и яйца. Как же Playwright удаётся её разрешить?
Если посмотреть на проблему шире, она сводится к простому вопросу: как узнать, какие параметры принимает функция, не вызывая её?
Как получить параметры функции без её вызова
Выходит, Playwright каким-то образом может узнать, какие поля fixtures понадобятся внутри тестовой функции, при этом не вызывая её. В документации мы видим:
Playwright Test looks at each test declaration, analyses the set of fixtures the test needs and prepares those fixtures specifically for the test.
Есть ещё одна важная деталь: Playwright фактически вынуждает нас обращаться к фикстурам через деструктуризацию в параметрах функции:
// ✅ Корректное использованиеtest("correct", async ({ page }) => {});// ❌ Ошибкаtest("not correct", async (fixtures) => { const { page } = fixtures;});
Если мы не соблюдаем этот паттерн, Playwright покажет ошибку “First argument must use the object destructuring pattern”. Наверняка это требование связано с тем, как Playwright понимает, какие параметры мы запрашиваем. Сначала я предположил, что Playwright делает это на этапе предварительного анализа: парсит файл, находит тестовые функции в AST и извлекает их аргументы. Но на деле отдельного этапа подготовки нет. Playwright читает объявление теста, пока тесты загружаются и регистрируются.
Секрет получения параметров функции без её вызова кроется в остроумном использовании Function.prototype.toString(). Этот метод возвращает исходный код функции в виде строки. Дальше Playwright может распарсить что-то вроде async ({ page }) => {...} и извлечь имена фикстур, используемых в тесте.
Ниже приведён упрощённый пример innerFixtureParameterNames:
function splitByComma(str) { const result = []; const stack = []; let start = 0; for (let i = 0; i < str.length; i++) { if (str[i] === "{" || str[i] === "[") { stack.push(str[i] === "{" ? "}" : "]"); } else if (str[i] === stack[stack.length - 1]) { stack.pop(); } else if (!stack.length && str[i] === ",") { const token = str.substring(start, i).trim(); if (token) result.push(token); start = i + 1; } } const lastToken = str.substring(start).trim(); if (lastToken) result.push(lastToken); return result;}function parseParams(params) { if (!params) return []; const [firstParam] = splitByComma(params); if (firstParam[0] !== "{" || firstParam[firstParam.length - 1] !== "}") { throw new Error(`First argument must use the object destructuring pattern`); } const props = splitByComma( firstParam.substring(1, firstParam.length - 1) ).map((prop) => { const colon = prop.indexOf(":"); return colon === -1 ? prop.trim() : prop.substring(0, colon).trim(); }); const restProperty = props.find((prop) => prop.startsWith("...")); if (restProperty) { throw new Error(`Rest properties are not supported in fixture parameters`); } return props;}function innerFixtureParameterNames(fn) { const text = fn.toString(); const match = text.match(/(?:async)?(?:\s+function)?[^(]*\(([^)]*)/); if (!match) return []; const trimmedParams = match[1].trim(); return parseParams(trimmedParams);}
Это объясняет требование “First argument must use the object destructuring pattern”: иначе эти параметры было бы намного сложнее извлекать. Подход остроумный, но возникают сомнения и в его прозрачности для пользователя, и в надёжности в крайних случаях.
Проверяем границы API фикстур
Эти сомнения насчёт надёжности были связаны с несколькими потенциальными проблемами.
Разные среды исполнения
Function.prototype.toString() выглядит неоднозначно, но это часть стандарта, и она хорошо поддерживается браузерами и серверными средами исполнения. Поэтому Playwright может на неё опираться, даже если сама идея всё равно выглядит необычно.
Разные виды функций в JavaScript
В JavaScript есть много способов объявить функцию:
function fn({ page, browser }) {}async function asyncFn({ page, browser }) {}function* generatorFn({ page, browser }) {}const arrowFn = ({ page, browser }) => {};const asyncArrowFn = async ({ page, browser }) => {};
Благодаря аккуратно подобранному регулярному выражению innerFixtureParameterNames поддерживает все эти варианты:
const match = text.match(/(?:async)?(?:\s+function)?[^(]*\(([^)]*)/);
Минификаторы
На практике код часто доходит до среды выполнения только после этапа сборки. Трансформаторы и минификаторы могут переписывать сигнатуры функций, особенно в коде для браузера. Могут ли они сломать такой API? Я попробовал Terser и esbuild с флагом minify. На выходе получилось следующее:
// beforeexport function fn({ foo, bar }) {}export async function asyncFn({ foo, bar }) {}export function* generatorFn({ foo, bar }) {}export const arrowFn = ({ foo, bar }) => {};export const asyncArrowFn = async ({ foo, bar }) => {};// afterexport function fn({ foo: o, bar: n }) {}export async function asyncFn({ foo: o, bar: n }) {}export function* generatorFn({ foo: o, bar: n }) {}export const arrowFn = ({ foo: o, bar: n }) => {};export const asyncArrowFn = async ({ foo: o, bar: n }) => {};
В этом эксперименте изменения в сигнатурах функций свелись к замене длинных идентификаторов foo и bar на более короткие имена o и n. innerFixtureParameterNames учитывает такой синтаксис и корректно обрабатывает код. Но я бы всё равно не стал считать, что любая трансформация безопасна для такого API.
Заключение
Подход с выделением параметров функции до её запуска очень интересный и остроумный. Он улучшает DX и делает тестовый API более прямым. При этом он кажется немного магическим, что может нарушать принцип наименьшего удивления.
Кроме того, такой подход усложняет использование некоторых паттернов. Один из примеров — композиция функций:
function noThrow(fn) { return () => { try { return fn(); } catch {} };}function fn({ foo, bar }) {}innerFixtureParameterNames(noThrow(fn));
В таком случае innerFixtureParameterNames ожидаемо завершится ошибкой, потому что на вход подаётся не fn, а функция-обёртка вокруг неё. Впрочем, для Playwright этот сценарий не слишком применим.
Думаю, команда Playwright приняла удачное решение, взяв такой API на вооружение: он отлично ложится на функцию test. Но мне трудно придумать другую библиотеку, где похожий подход был бы так же оправдан. Даже после написания этой статьи он всё ещё вызывает у меня внутренние противоречия. В нём всё-таки чуть больше магии, чем мне бы хотелось.
ссылка на оригинал статьи https://habr.com/ru/articles/1047326/