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
-
Read
playwright.config.tsPick uptestDir: './tests/e2e'. -
Scan the directoryWalk
tests/e2e/and test each entry against the file pattern.-
fixtures.ts— no match -
playwright.spec.ts— match -
README.md— no match
-
-
Parse the matching filesFind
test.describe()blocks andtest()calls — here, 2 suites and roughly 10 tests. -
ExecuteRun each
test()function, resolving fixtures fromfixtures.tsas 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()
-
@playwright/testShipstestwith the built-in fixtures:page,context,browser, and the rest.
imported asimport { test as base } -
fixtures.tsbase.extend<Fixtures>({ dbPool, testUser, authenticatedPage })returns a newtestobject carrying both every base fixture and your custom ones.
exported asexport const test -
playwright.spec.tsImports thattestand destructures whichever fixtures it needs —pagefrom the base,testUserandauthenticatedPagefrom 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):
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
-
Global setupThe
webServerstarts —cargo run— once for the entire run. -
Test 1Fixtures set up bottom-up, the test body runs, then everything tears down in reverse.
-
setup:
dbPool→testUser→page→authenticatedPage - test code executes
-
teardown:
authenticatedPage→page→testUser→dbPool
-
setup:
- Test 2The same cycle, with entirely fresh instances — a new connection, a new user, a new page. Nothing carries over from Test 1.
-
All tests completeThe
webServerstops, unless it was configured to be reused.
Detailed Fixture Execution Flow
For a test like test('My test', async ({ authenticatedPage, testSentence }) => { ... }):
- Resolve the dependency graphPlaywright works out which fixtures the test asked for, and what those in turn depend on.
-
Create
dbPoolOpens the pool, then parks atawait use(pool)— the test still hasn’t started. -
Create
testUserNeedsdbPool. Inserts the user, then parks atawait use(user). -
Create
pageBuilt in and automatic: opens a browser context and a fresh page. -
Create
authenticatedPageNeedspage,testUser,dbPool. Seeds sentences, logs the user in, then parks atawait use(page). -
The test body runsEvery fixture is now suspended mid-function, holding its resources open for the duration of
await authenticatedPage.goto(…)and whatever follows. -
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:
- Setup: Creating user…The fixture body runs top to bottom.
-
User created:
[email protected] -
About to call
use()…The fixture is about to hand control over and suspend itself. -
The test runs here
TEST: Got user: test_abc123…… test code executes …TEST: Finished -
use()returned, test is done!Control resumes on the line afteruse(). - Teardown: Cleaning up…
- 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
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.