Modernizing your test automation from outdated frameworks like Protractor or Selenium is a smart move, but without readable and reusable code, even the best test tools can become maintenance nightmares.
In our journey of migrating from Protractor to Playwright, we quickly realized the importance of simplifying repetitive setup logic across dozens of test specs. Our test suites combined UI interactions with API validations, executed in a specific sequence to maintain event order and test data integrity.
This post demonstrates how introducing custom fixtures in Playwright dramatically improved our test readability and helped us adhere to DRY (Don't Repeat Yourself) principles - making our Playwright tests easier to maintain, scale, and understand.
💡 At Vayner Systems, we help teams transition from stale frameworks and build modern, scalable QA foundations with Playwright. Contact us to elevate your test automation with clean architecture and best practices.
The problem: repeated setup logic across 30+ Specs
Here’s a simplified version of what our original test.beforeAll setup looked like:
test.beforeAll(async ({ browser }) => { page = await browser.newPage(); splash = new SplashPage(page); sidebar = new SidebarPage(page); // ... initialize 10+ more page objects await page.goto(env.users['apiUser10'].tokenUrl!); await splash.waitForSyncAndOfflineReady(); await splash.openCreateProjectForm(); await sidebar.createProject(projectName, fullName); });
And then test.beforeEach to init API client, and test.afterAll to close the page:
test.beforeEach(({ request }) => { api = new ApiService(request); }); test.afterAll(async () => { await page.close(); });
This pattern repeated across multiple test files, creating unnecessary complexity and duplication. We decided to introduce custom fixtures to encapsulate this behavior.
Phase 1: Create a loginAndCreateProject fixture
We introduced a fixture that encapsulates all initialization steps: navigation, synchronization, project creation, and returning all required page objects.
interface LoginFixture { loginAndCreateProject(params: LoginInput): Promise<AppPages>; } export const test = base.extend<LoginFixture>({ loginAndCreateProject: async ({ browser }, use) => { const loginAndCreateProject = async ({ projectName, user }: LoginInput): Promise<AppPages> => { const page = await browser.newPage(); const splash = new SplashPage(page); const sidebar = new SidebarPage(page); await splash.goTo(env.users[user].tokenUrl!, true); await splash.waitForSyncAndOfflineReady(); await splash.openCreateProjectForm(); await sidebar.createProject(projectName, fullName); return { page, sidebar, // All necessary page objects... }; }; await use(loginAndCreateProject); } });
Fixture Usage in Tests
test.beforeAll(async ({ loginAndCreateProject }) => { ({ page, menu, bucket2, ... } = await loginAndCreateProject({ projectName: projectName, user: 'apiUser10', })); });
//... test spec code
test.afterAll(async() => {
​ await page.close();
});
Better? Yes. But we still had an afterAll block outside the fixture and parameters passed explicitly to the fixture, creating some tight coupling.
Phase 2: Parameterize and Finalize the Fixture
We refactored further by:
- Moving user and project parameters to a shared testCaseInput fixture
- Managing the browser lifecycle via worker-scoped browser fixture
- Moving teardown logic into the fixture itself
- Removing the need for beforeAll by handling setup in the first test
export const test = base.extend<LoginFixture & TestCaseInput>({ testCaseInput: [ { projectName: 'defaultName', userId: 'defaultUser', }, { auto: true } ], browser: [ async ({}, use) => { const browser = await chromium.launch(); await use(browser); await browser.close(); }, { scope: 'worker' } ], loginAndCreateProject: async ({ browser, testCaseInput }, use) => { const loginAndCreateProject = async (): Promise<AppPages> => { console.log(`Logging in with user ${testCaseInput.userId}`); const page = await browser.newPage(); const splash = new SplashPage(page); const sidebar = new SidebarPage(page); await splash.goTo(url); await splash.waitForSyncAndOfflineReady(); await splash.openCreateProjectForm(); await sidebar.createProject(testCaseInput.projectName, fullName); return { page, sidebar, // Other page objects }; }; await use(loginAndCreateProject); } });
In the test spec file:
//pass custom spec related values to the fixture:
test.use({ testCaseInput: { projectName: 'Auto QA Project', userId: 'apiUser10', } });
//passed custom fixture to first test in spec & removed before all test('Initial setup', async ({ loginAndCreateProject }) => { ({ menu, bucket2, ... } = await loginAndCreateProject()); const project = await api.findProjectByName(projectName); await menu.openBucket2(); });
This is now much cleaner than the original version, just compare samples below. No more noisy setup logic or duplicated teardown calls. Each spec remains focused on behavior, not configuration.
Initial version:
After code refactoring & using custom fixtures:
Conclusion
By introducing custom fixtures in Playwright, we achieved better code organization, reduced duplication, and drastically improved the readability and maintainability of our test suites. Clean fixtures mean faster onboarding, simpler debugging, and more robust automation.
Looking to scale your automation setup or modernize your stack? Contact Vayner Systems to get expert help with custom software and QA solutions that combine speed, quality, and clarity.