A Deep Dive into Playwright: Lifecycle, Fixtures, and Test Architecture

In building the Kanjinomic Japanese vocabulary learning platform, I’ve implemented an End-to-End (E2E) testing suite using Playwright. This post documents the architectural decisions, the “magic” behind Playwright’s fixture system, and the lifecycle of a test run.

1. The Architecture of a Playwright Project

Unlike a simple script folder, a scalable Playwright setup requires structure. Here is the anatomy of our tests/e2e directory:

tests/e2e/
├── fixtures.ts          # The Engine Room: Custom fixture definitions
├── playwright.spec.ts   # The Specs: Actual test scenarios
└── README.md            # Documentation

Configuration (playwright.config.ts)

The entry point is playwright.config.ts. It acts as the command center, telling Playwright:

  1. Where to look: testDir: './tests/e2e'
  2. How to run: fullyParallel: true
  3. Environment: webServer config to spin up the backend automatically.

2. Test Discovery: How Playwright “Finds” Code

When you run bunx playwright test, Playwright doesn’t just run every file. It follows a specific discovery process:

  1. Directory Scanning: It looks inside testDir (configured as ./tests/e2e).
  2. Pattern Matching: It looks for files ending in .spec.ts, .test.ts, etc.
    • Note: This is why fixtures.ts is ignored—it doesn’t match the pattern.
  3. Parsing: It parses matching files for test() and test.describe() blocks.

This separation allows us to keep helper logic (fixtures.ts) right next to the tests without Playwright trying to execute it as a test file.

Visual Discovery Flow

  1. Read playwright.config.tsPick up testDir: './tests/e2e'.
  2. Scan the directoryWalk tests/e2e/ and test each entry against the file pattern.
    • fixtures.ts — no match
    • playwright.spec.tsmatch
    • README.md — no match
  3. Parse the matching filesFind test.describe() blocks and test() calls — here, 2 suites and roughly 10 tests.
  4. ExecuteRun each test() function, resolving fixtures from fixtures.ts as it goes.

3. The Power of Fixtures

The most confusing yet powerful part of Playwright is Fixtures. In traditional testing, you might have beforeEach and afterEach hooks cluttering your test files. Playwright replaces this with a dependency injection system.

The base.extend Pattern

We extend the default test object.

import { test as base } from '@playwright/test';

// We define a type for our custom world
type Fixtures = {
  dbPool: pg.Pool;
  testUser: TestUser;
  authenticatedPage: Page;
};

// We create a NEW test object with our fixtures baked in
export const test = base.extend<Fixtures>({
  // Fixture definitions go here...
});

This pattern means that any test importing our custom test object automatically has access to our custom environment, with full TypeScript support.

Visual Flow of base.extend()

  1. @playwright/testShips test with the built-in fixtures: page, context, browser, and the rest.
    imported as import { test as base }
  2. fixtures.tsbase.extend<Fixtures>({ dbPool, testUser, authenticatedPage }) returns a new test object carrying both every base fixture and your custom ones.
    exported as export const test
  3. playwright.spec.tsImports that test and destructures whichever fixtures it needs — page from the base, testUser and authenticatedPage from your extension — with full type support.

4. The Test Lifecycle: A Timeline

Understanding the order of operations is critical for debugging. Here is the lifecycle of a single test execution:

Phase 1: Global Setup

Before any test file is touched, Playwright starts the webServer (our Rust backend). This happens once.

Phase 2: Dependency Resolution (The Graph)

For a test requesting ({ authenticatedPage }), Playwright builds a graph:

authenticatedPage depends on:
  ├── page (built-in fixture)
  ├── testUser (custom fixture)
  └── dbPool (custom fixture)

testUser depends on:
  └── dbPool (custom fixture)

testSentence depends on:
  └── dbPool (custom fixture)

Resolution Order (Bottom-Up):

  1. dbPool (no dependencies)
  2. testUser (needs dbPool)
  3. testSentence (needs dbPool)
  4. page (built-in, created automatically)
  5. authenticatedPage (needs page, testUser, dbPool)

Phase 3: Setup (Bottom-Up)

Fixtures are initialized in dependency order:

  1. dbPool: Connects to Postgres.
  2. testUser: Uses dbPool to insert a user via API/DB.
  3. page: Playwright launches the browser context.
  4. authenticatedPage: Logs the user in and navigates to the dashboard.

Phase 4: Execution

The test function finally runs.

Phase 5: Teardown (Top-Down / LIFO)

Once the test finishes (pass or fail), fixtures are torn down in reverse order:

  1. authenticatedPage: (Cleanup logic, if any).
  2. page: Browser context closes.
  3. testUser: User data is deleted from the DB.
  4. dbPool: Database connection closes.

