import { test, expect } from "@playwright/test";test("basic test", async ({ page }) => { await page.goto("https://playwright.dev/"); await expect(page).toHaveTitle(/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는 테스트에 필요한 것들만 설정하고 나머지는 건드리지 않습니다.
page가 사용되지 않는다면, Playwright는 초기화를 건너뛰어 테스트 실행 시간을 절약합니다. 하지만 위의 예제에서는 테스트가 시작되기 전에 fixtures 객체의 모든 필드가 초기화됩니다. 이를 어떻게 피할 수 있을까요? Playwright는 어떻게 픽스처를 지연 방식으로 만들까요?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/);});page 픽스처는 테스트 본문이 실행되기 전에 이미 초기화되어 있으므로 await가 필요하지 않습니다. 어떻게 동일한 동작을 구현할 수 있을까요? 우리는 page를 준비하기 전에 테스트가 이를 사용하는지 여부를 알아야 합니다. 하지만 이를 감지하기 위해 속성 접근(Property access)에 의존한다면, 먼저 테스트를 실행해야만 합니다. 이것은 전형적인 '닭이 먼저냐 달걀이 먼저냐'의 문제입니다. 그렇다면 Playwright는 이 문제를 어떻게 해결했을까요?fixtures 필드가 필요한지 알아낼 수 있다는 점입니다. 공식 문서에는 다음과 같이 설명되어 있습니다.Playwright Test는 각 테스트 선언을 살펴보고, 테스트에 필요한 픽스처 세트를 분석하여 해당 테스트만을 위해 특정 픽스처를 준비합니다.
// ✅ 올바른 픽스처 사용법test("correct", async ({ page }) => {});// ❌ 에러가 발생합니다test("not correct", async (fixtures) => { const { page } = fixtures;});Function.prototype.toString()입니다. 이를 통해 Playwright는 함수의 소스 코드를 문자열로 얻을 수 있습니다. 거기서부터 Playwright는 async ({ page }) => {...}와 같은 코드를 파싱하여 테스트에서 사용된 픽스처 이름을 추출할 수 있습니다.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);}Function.prototype.toString()이 미심쩍어 보일 수 있지만, 이는 표준의 일부이며 브라우저와 서버 사이드 런타임에서 잘 지원됩니다. 따라서 아이디어 자체는 여전히 독특하게 느껴지더라도 Playwright가 의존하기에 합리적인 방식입니다.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)?[^(]*\(([^)]*)/);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 }) => {};foo나 bar 같은 긴 식별자를 o나 n 같은 짧은 이름으로 바꾸어 함수 시그니처만 변경했습니다. innerFixtureParameterNames는 이러한 구문을 고려하여 코드를 올바르게 처리합니다. 그럼에도 불구하고, 가능한 모든 변환이 이 API에 안전하다고 완전히 확신할 수는 없습니다.function noThrow(fn) { return () => { try { return fn(); } catch {} };}function fn({ foo, bar }) {}innerFixtureParameterNames(noThrow(fn));innerFixtureParameterNames는 fn 자체가 아닌 래퍼(wrapper) 함수를 전달받기 때문에 예상대로 에러와 함께 실패합니다. 하지만 Playwright의 경우, 이 시나리오는 특별히 관련이 없습니다.test 함수와 매우 잘 어울리기 때문입니다. 하지만 동일한 접근 방식이 그만큼 정당화될 수 있는 다른 라이브러리는 쉽게 떠오르지 않습니다. 이 글을 쓰고 난 후에도 여전히 복합적인 감정이 듭니다. 제가 원하는 것보다 조금 더 많은 마법이 여기에 들어있네요.아직 댓글이 없습니다.
첫 번째 댓글을 작성해보세요!