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:
- Where to look:
testDir: './tests/e2e' - How to run:
fullyParallel: true - Environment:
webServerconfig 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:
- Directory Scanning: It looks inside
testDir(configured as./tests/e2e). - Pattern Matching: It looks for files ending in
.spec.ts,.test.ts, etc.- Note: This is why
fixtures.tsis ignored—it doesn’t match the pattern.
- Note: This is why
- Parsing: It parses matching files for
test()andtest.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.ts │
│ testDir: './tests/e2e' │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 2. Scan directory: tests/e2e/ │
│ ├── fixtures.ts │
│ ├── playwright.spec.ts ← ✅ MATCH │
│ └── README.md │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 3. Parse playwright.spec.ts │
│ - Find test.describe() blocks │
│ - Find test() calls │
│ - Count: 2 suites, ~10 tests │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 4. Execute tests │
│ - Run each test() function │
│ - Use fixtures from fixtures.ts │
└─────────────────────────────────────────┘
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()
┌─────────────────────────────────────────┐
│ @playwright/test │
│ export const test = { ... } │
│ - page fixture │
│ - context fixture │
│ - browser fixture │
│ - etc. │
└─────────────────────────────────────────┘
│
│ import { test as base }
▼
┌─────────────────────────────────────────┐
│ fixtures.ts │
│ │
│ base.extend<Fixtures>({ │
│ dbPool: ... │
│ testUser: ... │
│ authenticatedPage: ... │
│ }) │
│ │
│ Result: NEW test object with: │
│ ✅ All base fixtures (page, etc.) │
│ ✅ Your custom fixtures │
└─────────────────────────────────────────┘
│
│ export const test
▼
┌─────────────────────────────────────────┐
│ playwright.spec.ts │
│ import { test } from './fixtures' │
│ │
│ test('My test', async ({ │
│ page, ← from base │
│ testUser, ← from extend │
│ authenticatedPage ← from extend │
│ }) => { ... }) │
└─────────────────────────────────────────┘
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):
dbPool(no dependencies)testUser(needsdbPool)testSentence(needsdbPool)page(built-in, created automatically)authenticatedPage(needspage,testUser,dbPool)
Phase 3: Setup (Bottom-Up)
Fixtures are initialized in dependency order:
dbPool: Connects to Postgres.testUser: UsesdbPoolto insert a user via API/DB.page: Playwright launches the browser context.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:
authenticatedPage: (Cleanup logic, if any).page: Browser context closes.testUser: User data is deleted from the DB.dbPool: Database connection closes.
Complete Lifecycle Timeline
Time →
│
├─ [Global Setup]
│ └─ webServer: cargo run starts
│
├─ [Test 1 Starts]
│ ├─ dbPool setup
│ ├─ testUser setup
│ ├─ page setup
│ ├─ authenticatedPage setup
│ │
│ ├─ [TEST RUNS]
│ │ └─ Your test code executes
│ │
│ ├─ authenticatedPage teardown
│ ├─ page teardown
│ ├─ testUser teardown
│ └─ dbPool teardown
│
├─ [Test 2 Starts]
│ ├─ dbPool setup (NEW connection)
│ ├─ testUser setup (NEW user)
│ ├─ page setup (NEW page)
│ ├─ authenticatedPage setup
│ │
│ ├─ [TEST RUNS]
│ │
│ └─ [Teardown in reverse]
│
└─ [All Tests Complete]
└─ webServer stops (if not reused)
Detailed Fixture Execution Flow
For a test like test('My test', async ({ authenticatedPage, testSentence }) => { ... }):
┌─────────────────────────────────────────────────────────────┐
│ 1. Playwright resolves dependencies │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 2. Create dbPool fixture │
│ dbPool: async ({}, use) => { │
│ const pool = new pg.Pool(...); ← SETUP │
│ await use(pool); ← Test hasn't started yet! │
│ await pool.end(); ← TEARDOWN (after test) │
│ } │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 3. Create testUser fixture (needs dbPool) │
│ testUser: async ({ dbPool }, use) => { │
│ const user = await createTestUser(dbPool); ← SETUP │
│ await use(user); ← Test hasn't started yet! │
│ await cleanupUser(...); ← TEARDOWN (after test) │
│ } │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 4. Create page fixture (built-in, automatic) │
│ - Opens browser context │
│ - Creates new page │
└─────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────--─┐
│ 5. Create authenticatedPage fixture │
│ authenticatedPage: async ({ page, testUser, dbPool }) => {│
│ │
│ // SETUP PHASE (runs BEFORE test) │
│ const sentences = await createTestSentences(...); │
│ await loginUser(page, testUser, baseUrl); │
│ │
│ await use(page); ← TEST RUNS HERE │
│ │
│ // TEARDOWN PHASE (runs AFTER test) │
│ for (const sentence of sentences) { │
│ await cleanupSentence(...); │
│ } │
│ } │
└─────────────────────────────────────────────────────────────--┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 6. Test executes │
│ await authenticatedPage.goto(...); │
│ // ... test code ... │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 7. Teardown in REVERSE order (LIFO - Last In First Out) │
│ - authenticatedPage cleanup (delete sentences) │
│ - page cleanup (close browser) │
│ - testUser cleanup (delete user) │
│ - dbPool cleanup (close 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...
2. User created: [email protected]
3. About to call use()...
┌─────────────────────────────────┐
│ TEST: Got user: test_abc123... │ ← Test runs here
│ ... test code executes ... │
│ TEST: Finished │
└─────────────────────────────────┘
4. use() returned, test is done!
5. Teardown: Cleaning up...
6. 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 registertest is a regression test that guards the critical registration path.
The Test Pyramid
/\
/E2E\ ← E2E tests (playwright.spec.ts)
/------\
/Integration\ ← Integration tests (integration_handlers.rs)
/------------\
/ Unit \ ← Unit tests (in src/ modules)
/----------------\
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
Pattern 1: Feature-Based (Recommended for Larger Projects)
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:
- Reading
testDirfrom config (./tests/e2e) - Finding files matching
*.spec.*or*.test.*patterns - Parsing those files for
test()andtest.describe()calls - Executing the discovered tests
In our case:
- Config:
testDir: './tests/e2e' - Test file:
playwright.spec.ts(matches pattern) - Tests found: All
test()calls insidetest.describe()blocks - Helper file:
fixtures.ts(ignored, but imported by tests)
Understanding Fixture Lifecycle
await use()pauses the fixture and runs the test- Dependencies are resolved bottom-up (dependencies first)
- Teardown runs in reverse order (LIFO)
- Each test gets fresh fixture instances (test-scoped)
- 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
testfromfixtures.ts - It enables using custom fixtures like
testUser,dbPool, andauthenticatedPagein 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:
- Isolated: Every test gets a fresh user and database state.
- Clean: No global setup/teardown mess in spec files.
- Typed: TypeScript knows exactly what data is available in each test.
- Maintainable: Fixtures are reusable and composable.
- 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.