[번역] Playwright Fixtures API의 마법 파헤치기

I
Inkyu Oh

Front-End2026.06.23

Ivakin - 2026년 5월 23일


도구를 진정으로 배우는 가장 좋은 방법 중 하나는 내부 동작 방식을 이해하는 것입니다. 대부분의 JavaScript 라이브러리의 경우, 소스 코드를 열어보지 않고도 API 설계만으로 구현 방식을 대략적으로 짐작할 수 있습니다. 하지만 Playwright의 fixtures API는 추론하기가 더 어려웠습니다. 최소한의 테스트 코드는 다음과 같습니다.
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) 코드...
}
하지만 이렇게 간단했다면 좋았을 텐데 말이죠. 공식 문서에는 다음과 같이 적혀 있습니다.
픽스처는 온디맨드(On-demand) 방식입니다. 원하는 만큼 많은 픽스처를 정의할 수 있으며, Playwright Test는 테스트에 필요한 것들만 설정하고 나머지는 건드리지 않습니다.
즉, Playwright 픽스처는 지연(Lazy) 방식입니다. 만약 page가 사용되지 않는다면, Playwright는 초기화를 건너뛰어 테스트 실행 시간을 절약합니다. 하지만 위의 예제에서는 테스트가 시작되기 전에 fixtures 객체의 모든 필드가 초기화됩니다. 이를 어떻게 피할 수 있을까요? Playwright는 어떻게 픽스처를 지연 방식으로 만들까요?

프록시(Proxy) 기반 솔루션

한 가지 가능한 해결책은 Proxy를 사용하여 테스트 본문 내부에서 어떤 필드에 접근하는지 추적하는 것입니다. 이를 통해 실제로 필요한 필드만 초기화하고 나머지는 건너뛸 수 있습니다. 이해를 돕기 위해 아래 예제에서는 getter를 사용해 보겠습니다. 프록시 기반 버전은 이 아이디어의 더 일반적인 형태가 될 것입니다.
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);
// ...정리 코드...
}
이제 page는 테스트에서 해당 필드에 명시적으로 접근할 때만 초기화됩니다. 문제가 해결되고 픽스처가 지연 방식으로 동작하는 것처럼 보입니다. 하지만 이것은 더 이상 Playwright의 API처럼 동작하지 않습니다. context.newPage() 메서드는 비동기식이며 Promise를 반환하므로, 사용자는 픽스처를 사용하기 전에 await를 해야 합니다. 이는 API의 편의성을 떨어뜨립니다.
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/);
});
그러나 Playwright에서 page 픽스처는 테스트 본문이 실행되기 전에 이미 초기화되어 있으므로 await가 필요하지 않습니다. 어떻게 동일한 동작을 구현할 수 있을까요? 우리는 page를 준비하기 전에 테스트가 이를 사용하는지 여부를 알아야 합니다. 하지만 이를 감지하기 위해 속성 접근(Property access)에 의존한다면, 먼저 테스트를 실행해야만 합니다. 이것은 전형적인 '닭이 먼저냐 달걀이 먼저냐'의 문제입니다. 그렇다면 Playwright는 이 문제를 어떻게 해결했을까요?
조금 더 넓게 보면, 문제는 간단한 질문으로 바뀝니다. 함수를 호출하지 않고도 해당 함수가 어떤 매개변수를 받는지 어떻게 알 수 있을까요?

함수를 호출하지 않고 함수 매개변수를 가져오는 방법

빠진 조각은 Playwright가 테스트 함수를 호출하지 않고도 어떤 fixtures 필드가 필요한지 알아낼 수 있다는 점입니다. 공식 문서에는 다음과 같이 설명되어 있습니다.
Playwright Test는 각 테스트 선언을 살펴보고, 테스트에 필요한 픽스처 세트를 분석하여 해당 테스트만을 위해 특정 픽스처를 준비합니다.
또 다른 중요한 세부 사항이 있습니다. Playwright는 사실상 매개변수 구조 분해 할당(Destructuring)을 통해 픽스처에 접근하도록 강제합니다.
// ✅ 올바른 픽스처 사용법
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는 함수의 소스 코드를 문자열로 얻을 수 있습니다. 거기서부터 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);
}
이것이 왜 "첫 번째 인자는 반드시 객체 구조 분해 패턴을 사용해야 합니다"라는 요구 사항이 있는지 설명해 줍니다. 이 패턴이 없다면 매개변수를 추출하기가 훨씬 더 어려웠을 것입니다. 이 접근 방식은 영리하지만, 사용자에게 얼마나 투명한지(API가 너무 마법처럼 느껴짐), 그리고 예외적인 상황에서 얼마나 신뢰할 수 있을지 의문이 들었습니다.

픽스처 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)?[^(]*\(([^)]*)/);

미니파이어(Minifiers)

실제로 소스 코드는 빌드 단계를 거친 후에야 런타임에 도달하는 경우가 많습니다. 트랜스포머와 미니파이어는 특히 브라우저 지향 코드에서 함수 시그니처를 다시 작성할 수 있습니다. 이것이 API를 망가뜨릴 수 있을까요? 확인을 위해 Terser와 esbuild를 minify 플래그와 함께 사용해 보았습니다. 결과는 다음과 같았습니다.
// 변환 전
export 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 }) => {};

// 변환 후
export 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 }) => {};
이 실험에서 미니파이어는 foobar 같은 긴 식별자를 on 같은 짧은 이름으로 바꾸어 함수 시그니처만 변경했습니다. innerFixtureParameterNames는 이러한 구문을 고려하여 코드를 올바르게 처리합니다. 그럼에도 불구하고, 가능한 모든 변환이 이 API에 안전하다고 완전히 확신할 수는 없습니다.

결론

함수를 실행하기 전에 매개변수를 추출하는 것은 영리하고 흥미로운 접근 방식입니다. 이는 개발자 경험(DX)을 향상시키고 테스트 API를 더 직관적으로 느끼게 합니다. 동시에, 이는 약간 마법처럼 느껴져서 '최소 놀람의 원칙(Principle of least astonishment)'을 위반할 수도 있습니다.
또한 일부 패턴을 사용하기 어렵게 만듭니다. 함수 합성(Function composition)이 한 예입니다.
function noThrow(fn) {
return () => {
try {
return fn();
} catch {}
};
}

function fn({ foo, bar }) {}
innerFixtureParameterNames(noThrow(fn));
이 경우 innerFixtureParameterNamesfn 자체가 아닌 래퍼(wrapper) 함수를 전달받기 때문에 예상대로 에러와 함께 실패합니다. 하지만 Playwright의 경우, 이 시나리오는 특별히 관련이 없습니다.
Playwright 팀이 이 API를 채택한 것은 좋은 선택이었다고 생각합니다. test 함수와 매우 잘 어울리기 때문입니다. 하지만 동일한 접근 방식이 그만큼 정당화될 수 있는 다른 라이브러리는 쉽게 떠오르지 않습니다. 이 글을 쓰고 난 후에도 여전히 복합적인 감정이 듭니다. 제가 원하는 것보다 조금 더 많은 마법이 여기에 들어있네요.
0
2

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글