Complete Lifecycle Timeline

  1. Global setupThe webServer starts — cargo run — once for the entire run.
  2. Test 1Fixtures set up bottom-up, the test body runs, then everything tears down in reverse.
    • setup: dbPooltestUserpageauthenticatedPage
    • test code executes
    • teardown: authenticatedPagepagetestUserdbPool
  3. Test 2The same cycle, with entirely fresh instances — a new connection, a new user, a new page. Nothing carries over from Test 1.
  4. All tests completeThe webServer stops, unless it was configured to be reused.

Detailed Fixture Execution Flow

For a test like test('My test', async ({ authenticatedPage, testSentence }) => { ... }):

  1. Resolve the dependency graphPlaywright works out which fixtures the test asked for, and what those in turn depend on.
  2. Create dbPoolOpens the pool, then parks at await use(pool) — the test still hasn’t started.
  3. Create testUserNeeds dbPool. Inserts the user, then parks at await use(user).
  4. Create pageBuilt in and automatic: opens a browser context and a fresh page.
  5. Create authenticatedPageNeeds page, testUser, dbPool. Seeds sentences, logs the user in, then parks at await use(page).
  6. The test body runsEvery fixture is now suspended mid-function, holding its resources open for the duration of await authenticatedPage.goto(…) and whatever follows.
  7. Teardown, in reverse (LIFO)Each use() call returns and the second half of each fixture finally executes.
    • authenticatedPage — delete seeded sentences
    • page — close the browser
    • testUser — delete the user
    • dbPool — close the connection

5. The Magic of use()

In a fixture, use is not just a return statement. It is a callback that pauses execution.

testUser: async ({ dbPool }, use) => {
  // --- SETUP PHASE ---
  console.log('Creating user...');
  const user = await createTestUser(dbPool);

  // --- HANDOFF ---
  // This passes 'user' to the test and PAUSES this function
  await use(user);

  // --- TEARDOWN PHASE ---
  // This runs ONLY after the test completes
  console.log('Cleaning up user...');
  await cleanupUser(dbPool, user.id);
},

This “Setup -> Yield -> Teardown” pattern within a single function ensures that cleanup logic is co-located with setup logic, preventing data leaks and “flaky” tests.

Visual Execution Timeline of use()

// Your fixture definition
testUser: async ({ dbPool }, use) => {
  console.log('1. Setup: Creating user...');
  const user = await createTestUser(dbPool, baseUrl);
  console.log('2. User created:', user.email);
  
  console.log('3. About to call use()...');
  await use(user);  // ← MAGIC HAPPENS HERE
  console.log('4. use() returned, test is done!');
  
  console.log('5. Teardown: Cleaning up...');
  await cleanupUser(dbPool, user.id);
  console.log('6. Cleanup complete');
}

// Your test
test('My test', async ({ testUser }) => {
  console.log('TEST: Got user:', testUser.email);
  // ... test code ...
  console.log('TEST: Finished');
});

Execution Order:

  1. Setup: Creating user…The fixture body runs top to bottom.
  2. User created: [email protected]
  3. About to call use()The fixture is about to hand control over and suspend itself.
  4. The test runs hereTEST: Got user: test_abc123…
    … test code executes …
    TEST: Finished
  5. use() returned, test is done!Control resumes on the line after use().
  6. Teardown: Cleaning up…
  7. Cleanup complete

Real Example: Complete Fixture Trace

When you write:

test('Submitting answer shows feedback', async ({ authenticatedPage, testSentence }) => {
  await authenticatedPage.goto(`${BASE_URL}/learn`);
  // ... test code
});

What actually happens:

// 1. dbPool fixture starts
const pool = new pg.Pool({ connectionString: DATABASE_URL });
// (pauses at await use(pool))

// 2. testUser fixture starts (needs dbPool)
const user = await createTestUser(dbPool, baseUrl);
// (pauses at await use(user))

// 3. testSentence fixture starts (needs dbPool)
const sentence = await createTestSentence(dbPool);
// (pauses at await use(sentence))

// 4. page fixture starts (built-in)
// Browser opens, page created
// (pauses at await use(page))

// 5. authenticatedPage fixture starts
const sentences = await createTestSentences(dbPool, 3, 'n5');
await loginUser(page, testUser, baseUrl);
// (pauses at await use(page))

// 6. NOW YOUR TEST RUNS
await authenticatedPage.goto(`${BASE_URL}/learn`);
// ... rest of test code ...

// 7. Test finishes, teardown starts (REVERSE ORDER)

// 7a. authenticatedPage teardown
for (const sentence of sentences) {
  await cleanupSentence(dbPool, sentence.id);
}

