Uncovering the Magic Behind Playwright's Fixtures API
One of the best ways to really learn a tool is to understand how it works internally. With most JavaScript libraries, I can usually get a rough idea of the implementation from the API design alone, without opening the source code. Playwright’s fixtures API, however, was harder to reason about. A minimal test looks like this:
import { test, expect } from "@playwright/test";
test("basic test", async ({ page }) => { await page.goto("https://playwright.dev/");
await expect(page).toHaveTitle(/Playwright/);});In this example, we request the page fixture from Playwright and use it in the test. At first glance, nothing unusual is happening. Playwright passes an object with a set of fixtures, including page. A simplified implementation of the test function might look like this:
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...}If only it were that simple. The documentation says:
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.
In other words, Playwright fixtures are lazy. If page is not used, Playwright skips its initialization and saves some test execution time. But in the example above, every field of the fixtures object is initialized before the test starts. How can we avoid that? How does Playwright make fixtures lazy?
Proxy-based solution
One possible solution is to use Proxy to track which fields are accessed inside the test body. That would let us initialize only the fields that are actually needed and skip the rest. For simplicity, let’s use getters in the example below. A Proxy-based version would be a more general form of the same idea:
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...}Now page is initialized only if the test explicitly accesses that field. It looks as if the problem is solved and fixtures are lazy. But this no longer behaves like Playwright’s API. The context.newPage() method is asynchronous and returns a Promise, so the user would have to await the fixture before using it. That makes the API less convenient:
import { test, expect } from "@playwright/test";
test("basic test", async (fixtures) => { const page = await fixtures.page;
await page.goto("https://playwright.dev/");
await expect(page).toHaveTitle(/Playwright/);});In Playwright, however, the page fixture is already initialized before the test body runs, so no await is needed. How can we get the same behavior? We need to know whether the test uses page before we prepare it. But if we rely on property access to detect that, we have to run the test first. This gives us the classic chicken-and-egg problem. So how does Playwright solve it?
If we zoom out a little, the problem becomes a simple question: how can we know which parameters a function accepts without calling it?
How to get function parameters without calling the function
The missing piece is that Playwright can figure out which fixtures fields the test function needs without calling that function. The documentation says:
Playwright Test looks at each test declaration, analyses the set of fixtures the test needs and prepares those fixtures specifically for the test.
There is another important detail: Playwright effectively forces us to access fixtures through parameter destructuring:
// ✅ Valid use of fixturestest("correct", async ({ page }) => {});
// ❌ This will throw an errortest("not correct", async (fixtures) => { const { page } = fixtures;});If we don’t follow this pattern, Playwright shows the error “First argument must use the object destructuring pattern”. This requirement is almost certainly tied to how Playwright understands which fixtures we are requesting. At first, I assumed Playwright did this in a preliminary analysis step: parse the file, find test functions in the AST, and extract their arguments. In practice, there is no separate preparation phase. Playwright reads the declaration while the tests are being loaded and registered.
The key to reading function parameters without calling the function is Function.prototype.toString(). It gives Playwright the function’s source code as a string. From there, Playwright can parse something like async ({ page }) => {...} and extract the fixture names used by the test.
Here is a simplified version of innerFixtureParameterNames:
function splitByComma(str) {20 collapsed lines
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) {23 collapsed lines
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);}This explains the “First argument must use the object destructuring pattern” requirement. Without it, those parameters would be much harder to extract. The approach is clever, but it also made me wonder how transparent it is for the user (the API becomes more magical), and how reliable it would be in edge cases.
Testing the boundaries of the fixtures API
Those reliability doubts came from a few potential weak spots.
Different runtimes
Function.prototype.toString() may look suspicious, but it is part of the standard and is well supported by browsers and server-side runtimes. This makes it a reasonable dependency for Playwright, even though the whole idea still feels unusual.
Different kinds of functions in JavaScript
JavaScript gives us many ways to declare functions:
function fn({ page, browser }) {}async function asyncFn({ page, browser }) {}function* generatorFn({ page, browser }) {}const arrowFn = ({ page, browser }) => {};const asyncArrowFn = async ({ page, browser }) => {};Because of a carefully chosen regular expression, innerFixtureParameterNames supports all of these variants:
const match = text.match(/(?:async)?(?:\s+function)?[^(]*\(([^)]*)/);Minifiers
In practice, source code often reaches the runtime only after a build step. Transformers and minifiers may rewrite function signatures, especially in browser-oriented code. Can that break this API? To check, I tried Terser and esbuild with the minify flag. The output looked like this:
// 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 }) => {};In this experiment, the minifiers only changed function signatures by replacing longer identifiers like foo and bar with shorter names like o and n. innerFixtureParameterNames accounts for this syntax and handles the code correctly. Still, I am not fully convinced that every possible transformation is safe for this API.
Conclusion
Extracting function parameters before running the function is a clever and interesting approach. It improves the DX and makes the test API feel more direct. At the same time, it feels a little magical, which can violate the principle of least astonishment.
It also makes some patterns harder to use. Function composition is one example:
function noThrow(fn) { return () => { try { return fn(); } catch {} };}
function fn({ foo, bar }) {}
innerFixtureParameterNames(noThrow(fn));In this case, innerFixtureParameterNames predictably fails with an error, because it receives a wrapper function instead of fn itself. For Playwright, though, this scenario is not especially relevant.
I think the Playwright team made a good choice by adopting this API: it fits the test function very well. But I can’t easily think of another library where the same approach would feel just as justified. Even after writing this article, I still have mixed feelings about it. There is a little more magic here than I would like.