// 7b. page teardown (browser closes)

// 7c. testSentence teardown
await cleanupSentence(dbPool, sentence.id);

// 7d. testUser teardown
await cleanupUser(dbPool, user.id);

// 7e. dbPool teardown
await pool.end();

6. Integration vs. Regression

We use Playwright for both:

  • Integration Testing: Our tests verify the integration between the frontend (HTMX), the backend (Axum), and the database (Postgres). For example, creating a user verifies the entire stack works.
  • Regression Testing: By running these tests on every commit, we ensure that new changes haven’t broken existing features. The User can register test is a regression test that guards the critical registration path.

The Test Pyramid

The test pyramid Three tiers. A wide base of fast unit tests in src modules, a narrower band of integration tests in integration_handlers.rs, and a small cap of end-to-end tests in playwright.spec.ts. E2E Integration Unit E2E tests playwright.spec.ts Integration tests integration_handlers.rs Unit tests in src/ modules
Fewer and slower towards the top; more numerous and faster towards the base.

Test Types:

  • Unit Tests: Fast, isolated (e.g., scoring function)
  • Integration Tests: Medium speed, test interactions (e.g., API + DB)
  • E2E Tests: Slower, full user journey (e.g., Playwright)

How Tests Overlap

A test can be both integration and regression:

// From your state_consistency.rs
#[tokio::test]
async fn test_submission_creates_consistent_state() {
    // This is BOTH:
    // 1. Integration test: Tests handler + service + DB + transactions
    // 2. Regression test: Ensures state consistency doesn't break after changes
    
    // Test that submission updates:
    // - submissions table
    // - user_progress table  
    // - daily_stats table
    // All in one transaction (integration)
    // And this should never break (regression)
}

7. Common Folder Organization Patterns

tests/
├── e2e/
│   ├── auth/
│   │   ├── login.spec.ts
│   │   ├── register.spec.ts
│   │   └── fixtures.ts
│   ├── learning/
│   │   ├── sentence.spec.ts
│   │   ├── submission.spec.ts
│   │   └── fixtures.ts
│   ├── shared/
│   │   ├── fixtures.ts
│   │   └── helpers.ts
│   └── setup/
│       └── global-setup.ts

Pattern 2: Type-Based

tests/
├── e2e/
│   ├── fixtures.ts        # All fixtures
│   ├── playwright.spec.ts # All tests
│   └── helpers.ts         # Utility functions

Pattern 3: Hybrid (Scales Well)

tests/
├── e2e/
│   ├── fixtures/
│   │   ├── auth.fixtures.ts
│   │   ├── learning.fixtures.ts
│   │   └── index.ts
│   ├── specs/
│   │   ├── auth.spec.ts
│   │   └── learning.spec.ts
│   ├── helpers/
│   │   ├── api.helpers.ts
│   │   └── db.helpers.ts
│   └── setup/
│       └── global-setup.ts

8. Key Takeaways Summary

Understanding Test Discovery

Playwright discovers tests by:

  1. Reading testDir from config (./tests/e2e)
  2. Finding files matching *.spec.* or *.test.* patterns
  3. Parsing those files for test() and test.describe() calls
  4. Executing the discovered tests

In our case:

  • Config: testDir: './tests/e2e'
  • Test file: playwright.spec.ts (matches pattern)
  • Tests found: All test() calls inside test.describe() blocks
  • Helper file: fixtures.ts (ignored, but imported by tests)

Understanding Fixture Lifecycle

  1. await use() pauses the fixture and runs the test
  2. Dependencies are resolved bottom-up (dependencies first)
  3. Teardown runs in reverse order (LIFO)
  4. Each test gets fresh fixture instances (test-scoped)
  5. Global setup (webServer) runs once before all tests

Understanding base.extend()

  • base.extend() creates a new test object that combines Playwright’s built-in fixtures with your custom fixtures
  • It’s executed when the test file imports test from fixtures.ts
  • It enables using custom fixtures like testUser, dbPool, and authenticatedPage in your tests
  • The <Fixtures> type provides TypeScript support for your custom fixtures

Without base.extend(), you’d only have Playwright’s built-in fixtures. With it, you get both built-in and custom fixtures in one test object.

Conclusion

By leveraging Playwright’s fixture system, we’ve created a test suite that is:

  1. Isolated: Every test gets a fresh user and database state.
  2. Clean: No global setup/teardown mess in spec files.
  3. Typed: TypeScript knows exactly what data is available in each test.
  4. Maintainable: Fixtures are reusable and composable.
  5. Reliable: Automatic cleanup prevents test pollution.

This architecture provides the confidence needed to iterate quickly on the Kanjinomic platform without fear of breaking critical user flows.

Resources