
Bmad Testarch Atdd
- 6 installs
- 87 repo stars
- Updated August 4, 2026
- bmad-code-org/bmad-method-test-architecture-enterprise
bmad-testarch-atdd is a Claude Code skill that generates red-phase acceptance test scaffolds before implementation using the TDD red-green-refactor cycle.
About
bmad-testarch-atdd is a BMAD Method Test Architect skill that generates red-phase acceptance test scaffolds before implementation using the TDD red-green-refactor cycle. It runs a tri-modal step-file workflow with Create, Resume, Validate, and Edit modes. Developers invoke it when they say 'lets write acceptance tests' or 'I want to do ATDD.'
- Generates red-phase acceptance test scaffolds before implementation
- Uses the TDD red-green-refactor cycle
- Tri-modal step-file architecture with Create, Resume, Validate, and Edit modes
Bmad Testarch Atdd by the numbers
- 6 all-time installs (skills.sh)
- Ranked #1,591 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
bmad-testarch-atdd capabilities & compatibility
- Capabilities
- acceptance testing · tdd · test scaffolding
- Works with
- playwright
- Use cases
- testing
What bmad-testarch-atdd says it does
Generate red-phase acceptance test scaffolds using the TDD cycle.
Generate red-phase acceptance test scaffolds before implementation using TDD red-green-refactor cycle.
This workflow uses **tri-modal step-file architecture**:
npx skills add https://github.com/bmad-code-org/bmad-method-test-architecture-enterprise --skill bmad-testarch-atddAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 6 |
|---|---|
| repo stars | ★ 87 |
| Last updated | August 4, 2026 |
| Repository | bmad-code-org/bmad-method-test-architecture-enterprise ↗ |
What it does
Generate red-phase acceptance test scaffolds before implementation using the TDD cycle.
Who is it for?
Scaffolding failing acceptance tests before writing implementation code
Skip if: Expanding coverage of an already-implemented codebase
When should I use this skill?
The user says 'lets write acceptance tests' or 'I want to do ATDD'
What you get
Red-phase acceptance test scaffolds generated before implementation, ready to drive red-green-refactor.
- acceptance test scaffolds
- atdd checklist
By the numbers
- 4 modes (Create, Resume, Validate, Edit)
Files
Acceptance Test-Driven Development (ATDD)
Goal: Generate red-phase acceptance test scaffolds before implementation using TDD red-green-refactor cycle.
Role: You are the Master Test Architect.
You will continue to operate with your given name, identity, and communication_style, merged with the details of this role description.
Conventions
- Bare paths (e.g.
instructions.md) resolve from the skill root. {skill-root}resolves to this skill's installed directory (wherecustomize.tomllives).{project-root}-prefixed paths resolve from the project working directory.{skill-name}resolves to the skill directory's basename.- Resolve sibling workflow files such as
instructions.md,checklist.md,steps-c/...,steps-e/...,steps-v/..., and templates from{skill-root}.
On Activation
Step 1: Resolve the Workflow Block
Run: python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow
If the script fails, resolve the workflow block yourself by reading these three files in base → team → user order and applying the same structural merge rules as the resolver:
1. {skill-root}/customize.toml — defaults 2. {project-root}/_bmad/custom/{skill-name}.toml — team overrides 3. {project-root}/_bmad/custom/{skill-name}.user.toml — personal overrides
Any missing file is skipped. Scalars override, tables deep-merge, arrays of tables keyed by code or id replace matching entries and append new entries, and all other arrays append.
Step 2: Execute Prepend Steps
Execute each entry in {workflow.activation_steps_prepend} in order before proceeding.
Step 3: Load Persistent Facts
Treat every entry in {workflow.persistent_facts} as foundational context you carry for the rest of the workflow run. Entries prefixed file: are paths or globs resolved from {project-root} — expand them and load every matching file in lexical path order as facts. All other entries are facts verbatim.
Step 4: Load Config
Load config from {project-root}/_bmad/tea/config.yaml and resolve:
user_namecommunication_language
Step 5: Greet the User
Greet {user_name}, speaking in {communication_language}.
Step 6: Execute Append Steps
Execute each entry in {workflow.activation_steps_append} in order.
Activation is complete. Begin the workflow below.
Workflow Architecture
This workflow uses tri-modal step-file architecture:
- Create mode (steps-c/): primary execution flow for new runs and resume continuation
- Validate mode (steps-v/): validation against checklist
- Edit mode (steps-e/): revise existing outputs
Initialization Sequence
1. Mode Determination
"Welcome to the workflow. What would you like to do?"
- [C] Create — Run the workflow from the beginning
- [R] Resume — Resume an interrupted Create workflow
- [V] Validate — Validate existing outputs
- [E] Edit — Edit existing outputs
2. Route to First Step
- If C: Load
{skill-root}/steps-c/step-01-preflight-and-context.md - If R: Load
{skill-root}/steps-c/step-01b-resume.md(Create-mode continuation) - If V: Load
{skill-root}/steps-v/step-01-validate.md - If E: Load
{skill-root}/steps-e/step-01-assess.md
ATDD Checklist - Epic {epic_num}, Story {story_num}: {story_title}
Date: {date} Author: {user_name} Primary Test Level: {primary_level}
---
Story Summary
{Brief 2-3 sentence summary of the user story}
As a {user_role} I want {feature_description} So that {business_value}
---
Acceptance Criteria
{List all testable acceptance criteria from the story}
1. {Acceptance criterion 1} 2. {Acceptance criterion 2} 3. {Acceptance criterion 3}
---
Story Integration Metadata
- Story ID:
{story_id} - Story Key:
{story_key} - Story File:
{story_file} - Checklist Path:
{test_artifacts}/atdd-checklist-{story_key}.md - Generated Test Files:
{e2e_test_file_path},{api_test_file_path},{component_test_file_path}
If this story came from BMM create-story, mirror these artifact paths into the story's Dev Notes so dev-story can discover and activate the red-phase scaffolds.
---
Red-Phase Test Scaffolds Created
E2E Tests ({e2e_test_count} tests)
File: {e2e_test_file_path} ({line_count} lines)
{List each E2E test with its current status and expected failure reason}
- ✅ Test: {test_name}
- Status: RED - {failure_reason}
- Verifies: {what_this_test_validates}
API Tests ({api_test_count} tests)
File: {api_test_file_path} ({line_count} lines)
{List each API test with its current status and expected failure reason}
- ✅ Test: {test_name}
- Status: RED - {failure_reason}
- Verifies: {what_this_test_validates}
Component Tests ({component_test_count} tests)
File: {component_test_file_path} ({line_count} lines)
{List each component test with its current status and expected failure reason}
- ✅ Test: {test_name}
- Status: RED - {failure_reason}
- Verifies: {what_this_test_validates}
---
Data Factories Created
{List all data factory files created with their exports}
{Entity} Factory
File: tests/support/factories/{entity}.factory.ts
Exports:
create{Entity}(overrides?)- Create single entity with optional overridescreate{Entity}s(count)- Create array of entities
Example Usage:
const user = createUser({ email: 'specific@example.com' });
const users = createUsers(5); // Generate 5 random users---
Fixtures Created
{List all test fixture files created with their fixture names and descriptions}
{Feature} Fixtures
File: tests/support/fixtures/{feature}.fixture.ts
Fixtures:
{fixtureName}- {description_of_what_fixture_provides}- Setup: {what_setup_does}
- Provides: {what_test_receives}
- Cleanup: {what_cleanup_does}
Example Usage:
import { test } from './fixtures/{feature}.fixture';
test('should do something', async ({ {fixtureName} }) => {
// {fixtureName} is ready to use with auto-cleanup
});---
Mock Requirements
{Document external services that need mocking and their requirements}
{Service Name} Mock
Endpoint: {HTTP_METHOD} {endpoint_url}
Success Response:
{
{success_response_example}
}Failure Response:
{
{failure_response_example}
}Notes: {any_special_mock_requirements}
---
Required data-testid Attributes
{List all data-testid attributes required in UI implementation for test stability}
{Page or Component Name}
{data-testid-name}- {description_of_element}{data-testid-name}- {description_of_element}
Implementation Example:
<button data-testid="login-button">Log In</button>
<input data-testid="email-input" type="email" />
<div data-testid="error-message">{errorText}</div>---
Implementation Checklist
{Map each scaffolded test to concrete implementation tasks that will make it pass}
Test: {test_name_1}
File: {test_file_path}
Tasks to make this test pass:
- [ ] {Implementation task 1}
- [ ] {Implementation task 2}
- [ ] {Implementation task 3}
- [ ] Add required data-testid attributes: {list_of_testids}
- [ ] Run test:
{test_execution_command} - [ ] ✅ Test passes (green phase)
Estimated Effort: {effort_estimate} hours
---
Test: {test_name_2}
File: {test_file_path}
Tasks to make this test pass:
- [ ] {Implementation task 1}
- [ ] {Implementation task 2}
- [ ] {Implementation task 3}
- [ ] Add required data-testid attributes: {list_of_testids}
- [ ] Run test:
{test_execution_command} - [ ] ✅ Test passes (green phase)
Estimated Effort: {effort_estimate} hours
---
Running Tests
# Run all activated tests for this story
{test_command_all}
# Run specific test file
{test_command_specific_file}
# Run tests in headed mode (see browser)
{test_command_headed}
# Debug specific test
{test_command_debug}
# Run tests with coverage
{test_command_coverage}---
Red-Green-Refactor Workflow
RED Phase (Complete) ✅
TEA Agent Responsibilities:
- ✅ All tests written as red-phase scaffolds with
test.skip() - ✅ Fixtures and factories created with auto-cleanup
- ✅ Mock requirements documented
- ✅ data-testid requirements listed
- ✅ Implementation checklist created
Verification:
- All generated tests are present and marked with
test.skip() - Activation guidance is clear and actionable
- Any activated test fails due to missing implementation, not test bugs
---
GREEN Phase (DEV Team - Next Steps)
DEV Agent Responsibilities:
1. Pick one scaffolded test from implementation checklist (start with highest priority) 2. Remove `test.skip()` for that test and confirm it fails first 3. Read the test to understand expected behavior 4. Implement minimal code to make that specific test pass 5. Run the test to verify it now passes (green) 6. Check off the task in implementation checklist 7. Move to next test and repeat
Key Principles:
- One test at a time (don't try to fix all at once)
- Minimal implementation (don't over-engineer)
- Run tests frequently (immediate feedback)
- Use implementation checklist as roadmap
Progress Tracking:
- Check off tasks as you complete them
- Share progress in daily standup
---
REFACTOR Phase (DEV Team - After All Tests Pass)
DEV Agent Responsibilities:
1. Verify all tests pass (green phase complete) 2. Review code for quality (readability, maintainability, performance) 3. Extract duplications (DRY principle) 4. Optimize performance (if needed) 5. Ensure tests still pass after each refactor 6. Update documentation (if API contracts change)
Key Principles:
- Tests provide safety net (refactor with confidence)
- Make small refactors (easier to debug if tests fail)
- Run tests after each change
- Don't change test behavior (only implementation)
Completion:
- All tests pass
- Code quality meets team standards
- No duplications or code smells
- Ready for code review and story approval
---
Next Steps
1. Link this checklist and generated tests into the story file Dev Notes / ATDD Artifacts section when a writable story file is available 2. If the story file cannot be updated automatically, share this checklist and generated tests with the dev workflow as a manual handoff 3. Review this checklist with team in standup or planning 4. Begin implementation using implementation checklist as guide 5. Activate one scaffold at a time by removing test.skip() for the current task, then confirm it fails before implementing 6. Work one activated test at a time (red → green for each) 7. Share progress in daily standup 8. When all activated tests pass, refactor code for quality 9. When refactoring complete, manually update story status to 'done' in sprint-status.yaml
---
Knowledge Base References Applied
This ATDD workflow consulted the following knowledge fragments:
- fixture-architecture.md - Test fixture patterns with setup/teardown and auto-cleanup using Playwright's
test.extend() - data-factories.md - Factory patterns using
@faker-js/fakerfor random test data generation with overrides support - component-tdd.md - Component test strategies using Playwright Component Testing
- network-first.md - Route interception patterns (intercept BEFORE navigation to prevent race conditions)
- test-quality.md - Test design principles (Given-When-Then, one assertion per test, determinism, isolation)
- test-levels-framework.md - Test level selection framework (E2E vs API vs Component vs Unit)
See tea-index.csv for complete knowledge fragment mapping.
---
Test Execution Evidence
Initial Scaffold Review / RED Verification
Command: {test_command_all} (or a narrower command after removing test.skip() for the current task)
Results:
{paste_test_run_output_showing_scaffolds_skipped_or_activated_tests_failing}Summary:
- Total tests: {total_test_count}
- Skipped: {total_test_count} (expected before activation)
- Activated RED tests: {activated_test_count} (expected after activation, before implementation)
- Passing: 0 before implementation (expected for activated tests)
- Status: ✅ Red-phase scaffolds verified
Expected Failure Messages: {list_expected_skip_or_failure_states_for_each_test}
---
Notes
{Any additional notes, context, or special considerations for this story}
- {Note 1}
- {Note 2}
- {Note 3}
---
Contact
Questions or Issues?
- Ask in team standup
- Tag @{tea_agent_username} in Slack/Discord
- Refer to
./bmm/docs/tea-README.mdfor workflow documentation - Consult
./resources/knowledgefor testing best practices
---
Generated by BMad TEA Agent - {date}
ATDD Workflow Validation Checklist
Use this checklist to validate that the ATDD workflow has been executed correctly and all deliverables meet quality standards.
Prerequisites
Before starting this workflow, verify:
- [ ] Story approved with clear acceptance criteria (AC must be testable)
- [ ] Development sandbox/environment ready
- [ ] Framework scaffolding exists (run
frameworkworkflow if missing) - [ ] Test framework configuration available (playwright.config.ts or cypress.config.ts)
- [ ] Package.json has test dependencies installed (Playwright or Cypress)
Halt if missing: Framework scaffolding or story acceptance criteria
---
Step 1: Story Context and Requirements
- [ ] Story markdown file loaded and parsed successfully
- [ ] All acceptance criteria identified and extracted
- [ ] Affected systems and components identified
- [ ] Technical constraints documented
- [ ] Framework configuration loaded (playwright.config.ts or cypress.config.ts)
- [ ] Test directory structure identified from config
- [ ] Existing fixture patterns reviewed for consistency
- [ ] Similar test patterns searched and found in
{test_dir} - [ ] Knowledge base fragments loaded:
- [ ]
fixture-architecture.md - [ ]
data-factories.md - [ ]
component-tdd.md - [ ]
network-first.md - [ ]
test-quality.md
---
Step 2: Test Level Selection and Strategy
- [ ] Each acceptance criterion analyzed for appropriate test level
- [ ] Test level selection framework applied (E2E vs API vs Component vs Unit)
- [ ] E2E tests: Critical user journeys and multi-system integration identified
- [ ] API tests: Business logic and service contracts identified
- [ ] Component tests: UI component behavior and interactions identified
- [ ] Unit tests: Pure logic and edge cases identified (if applicable)
- [ ] Duplicate coverage avoided (same behavior not tested at multiple levels unnecessarily)
- [ ] Tests prioritized using P0-P3 framework (if test-design document exists)
- [ ] Primary test level set in
primary_levelvariable (typically E2E or API) - [ ] Test levels documented in ATDD checklist
---
Step 3: Red-Phase Test Scaffolds Generated
Test File Structure Created
- [ ] Test files organized in appropriate directories:
- [ ]
tests/e2e/for end-to-end tests - [ ]
tests/api/for API tests - [ ]
tests/component/for component tests - [ ]
tests/support/for infrastructure (fixtures, factories, helpers)
E2E Tests (If Applicable)
- [ ] E2E test files created in
tests/e2e/ - [ ] All tests follow Given-When-Then format
- [ ] Tests use
data-testidselectors (not CSS classes or fragile selectors) - [ ] One assertion per test (atomic test design)
- [ ] No hard waits or sleeps (explicit waits only)
- [ ] Network-first pattern applied (route interception BEFORE navigation)
- [ ] Tests are generated as
test.skip()scaffolds - [ ] Activation guidance is documented for the current task
API Tests (If Applicable)
- [ ] API test files created in
tests/api/ - [ ] Tests follow Given-When-Then format
- [ ] API contracts validated (request/response structure)
- [ ] HTTP status codes verified
- [ ] Response body validation includes all required fields
- [ ] Error cases tested (400, 401, 403, 404, 500)
- [ ] Tests are generated as
test.skip()scaffolds
Component Tests (If Applicable)
- [ ] Component test files created in
tests/component/ - [ ] Tests follow Given-When-Then format
- [ ] Component mounting works correctly
- [ ] Interaction testing covers user actions (click, hover, keyboard)
- [ ] State management within component validated
- [ ] Props and events tested
- [ ] Tests are generated as
test.skip()scaffolds
Test Quality Validation
- [ ] All tests use Given-When-Then structure with clear comments
- [ ] All tests have descriptive names explaining what they test
- [ ] No duplicate tests (same behavior tested multiple times)
- [ ] No flaky patterns (race conditions, timing issues)
- [ ] No test interdependencies (tests can run in any order)
- [ ] Tests are deterministic (same input always produces same result)
---
Step 4: Data Infrastructure Built
Data Factories Created
- [ ] Factory files created in
tests/support/factories/ - [ ] All factories use
@faker-js/fakerfor random data generation (no hardcoded values) - [ ] Factories support overrides for specific test scenarios
- [ ] Factories generate complete valid objects matching API contracts
- [ ] Helper functions for bulk creation provided (e.g.,
createUsers(count)) - [ ] Factory exports are properly typed (TypeScript)
Test Fixtures Created
- [ ] Fixture files created in
tests/support/fixtures/ - [ ] All fixtures use Playwright's
test.extend()pattern - [ ] Fixtures have setup phase (arrange test preconditions)
- [ ] Fixtures provide data to tests via
await use(data) - [ ] Fixtures have teardown phase with auto-cleanup (delete created data)
- [ ] Fixtures are composable (can use other fixtures if needed)
- [ ] Fixtures are isolated (each test gets fresh data)
- [ ] Fixtures are type-safe (TypeScript types defined)
Mock Requirements Documented
- [ ] External service mocking requirements identified
- [ ] Mock endpoints documented with URLs and methods
- [ ] Success response examples provided
- [ ] Failure response examples provided
- [ ] Mock requirements documented in ATDD checklist for DEV team
data-testid Requirements Listed
- [ ] All required data-testid attributes identified from E2E tests
- [ ] data-testid list organized by page or component
- [ ] Each data-testid has clear description of element it targets
- [ ] data-testid list included in ATDD checklist for DEV team
---
Step 5: Implementation Checklist Created
- [ ] Implementation checklist created with clear structure
- [ ] Each scaffolded test mapped to concrete implementation tasks
- [ ] Tasks include:
- [ ] Route/component creation
- [ ] Business logic implementation
- [ ] API integration
- [ ] data-testid attribute additions
- [ ] Error handling
- [ ] Test execution command
- [ ] Completion checkbox
- [ ] Red-Green-Refactor workflow documented in checklist
- [ ] RED phase marked as complete (TEA responsibility)
- [ ] GREEN phase tasks listed for DEV team
- [ ] REFACTOR phase guidance provided
- [ ] Execution commands provided:
- [ ] Run all tests:
npm run test:e2e - [ ] Run specific test file
- [ ] Run in headed mode
- [ ] Debug specific test
- [ ] Estimated effort included (hours or story points)
---
Step 6: Deliverables Generated
ATDD Checklist Document Created
- [ ] Output file created at
{test_artifacts}/atdd-checklist-{story_key}.md - [ ] Document follows template structure from
atdd-checklist-template.md - [ ] Document includes all required sections:
- [ ] Story summary
- [ ] Acceptance criteria breakdown
- [ ] Red-phase test scaffolds created (paths and line counts)
- [ ] Data factories created
- [ ] Fixtures created
- [ ] Mock requirements
- [ ] Required data-testid attributes
- [ ] Implementation checklist
- [ ] Red-green-refactor workflow
- [ ] Execution commands
- [ ] Next steps for DEV team
- [ ] Checklist frontmatter includes
storyId,storyKey,storyFile,atddChecklistPath, and generated test file paths - [ ] If a writable story file was provided, ATDD artifacts were linked back into story context
- [ ] If a story file could not be updated, manual handoff instructions are present
Red-Phase Scaffolds Verified
- [ ] All generated acceptance test scaffolds are marked with
test.skip() - [ ] No scaffold was emitted as an active passing test before implementation
- [ ] Activation guidance is documented: remove
test.skip()for the current task, then confirm RED before implementing - [ ] Any assumptions or expected failure reasons are documented in ATDD checklist
- [ ] Test run output captured for reference
Summary Provided
- [ ] Summary includes:
- [ ] Story ID
- [ ] Primary test level
- [ ] Test counts (E2E, API, Component)
- [ ] Test file paths
- [ ] Factory count
- [ ] Fixture count
- [ ] Mock requirements count
- [ ] data-testid count
- [ ] Implementation task count
- [ ] Estimated effort
- [ ] Next steps for DEV team
- [ ] Output file path
- [ ] Knowledge base references applied
---
Quality Checks
Test Design Quality
- [ ] Tests are readable (clear Given-When-Then structure)
- [ ] Tests are maintainable (use factories and fixtures, not hardcoded data)
- [ ] Tests are isolated (no shared state between tests)
- [ ] Tests are deterministic (no race conditions or flaky patterns)
- [ ] Tests are atomic (one assertion per test)
- [ ] Tests are fast (no unnecessary waits or delays)
Knowledge Base Integration
- [ ] fixture-architecture.md patterns applied to all fixtures
- [ ] data-factories.md patterns applied to all factories
- [ ] network-first.md patterns applied to E2E tests with network requests
- [ ] component-tdd.md patterns applied to component tests
- [ ] test-quality.md principles applied to all test design
Code Quality
- [ ] All TypeScript types are correct and complete
- [ ] No linting errors in generated test files
- [ ] Consistent naming conventions followed
- [ ] Imports are organized and correct
- [ ] Code follows project style guide
---
Integration Points
With DEV Agent
- [ ] ATDD checklist provides clear implementation guidance
- [ ] Implementation tasks are granular and actionable
- [ ] data-testid requirements are complete and clear
- [ ] Mock requirements include all necessary details
- [ ] Execution commands work correctly
With Story Workflow
- [ ] Story ID correctly referenced in output files
- [ ] Acceptance criteria from story accurately reflected in tests
- [ ] Technical constraints from story considered in test design
With Framework Workflow
- [ ] Test framework configuration correctly detected and used
- [ ] Directory structure matches framework setup
- [ ] Fixtures and helpers follow established patterns
- [ ] Naming conventions consistent with framework standards
With test-design Workflow (If Available)
- [ ] P0 scenarios from test-design prioritized in ATDD
- [ ] Risk assessment from test-design considered in test coverage
- [ ] Coverage strategy from test-design aligned with ATDD tests
---
Completion Criteria
All of the following must be true before marking this workflow as complete:
- [ ] Story acceptance criteria analyzed and mapped to appropriate test levels
- [ ] Red-phase test scaffolds created at all appropriate levels (E2E, API, Component)
- [ ] Given-When-Then format used consistently across all tests
- [ ] RED phase verified by scaffold generation plus task-by-task activation guidance
- [ ] Network-first pattern applied to E2E tests with network requests
- [ ] Data factories created using faker (no hardcoded test data)
- [ ] Fixtures created with auto-cleanup in teardown
- [ ] Mock requirements documented for external services
- [ ] data-testid attributes listed for DEV team
- [ ] Implementation checklist created mapping tests to code tasks
- [ ] Red-green-refactor workflow documented in ATDD checklist
- [ ] Execution commands provided and verified to work
- [ ] ATDD checklist document created and saved to correct location
- [ ] Output file formatted correctly using template structure
- [ ] Knowledge base references applied and documented in summary
- [ ] No test quality issues (flaky patterns, race conditions, hardcoded data)
---
Common Issues and Resolutions
Issue: Tests pass before implementation
Problem: A test passes even though no implementation code exists yet.
Resolution:
- Review test to ensure it's testing actual behavior, not mocked/stubbed behavior
- Check if test is accidentally using existing functionality
- Verify test assertions are correct and meaningful
- Rewrite test to fail until implementation is complete
Issue: Network-first pattern not applied
Problem: Route interception happens after navigation, causing race conditions.
Resolution:
- Move
await page.route()calls BEFOREawait page.goto() - Review
network-first.mdknowledge fragment - Update all E2E tests to follow network-first pattern
Issue: Hardcoded test data in tests
Problem: Tests use hardcoded strings/numbers instead of factories.
Resolution:
- Replace all hardcoded data with factory function calls
- Use
fakerfor all random data generation - Update data-factories to support all required test scenarios
Issue: Fixtures missing auto-cleanup
Problem: Fixtures create data but don't clean it up in teardown.
Resolution:
- Add cleanup logic after
await use(data)in fixture - Call deletion/cleanup functions in teardown
- Verify cleanup works by checking database/storage after test run
Issue: Tests have multiple assertions
Problem: Tests verify multiple behaviors in single test (not atomic).
Resolution:
- Split into separate tests (one assertion per test)
- Each test should verify exactly one behavior
- Use descriptive test names to clarify what each test verifies
Issue: Tests depend on execution order
Problem: Tests fail when run in isolation or different order.
Resolution:
- Remove shared state between tests
- Each test should create its own test data
- Use fixtures for consistent setup across tests
- Verify tests can run with
.onlyflag
---
Notes for TEA Agent
- Preflight halt is critical: Do not proceed if story has no acceptance criteria or framework is missing
- RED phase verification is mandatory: Tests must fail before sharing with DEV team
- Network-first pattern: Route interception BEFORE navigation prevents race conditions
- One assertion per test: Atomic tests provide clear failure diagnosis
- Auto-cleanup is non-negotiable: Every fixture must clean up data in teardown
- Use knowledge base: Load relevant fragments (fixture-architecture, data-factories, network-first, component-tdd, test-quality) for guidance
- Share with DEV agent: ATDD checklist provides implementation roadmap from red to green
# DO NOT EDIT -- overwritten on every update.
#
# Workflow customization surface for bmad-testarch-atdd. Mirrors the
# agent customization shape under the [workflow] namespace.
[workflow]
# --- Configurable below. Overrides merge per BMad structural rules: ---
# scalars: override wins • arrays (persistent_facts, activation_steps_*): append
# Steps to run before the standard activation (config load, greet).
# Overrides append. Use for pre-flight loads, compliance checks, etc.
activation_steps_prepend = []
# Steps to run after greet but before the workflow begins.
# Overrides append. Use for context-heavy setup that should happen
# once the user has been acknowledged.
activation_steps_append = []
# Persistent facts the workflow keeps in mind for the whole run
# (testing standards, framework conventions, compliance constraints).
# Distinct from the runtime memory sidecar — these are static context
# loaded on activation. Overrides append.
#
# Each entry is either:
# - a literal sentence, e.g. "Every test must run deterministically in CI."
# - a file reference prefixed with `file:`, e.g. "file:{project-root}/docs/test-standards.md"
# (glob patterns are supported; matching files load in lexical path order as facts).
persistent_facts = [
"file:{project-root}/**/project-context.md",
]
# Scalar: executed when the workflow reaches its terminal step in any
# mode (create, validate, edit), after the final outputs are produced.
# Override wins. Leave empty for no custom post-completion behavior.
on_complete = ""
<!-- Powered by BMAD-CORE™ -->
Acceptance Test-Driven Development (ATDD)
Version: 5.0 (Step-File Architecture)
---
Overview
Generates red-phase acceptance test scaffolds before implementation (TDD red phase), plus an implementation checklist. Produces tests at appropriate levels (E2E/API/Component) with supporting fixtures and helpers.
---
WORKFLOW ARCHITECTURE
This workflow uses step-file architecture:
- Micro-file Design: Each step is self-contained
- JIT Loading: Only the current step file is in memory
- Sequential Enforcement: Execute steps in order without skipping
---
INITIALIZATION SEQUENCE
1. Configuration Loading
From workflow.yaml, resolve:
config_source,test_artifacts,user_name,communication_language,document_output_language,datetest_dir
2. First Step
Load, read completely, and execute: {skill-root}/steps-c/step-01-preflight-and-context.md
3. Resume Support
If the user selects Resume mode, load, read completely, and execute: {skill-root}/steps-c/step-01b-resume.md
This checks the output document for progress tracking frontmatter and routes to the next incomplete step.
ADR Quality Readiness Checklist
Purpose: Standardized 8-category, 29-criteria framework for evaluating system testability and NFR compliance during architecture review (Phase 3) and NFR assessment.
When to Use:
- System-level test design (Phase 3): Identify testability gaps in architecture
- NFR assessment workflow: Structured evaluation with evidence
- Gate decisions: Quantifiable criteria (X/29 met = PASS/CONCERNS/FAIL)
How to Use:
1. For each criterion, assess status: ✅ Covered / ⚠️ Gap / ⬜ Not Assessed 2. Document gap description if ⚠️ 3. Describe risk if criterion unmet 4. Map to test scenarios (what tests validate this criterion)
---
1. Testability & Automation
Question: Can we verify this effectively without manual toil?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 1.1 | Isolation: Can the service be tested with all downstream dependencies (DBs, APIs, Queues) mocked or stubbed? | Flaky tests; inability to test in isolation | P1: Service runs with mocked DB, P1: Service runs with mocked API, P2: Integration tests with real deps |
| 1.2 | Headless Interaction: Is 100% of the business logic accessible via API (REST/gRPC) to bypass the UI for testing? | Slow, brittle UI-based automation | P0: All core logic callable via API, P1: No UI dependency for critical paths |
| 1.3 | State Control: Do we have "Seeding APIs" or scripts to inject specific data states (e.g., "User with expired subscription") instantly? | Long setup times; inability to test edge cases | P0: Seed baseline data, P0: Inject edge case data states, P1: Cleanup after tests |
| 1.4 | Sample Requests: Are there valid and invalid cURL/JSON sample requests provided in the design doc for QA to build upon? | Ambiguity on how to consume the service | P1: Valid request succeeds, P1: Invalid request fails with clear error |
Common Gaps:
- No mock endpoints for external services (Athena, Milvus, third-party APIs)
- Business logic tightly coupled to UI (requires E2E tests for everything)
- No seeding APIs (manual database setup required)
- ADR has architecture diagrams but no sample API requests
Mitigation Examples:
- 1.1 (Isolation): Provide mock endpoints, dependency injection, interface abstractions
- 1.2 (Headless): Expose all business logic via REST/GraphQL APIs
- 1.3 (State Control): Implement
/api/test-dataseeding endpoints (dev/staging only) - 1.4 (Sample Requests): Add "Example API Calls" section to ADR with cURL commands
---
2. Test Data Strategy
Question: How do we fuel our tests safely?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 2.1 | Segregation: Does the design support multi-tenancy or specific headers (e.g., x-test-user) to keep test data out of prod metrics? | Skewed business analytics; data pollution | P0: Multi-tenant isolation (customer A ≠ customer B), P1: Test data excluded from prod metrics |
| 2.2 | Generation: Can we use synthetic data, or do we rely on scrubbing production data (GDPR/PII risk)? | Privacy violations; dependency on stale data | P0: Faker-based synthetic data, P1: No production data in tests |
| 2.3 | Teardown: Is there a mechanism to "reset" the environment or clean up data after destructive tests? | Environment rot; subsequent test failures | P0: Automated cleanup after tests, P2: Environment reset script |
Common Gaps:
- No
customer_idscoping in queries (cross-tenant data leakage risk) - Reliance on production data dumps (GDPR/PII violations)
- No cleanup mechanism (tests leave data behind, polluting environment)
Mitigation Examples:
- 2.1 (Segregation): Enforce
customer_idin all queries, add test-specific headers - 2.2 (Generation): Use Faker library, create synthetic data generators, prohibit prod dumps
- 2.3 (Teardown): Auto-cleanup hooks in test framework, isolated test customer IDs
---
3. Scalability & Availability
Question: Can it grow, and will it stay up?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 3.1 | Statelessness: Is the service stateless? If not, how is session state replicated across instances? | Inability to auto-scale horizontally | P1: Service restart mid-request → no data loss, P2: Horizontal scaling under load |
| 3.2 | Bottlenecks: Have we identified the weakest link (e.g., database connections, API rate limits) under load? | System crash during peak traffic | P2: Load test identifies bottleneck, P2: Connection pool exhaustion handled |
| 3.3 | SLA Definitions: What is the target Availability (e.g., 99.9%) and does the architecture support redundancy to meet it? | Breach of contract; customer churn | P1: Availability target defined, P2: Redundancy validated (multi-region/zone) |
| 3.4 | Circuit Breakers: If a dependency fails, does this service fail fast or hang? | Cascading failures taking down the whole platform | P1: Circuit breaker opens on 5 failures, P1: Auto-reset after recovery, P2: Timeout prevents hanging |
Common Gaps:
- Stateful session management (can't scale horizontally)
- No load testing, bottlenecks unknown
- SLA undefined or unrealistic (99.99% without redundancy)
- No circuit breakers (cascading failures)
Mitigation Examples:
- 3.1 (Statelessness): Externalize session to Redis/JWT, design for horizontal scaling
- 3.2 (Bottlenecks): Load test with k6, monitor connection pools, identify weak links
- 3.3 (SLA): Define realistic SLA (99.9% = 43 min/month downtime), add redundancy
- 3.4 (Circuit Breakers): Implement circuit breakers (Hystrix pattern), fail fast on errors
---
4. Disaster Recovery (DR)
Question: What happens when the worst-case scenario occurs?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 4.1 | RTO/RPO: What is the Recovery Time Objective (how long to restore) and Recovery Point Objective (max data loss)? | Extended outages; data loss liability | P2: RTO defined and tested, P2: RPO validated (backup frequency) |
| 4.2 | Failover: Is region/zone failover automated or manual? Has it been practiced? | "Heroics" required during outages; human error | P2: Automated failover works, P2: Manual failover documented and tested |
| 4.3 | Backups: Are backups immutable and tested for restoration integrity? | Ransomware vulnerability; corrupted backups | P2: Backup restore succeeds, P2: Backup immutability validated |
Common Gaps:
- RTO/RPO undefined (no recovery plan)
- Failover never tested (manual process, prone to errors)
- Backups exist but restoration never validated (untested backups = no backups)
Mitigation Examples:
- 4.1 (RTO/RPO): Define RTO (e.g., 4 hours) and RPO (e.g., 1 hour), document recovery procedures
- 4.2 (Failover): Automate multi-region failover, practice failover drills quarterly
- 4.3 (Backups): Implement immutable backups (S3 versioning), test restore monthly
---
5. Security
Question: Is the design safe by default?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 5.1 | AuthN/AuthZ: Does it implement standard protocols (OAuth2/OIDC)? Are permissions granular (Least Privilege)? | Unauthorized access; data leaks | P0: OAuth flow works, P0: Expired token rejected, P0: Insufficient permissions return 403, P1: Scope enforcement |
| 5.2 | Encryption: Is data encrypted at rest (DB) and in transit (TLS)? | Compliance violations; data theft | P1: Milvus data-at-rest encrypted, P1: TLS 1.2+ enforced, P2: Certificate rotation works |
| 5.3 | Secrets: Are API keys/passwords stored in a Vault (not in code or config files)? | Credentials leaked in git history | P1: No hardcoded secrets in code, P1: Secrets loaded from AWS Secrets Manager |
| 5.4 | Input Validation: Are inputs sanitized against Injection attacks (SQLi, XSS)? | System compromise via malicious payloads | P1: SQL injection sanitized, P1: XSS escaped, P2: Command injection prevented |
Common Gaps:
- Weak authentication (no OAuth, hardcoded API keys)
- No encryption at rest (plaintext in database)
- Secrets in git (API keys, passwords in config files)
- No input validation (vulnerable to SQLi, XSS, command injection)
Mitigation Examples:
- 5.1 (AuthN/AuthZ): Implement OAuth 2.1/OIDC, enforce least privilege, validate scopes
- 5.2 (Encryption): Enable TDE (Transparent Data Encryption), enforce TLS 1.2+
- 5.3 (Secrets): Migrate to AWS Secrets Manager/Vault, scan git history for leaks
- 5.4 (Input Validation): Sanitize all inputs, use parameterized queries, escape outputs
---
6. Monitorability, Debuggability & Manageability
Question: Can we operate and fix this in production?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 6.1 | Tracing: Does the service propagate W3C Trace Context / Correlation IDs for distributed tracing? | Impossible to debug errors across microservices | P2: W3C Trace Context propagated (EventBridge → Lambda → Service), P2: Correlation ID in all logs |
| 6.2 | Logs: Can log levels (INFO vs DEBUG) be toggled dynamically without a redeploy? | Inability to diagnose issues in real-time | P2: Log level toggle works without redeploy, P2: Logs structured (JSON format) |
| 6.3 | Metrics: Does it expose RED metrics (Rate, Errors, Duration) for Prometheus/Datadog? | Flying blind regarding system health | P2: /metrics endpoint exposes RED metrics, P2: Prometheus/Datadog scrapes successfully |
| 6.4 | Config: Is configuration externalized? Can we change behavior without a code build? | Rigid system; full deploys needed for minor tweaks | P2: Config change without code build, P2: Feature flags toggle behavior |
Common Gaps:
- No distributed tracing (can't debug across microservices)
- Static log levels (requires redeploy to enable DEBUG)
- No metrics endpoint (blind to system health)
- Configuration hardcoded (requires full deploy for minor changes)
Mitigation Examples:
- 6.1 (Tracing): Implement W3C Trace Context, add correlation IDs to all logs
- 6.2 (Logs): Use dynamic log levels (environment variable), structured logging (JSON)
- 6.3 (Metrics): Expose /metrics endpoint, track RED metrics (Rate, Errors, Duration)
- 6.4 (Config): Externalize config (AWS SSM/AppConfig), use feature flags (LaunchDarkly)
---
7. QoS (Quality of Service) & QoE (Quality of Experience)
Question: How does it perform, and how does it feel?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 7.1 | Latency (QoS): What are the P95 and P99 latency targets? | Slow API responses affecting throughput | P3: P95 latency <Xs (load test), P3: P99 latency <Ys (load test) |
| 7.2 | Throttling (QoS): Is there Rate Limiting to prevent "noisy neighbors" or DDoS? | Service degradation for all users due to one bad actor | P2: Rate limiting enforced, P2: 429 returned when limit exceeded |
| 7.3 | Perceived Performance (QoE): Does the UI show optimistic updates or skeletons while loading? | App feels sluggish to the user | P2: Skeleton/spinner shown while loading (E2E), P2: Optimistic updates (E2E) |
| 7.4 | Degradation (QoE): If the service is slow, does it show a friendly message or a raw stack trace? | Poor user trust; frustration | P2: Friendly error message shown (not stack trace), P1: Error boundary catches exceptions (E2E) |
Common Gaps:
- Latency targets undefined (no SLOs)
- No rate limiting (vulnerable to DDoS, noisy neighbors)
- Poor perceived performance (blank screen while loading)
- Raw error messages (stack traces exposed to users)
Mitigation Examples:
- 7.1 (Latency): Define SLOs (P95 <2s, P99 <5s), load test to validate
- 7.2 (Throttling): Implement rate limiting (per-user, per-IP), return 429 with Retry-After
- 7.3 (Perceived Performance): Add skeleton screens, optimistic updates, progressive loading
- 7.4 (Degradation): Implement error boundaries, show friendly messages, log stack traces server-side
---
8. Deployability
Question: How easily can we ship this?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 8.1 | Zero Downtime: Does the design support Blue/Green or Canary deployments? | Maintenance windows required (downtime) | P2: Blue/Green deployment works, P2: Canary deployment gradual rollout |
| 8.2 | Backward Compatibility: Can we deploy the DB changes separately from the Code changes? | "Lock-step" deployments; high risk of breaking changes | P2: DB migration before code deploy, P2: Code handles old and new schema |
| 8.3 | Rollback: Is there an automated rollback trigger if Health Checks fail post-deploy? | Prolonged outages after a bad deploy | P2: Health check fails → automated rollback, P2: Rollback completes within RTO |
Common Gaps:
- No zero-downtime strategy (requires maintenance window)
- Tight coupling between DB and code (lock-step deployments)
- No automated rollback (manual intervention required)
Mitigation Examples:
- 8.1 (Zero Downtime): Implement Blue/Green or Canary deployments, use feature flags
- 8.2 (Backward Compatibility): Separate DB migrations from code deploys, support N-1 schema
- 8.3 (Rollback): Automate rollback on health check failures, test rollback procedures
---
Usage in Test Design Workflow
System-Level Mode (Phase 3):
In test-design-architecture.md:
- Add "NFR Testability Requirements" section after ASRs
- Use 8 categories with checkboxes (29 criteria)
- For each criterion: Status (⬜ Not Assessed, ⚠️ Gap, ✅ Covered), Gap description, Risk if unmet
- Example:
## NFR Testability Requirements
**Based on ADR Quality Readiness Checklist**
### 1. Testability & Automation
Can we verify this effectively without manual toil?
| Criterion | Status | Gap/Requirement | Risk if Unmet |
| ---------------------------------------------------------------- | --------------- | ------------------------------------ | --------------------------------------- |
| ⬜ Isolation: Can service be tested with downstream deps mocked? | ⚠️ Gap | No mock endpoints for Athena queries | Flaky tests; can't test in isolation |
| ⬜ Headless: 100% business logic accessible via API? | ✅ Covered | All MCP tools are REST APIs | N/A |
| ⬜ State Control: Seeding APIs to inject data states? | ⚠️ Gap | Need `/api/test-data` endpoints | Long setup times; can't test edge cases |
| ⬜ Sample Requests: Valid/invalid cURL/JSON samples provided? | ⬜ Not Assessed | Pending ADR Tool schemas finalized | Ambiguity on how to consume service |
**Actions Required:**
- [ ] Backend: Implement mock endpoints for Athena (R-002 blocker)
- [ ] Backend: Implement `/api/test-data` seeding APIs (R-002 blocker)
- [ ] PM: Finalize ADR Tool schemas with sample requests (Q4)In test-design-qa.md:
- Map each criterion to test scenarios
- Add "NFR Test Coverage Plan" section with P0/P1/P2 priority for each category
- Reference Architecture doc gaps
- Example:
## NFR Test Coverage Plan
**Based on ADR Quality Readiness Checklist**
### 1. Testability & Automation (4 criteria)
**Prerequisites from Architecture doc:**
- [ ] R-002: Test data seeding APIs implemented (blocker)
- [ ] Mock endpoints available for Athena queries
| Criterion | Test Scenarios | Priority | Test Count | Owner |
| ------------------------------- | -------------------------------------------------------------------- | -------- | ---------- | ---------------- |
| Isolation: Mock downstream deps | Mock Athena queries, Mock Milvus, Service runs isolated | P1 | 3 | Backend Dev + QA |
| Headless: API-accessible logic | All MCP tools callable via REST, No UI dependency for business logic | P0 | 5 | QA |
| State Control: Seeding APIs | Create test customer, Seed 1000 transactions, Inject edge cases | P0 | 4 | QA |
| Sample Requests: cURL examples | Valid request succeeds, Invalid request fails with clear error | P1 | 2 | QA |
**Detailed Test Scenarios:**
- [ ] Isolation: Service runs with Athena mocked (returns fixture data)
- [ ] Isolation: Service runs with Milvus mocked (returns ANN fixture)
- [ ] State Control: Seed test customer with 1000 baseline transactions
- [ ] State Control: Inject edge case (expired subscription user)---
Usage in NFR Assessment Workflow
Output Structure:
# NFR Assessment: {Feature Name}
**Based on ADR Quality Readiness Checklist (8 categories, 29 criteria)**
## Assessment Summary
| Category | Status | Criteria Met | Evidence | Next Action |
| ----------------------------- | ----------- | ------------ | -------------------------------------- | -------------------- |
| 1. Testability & Automation | ⚠️ CONCERNS | 2/4 | Mock endpoints missing | Implement R-002 |
| 2. Test Data Strategy | ✅ PASS | 3/3 | Faker + auto-cleanup | None |
| 3. Scalability & Availability | ⚠️ CONCERNS | 1/4 | SLA undefined | Define SLA |
| 4. Disaster Recovery | ⚠️ CONCERNS | 0/3 | No RTO/RPO defined | Define recovery plan |
| 5. Security | ✅ PASS | 4/4 | OAuth 2.1 + TLS + Vault + Sanitization | None |
| 6. Monitorability | ⚠️ CONCERNS | 2/4 | No metrics endpoint | Add /metrics |
| 7. QoS & QoE | ⚠️ CONCERNS | 1/4 | Latency targets undefined | Define SLOs |
| 8. Deployability | ✅ PASS | 3/3 | Blue/Green + DB migrations + Rollback | None |
**Overall:** 14/29 criteria met (48%) → ⚠️ CONCERNS
**Gate Decision:** CONCERNS (requires mitigation plan before GA)
---
## Detailed Assessment
### 1. Testability & Automation (2/4 criteria met)
**Question:** Can we verify this effectively without manual toil?
| Criterion | Status | Evidence | Gap/Action |
| ---------------------------- | ------ | ------------------------ | -------------------------- |
| ⬜ Isolation: Mock deps | ⚠️ | No Athena mock | Implement mock endpoints |
| ⬜ Headless: API-accessible | ✅ | All MCP tools are REST | N/A |
| ⬜ State Control: Seeding | ⚠️ | `/api/test-data` pending | Pre-implementation blocker |
| ⬜ Sample Requests: Examples | ⬜ | Pending schemas | Finalize ADR Tools |
**Overall Status:** ⚠️ CONCERNS (2/4 criteria met)
**Next Actions:**
- [ ] Backend: Implement Athena mock endpoints (pre-implementation)
- [ ] Backend: Implement `/api/test-data` (pre-implementation)
- [ ] PM: Finalize sample requests (implementation phase)
{Repeat for all 8 categories}---
Benefits
For test-design workflow:
- ✅ Standard NFR structure (same 8 categories every project)
- ✅ Clear testability requirements for Architecture team
- ✅ Direct mapping: criterion → requirement → test scenario
- ✅ Comprehensive coverage (29 criteria = no blind spots)
For nfr-assess workflow:
- ✅ Structured assessment (not ad-hoc)
- ✅ Quantifiable (X/29 criteria met)
- ✅ Evidence-based (each criterion has evidence field)
- ✅ Actionable (gaps → next actions with owners)
For Architecture teams:
- ✅ Clear checklist (29 yes/no questions)
- ✅ Risk-aware (each criterion has "risk if unmet")
- ✅ Scoped work (only implement what's needed, not everything)
For QA teams:
- ✅ Comprehensive test coverage (29 criteria → test scenarios)
- ✅ Clear priorities (P0 for security/isolation, P1 for monitoring, etc.)
- ✅ No ambiguity (each criterion has specific test scenarios)
API Request Utility
Principle
Use typed HTTP client with built-in schema validation and automatic retry for server errors. The utility handles URL resolution, header management, response parsing, and single-line response validation with proper TypeScript support. Works without a browser - ideal for pure API/service testing.
Rationale
Vanilla Playwright's request API requires boilerplate for common patterns:
- Manual JSON parsing (
await response.json()) - Repetitive status code checking
- No built-in retry logic for transient failures
- No schema validation
- Complex URL construction
The apiRequest utility provides:
- Automatic JSON parsing: Response body pre-parsed
- Built-in retry: 5xx errors retry with exponential backoff
- Schema validation: Single-line validation (JSON Schema, Zod, OpenAPI)
- URL resolution: Four-tier strategy (explicit > config > Playwright > direct)
- TypeScript generics: Type-safe response bodies
- No browser required: Pure API testing without browser overhead
Pattern Examples
Example 1: Basic API Request
Context: Making authenticated API requests with automatic retry and type safety.
Implementation:
import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';
test('should fetch user data', async ({ apiRequest }) => {
const { status, body } = await apiRequest<User>({
method: 'GET',
path: '/api/users/123',
headers: { Authorization: 'Bearer token' },
});
expect(status).toBe(200);
expect(body.name).toBe('John Doe'); // TypeScript knows body is User
});Key Points:
- Generic type
<User>provides TypeScript autocomplete forbody - Status and body destructured from response
- Headers passed as object
- Automatic retry for 5xx errors (configurable)
Example 2: Schema Validation (Single Line)
Context: Validate API responses match expected schema with single-line syntax.
Implementation:
import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';
import { z } from 'zod';
// JSON Schema validation
test('should validate response schema (JSON Schema)', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/users/123',
validateSchema: {
type: 'object',
required: ['id', 'name', 'email'],
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string', format: 'email' },
},
},
});
// Throws if schema validation fails
expect(status).toBe(200);
});
// Zod schema validation
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
test('should validate response schema (Zod)', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/users/123',
validateSchema: UserSchema,
});
// Response body is type-safe AND validated
expect(status).toBe(200);
expect(body.email).toContain('@');
});Key Points:
- Single
validateSchemaparameter - Supports JSON Schema, Zod, YAML files, OpenAPI specs
- Throws on validation failure with detailed errors
- Zero boilerplate validation code
Example 3: POST with Body and Retry Configuration
Context: Creating resources with custom retry behavior for error testing.
Implementation:
test('should create user', async ({ apiRequest }) => {
const newUser = {
name: 'Jane Doe',
email: 'jane@example.com',
};
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/users',
body: newUser, // Automatically sent as JSON
headers: { Authorization: 'Bearer token' },
});
expect(status).toBe(201);
expect(body.id).toBeDefined();
});
// Disable retry for error testing
test('should handle 500 errors', async ({ apiRequest }) => {
await expect(
apiRequest({
method: 'GET',
path: '/api/error',
retryConfig: { maxRetries: 0 }, // Disable retry
}),
).rejects.toThrow('Request failed with status 500');
});Key Points:
bodyparameter auto-serializes to JSON- Default retry: 5xx errors, 3 retries, exponential backoff
- Disable retry with
retryConfig: { maxRetries: 0 } - Only 5xx errors retry (4xx errors fail immediately)
Example 4: URL Resolution Strategy
Context: Flexible URL handling for different environments and test contexts.
Implementation:
// Strategy 1: Explicit baseUrl (highest priority)
await apiRequest({
method: 'GET',
path: '/users',
baseUrl: 'https://api.example.com', // Uses https://api.example.com/users
});
// Strategy 2: Config baseURL (from fixture)
import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';
test.use({ configBaseUrl: 'https://staging-api.example.com' });
test('uses config baseURL', async ({ apiRequest }) => {
await apiRequest({
method: 'GET',
path: '/users', // Uses https://staging-api.example.com/users
});
});
// Strategy 3: Playwright baseURL (from playwright.config.ts)
// playwright.config.ts
export default defineConfig({
use: {
baseURL: 'https://api.example.com',
},
});
test('uses Playwright baseURL', async ({ apiRequest }) => {
await apiRequest({
method: 'GET',
path: '/users', // Uses https://api.example.com/users
});
});
// Strategy 4: Direct path (full URL)
await apiRequest({
method: 'GET',
path: 'https://api.example.com/users', // Full URL works too
});Key Points:
- Four-tier resolution: explicit > config > Playwright > direct
- Trailing slashes normalized automatically
- Environment-specific baseUrl easy to configure
Example 5: Integration with Recurse (Polling)
Context: Waiting for async operations to complete (background jobs, eventual consistency).
Implementation:
import { test } from '@seontechnologies/playwright-utils/fixtures';
test('should poll until job completes', async ({ apiRequest, recurse }) => {
// Create job
const { body } = await apiRequest({
method: 'POST',
path: '/api/jobs',
body: { type: 'export' },
});
const jobId = body.id;
// Poll until ready
const completedJob = await recurse(
() => apiRequest({ method: 'GET', path: `/api/jobs/${jobId}` }),
(response) => response.body.status === 'completed',
{ timeout: 60000, interval: 2000 },
);
expect(completedJob.body.result).toBeDefined();
});Key Points:
apiRequestreturns full response objectrecursepolls until predicate returns true- Composable utilities work together seamlessly
Example 6: Microservice Testing (Multiple Services)
Context: Test interactions between microservices without a browser.
Implementation:
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
const USER_SERVICE = process.env.USER_SERVICE_URL || 'http://localhost:3001';
const ORDER_SERVICE = process.env.ORDER_SERVICE_URL || 'http://localhost:3002';
test.describe('Microservice Integration', () => {
test('should validate cross-service user lookup', async ({ apiRequest }) => {
// Create user in user-service
const { body: user } = await apiRequest({
method: 'POST',
path: '/api/users',
baseUrl: USER_SERVICE,
body: { name: 'Test User', email: 'test@example.com' },
});
// Create order in order-service (validates user via user-service)
const { status, body: order } = await apiRequest({
method: 'POST',
path: '/api/orders',
baseUrl: ORDER_SERVICE,
body: {
userId: user.id,
items: [{ productId: 'prod-1', quantity: 2 }],
},
});
expect(status).toBe(201);
expect(order.userId).toBe(user.id);
});
test('should reject order for invalid user', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/orders',
baseUrl: ORDER_SERVICE,
body: {
userId: 'non-existent-user',
items: [{ productId: 'prod-1', quantity: 1 }],
},
});
expect(status).toBe(400);
expect(body.code).toBe('INVALID_USER');
});
});Key Points:
- Test multiple services without browser
- Use
baseUrlto target different services - Validate cross-service communication
- Pure API testing - fast and reliable
Example 7: GraphQL API Testing
Context: Test GraphQL endpoints with queries and mutations.
Implementation:
test.describe('GraphQL API', () => {
const GRAPHQL_ENDPOINT = '/graphql';
test('should query users via GraphQL', async ({ apiRequest }) => {
const query = `
query GetUsers($limit: Int) {
users(limit: $limit) {
id
name
email
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query,
variables: { limit: 10 },
},
});
expect(status).toBe(200);
expect(body.errors).toBeUndefined();
expect(body.data.users).toHaveLength(10);
});
test('should create user via mutation', async ({ apiRequest }) => {
const mutation = `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query: mutation,
variables: {
input: { name: 'GraphQL User', email: 'gql@example.com' },
},
},
});
expect(status).toBe(200);
expect(body.data.createUser.id).toBeDefined();
});
});Key Points:
- GraphQL via POST request
- Variables in request body
- Check
body.errorsfor GraphQL errors (not status code) - Works for queries and mutations
Example 8: Operation-Based Overload (OpenAPI / Code Generators)
Context: When using a code generator (orval, openapi-generator, custom scripts) that produces typed operation definitions from an OpenAPI spec, pass the operation object directly to apiRequest. This eliminates manual method/path extraction and typeof assertions while preserving full type inference for request body, response, and query parameters. Available since v3.14.0.
Implementation:
// Generated operation definition — structural typing, no import from playwright-utils needed
// type OperationShape = { path: string; method: 'POST'|'GET'|'PUT'|'DELETE'|'PATCH'|'HEAD'; response: unknown; request: unknown; query?: unknown }
import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
// --- Basic usage: operation replaces method + path ---
test('should upsert person via operation overload', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
operation: upsertPersonv2({ customerId }),
headers: getHeaders(customerId),
body: personInput, // compile-time typed as Schemas.PersonInput
});
expect(status).toBe(200);
expect(body.id).toBeDefined(); // body typed as Schemas.Person
});
// --- Typed query parameters (replaces string concatenation) ---
test('should list people with typed query', async ({ apiRequest }) => {
const { body } = await apiRequest({
operation: getPeoplev2({ customerId }),
headers: getHeaders(customerId),
query: { page: 0, page_size: 5 }, // typed from operation's query definition
});
expect(body.items).toHaveLength(5);
});
// --- Params escape hatch (pre-formatted query strings) ---
test('should fetch billing history with raw params', async ({ apiRequest }) => {
const { body } = await apiRequest({
operation: getBillingHistoryv2({ customerId }),
headers: getHeaders(customerId),
params: {
'filters[start_date]': getThisMonthTimestamp(),
'filters[date_type]': 'MONTH',
},
});
expect(body.entries.length).toBeGreaterThan(0);
});
// --- Works with recurse (polling) ---
test('should poll until person is reviewed', async ({ apiRequest, recurse }) => {
await recurse(
async () =>
apiRequest({
operation: getPersonv2({ customerId, hash }),
headers: getHeaders(customerId),
}),
(res) => {
expect(res.status).toBe(200);
expect(res.body.status).toBe('REVIEWED');
},
{ timeout: 30000, interval: 1000 },
);
});
// --- Schema validation chains work identically ---
test('should create movie with schema validation', async ({ apiRequest }) => {
const { body } = await apiRequest({
operation: createMovieOp,
headers: commonHeaders(authToken),
body: movie,
}).validateSchema(CreateMovieResponseSchema, {
shape: { status: 200, data: { name: movie.name } },
});
expect(body.data.id).toBeDefined();
});Key Points:
- Pass
operationinstead ofmethod+path— mutually exclusive at compile time - Response body, request body, and query types inferred from operation definition
- Uses structural typing (duck typing) — works with any code generator producing
{ path, method, response, request, query? } queryfield auto-serializes to bracket notation (filters[type]=pep,ids[0]=10)paramsescape hatch for pre-formatted strings — wins overqueryon conflict- Fully composable with
recurse,validateSchema, and all existing features response/request/queryon the operation are type-level only — runtime never reads their values
Comparison with Vanilla Playwright
| Vanilla Playwright | playwright-utils apiRequest |
|---|---|
const resp = await request.get('/api/users') | const { status, body } = await apiRequest({ method: 'GET', path: '/api/users' }) |
const body = await resp.json() | Response already parsed |
expect(resp.ok()).toBeTruthy() | Status code directly accessible |
| No retry logic | Auto-retry 5xx errors with backoff |
| No schema validation | Built-in multi-format validation |
| Manual error handling | Descriptive error messages |
When to Use
Use apiRequest for:
- ✅ Pure API/service testing (no browser needed)
- ✅ Microservice integration testing
- ✅ GraphQL API testing
- ✅ Schema validation needs
- ✅ Tests requiring retry logic
- ✅ Background API calls in UI tests
- ✅ Contract testing support
- ✅ Type-safe API testing with OpenAPI-generated operations (v3.14.0+)
Stick with vanilla Playwright for:
- Simple one-off requests where utility overhead isn't worth it
- Testing Playwright's native features specifically
- Legacy tests where migration isn't justified
Related Fragments
api-testing-patterns.md- Comprehensive pure API testing patternsoverview.md- Installation and design principlesauth-session.md- Authentication token managementrecurse.md- Polling for async operationsfixtures-composition.md- Combining utilities with mergeTestslog.md- Logging API requestscontract-testing.md- Pact contract testing
Anti-Patterns
❌ Ignoring retry failures:
try {
await apiRequest({ method: 'GET', path: '/api/unstable' });
} catch {
// Silent failure - loses retry information
}✅ Let retries happen, handle final failure:
await expect(apiRequest({ method: 'GET', path: '/api/unstable' })).rejects.toThrow(); // Retries happen automatically, then final error caught❌ Disabling TypeScript benefits:
const response: any = await apiRequest({ method: 'GET', path: '/users' });✅ Use generic types:
const { body } = await apiRequest<User[]>({ method: 'GET', path: '/users' });
// body is typed as User[]❌ Mixing operation overload with explicit generics:
// Don't pass a generic when using operation — types are inferred from the operation
const { body } = await apiRequest<MyType>({
operation: getPersonv2({ customerId }),
headers: getHeaders(customerId),
});✅ Let the operation infer the types:
const { body } = await apiRequest({
operation: getPersonv2({ customerId }),
headers: getHeaders(customerId),
});
// body type inferred from operation.response❌ Mixing operation with method/path:
// Compile error — operation and method/path are mutually exclusive
await apiRequest({
operation: getPersonv2({ customerId }),
method: 'GET', // Error: method?: never
path: '/api/person', // Error: path?: never
});API Testing Patterns
Principle
Test APIs and backend services directly without browser overhead. Use Playwright's request context for HTTP operations, apiRequest utility for enhanced features, and recurse for async operations. Pure API tests run faster, are more stable, and provide better coverage for service-layer logic.
Rationale
Many teams over-rely on E2E/browser tests when API tests would be more appropriate:
- Slower feedback: Browser tests take seconds, API tests take milliseconds
- More brittle: UI changes break tests even when API works correctly
- Wrong abstraction: Testing business logic through UI layers adds noise
- Resource heavy: Browsers consume memory and CPU
API-first testing provides:
- Fast execution: No browser startup, no rendering, no JavaScript execution
- Direct validation: Test exactly what the service returns
- Better isolation: Test service logic independent of UI
- Easier debugging: Clear request/response without DOM noise
- Contract validation: Verify API contracts explicitly
When to Use API Tests vs E2E Tests
| Scenario | API Test | E2E Test |
|---|---|---|
| CRUD operations | ✅ Primary | ❌ Overkill |
| Business logic validation | ✅ Primary | ❌ Overkill |
| Error handling (4xx, 5xx) | ✅ Primary | ⚠️ Supplement |
| Authentication flows | ✅ Primary | ⚠️ Supplement |
| Data transformation | ✅ Primary | ❌ Overkill |
| User journeys | ❌ Can't test | ✅ Primary |
| Visual regression | ❌ Can't test | ✅ Primary |
| Cross-browser issues | ❌ Can't test | ✅ Primary |
Rule of thumb: If you're testing what the server returns (not how it looks), use API tests.
Pattern Examples
Example 1: Pure API Test (No Browser)
Context: Test REST API endpoints directly without any browser context.
Implementation:
// tests/api/users.spec.ts
import { test, expect } from '@playwright/test';
// No page, no browser - just API
test.describe('Users API', () => {
test('should create user', async ({ request }) => {
const response = await request.post('/api/users', {
data: {
name: 'John Doe',
email: 'john@example.com',
role: 'user',
},
});
expect(response.status()).toBe(201);
const user = await response.json();
expect(user.id).toBeDefined();
expect(user.name).toBe('John Doe');
expect(user.email).toBe('john@example.com');
});
test('should get user by ID', async ({ request }) => {
// Create user first
const createResponse = await request.post('/api/users', {
data: { name: 'Jane Doe', email: 'jane@example.com' },
});
const { id } = await createResponse.json();
// Get user
const getResponse = await request.get(`/api/users/${id}`);
expect(getResponse.status()).toBe(200);
const user = await getResponse.json();
expect(user.id).toBe(id);
expect(user.name).toBe('Jane Doe');
});
test('should return 404 for non-existent user', async ({ request }) => {
const response = await request.get('/api/users/non-existent-id');
expect(response.status()).toBe(404);
const error = await response.json();
expect(error.code).toBe('USER_NOT_FOUND');
});
test('should validate required fields', async ({ request }) => {
const response = await request.post('/api/users', {
data: { name: 'Missing Email' }, // email is required
});
expect(response.status()).toBe(400);
const error = await response.json();
expect(error.code).toBe('VALIDATION_ERROR');
expect(error.details).toContainEqual(expect.objectContaining({ field: 'email', message: expect.any(String) }));
});
});Key Points:
- No
pagefixture needed - onlyrequest - Tests run without browser overhead
- Direct HTTP assertions
- Clear error handling tests
Example 2: API Test with apiRequest Utility
Context: Use enhanced apiRequest for schema validation, retry, and type safety.
Implementation:
// tests/api/orders.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
import { z } from 'zod';
// Define schema for type safety and validation
const OrderSchema = z.object({
id: z.string().uuid(),
userId: z.string(),
items: z.array(
z.object({
productId: z.string(),
quantity: z.number().positive(),
price: z.number().positive(),
}),
),
total: z.number().positive(),
status: z.enum(['pending', 'processing', 'shipped', 'delivered']),
createdAt: z.string().datetime(),
});
type Order = z.infer<typeof OrderSchema>;
test.describe('Orders API', () => {
test('should create order with schema validation', async ({ apiRequest }) => {
const { status, body } = await apiRequest<Order>({
method: 'POST',
path: '/api/orders',
body: {
userId: 'user-123',
items: [
{ productId: 'prod-1', quantity: 2, price: 29.99 },
{ productId: 'prod-2', quantity: 1, price: 49.99 },
],
},
validateSchema: OrderSchema, // Validates response matches schema
});
expect(status).toBe(201);
expect(body.id).toBeDefined();
expect(body.status).toBe('pending');
expect(body.total).toBe(109.97); // 2*29.99 + 49.99
});
test('should handle server errors with retry', async ({ apiRequest }) => {
// apiRequest retries 5xx errors by default
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/orders/order-123',
retryConfig: {
maxRetries: 3,
retryDelay: 1000,
},
});
expect(status).toBe(200);
});
test('should list orders with pagination', async ({ apiRequest }) => {
const { status, body } = await apiRequest<{ orders: Order[]; total: number; page: number }>({
method: 'GET',
path: '/api/orders',
params: { page: 1, limit: 10, status: 'pending' },
});
expect(status).toBe(200);
expect(body.orders).toHaveLength(10);
expect(body.total).toBeGreaterThan(10);
expect(body.page).toBe(1);
});
});Key Points:
- Zod schema for runtime validation AND TypeScript types
validateSchemathrows if response doesn't match- Built-in retry for transient failures
- Type-safe
bodyaccess - Note: If your project uses code-generated operations from an OpenAPI spec, see Example 8 for the preferred
operation-based overload (v3.14.0+)
Example 3: Microservice-to-Microservice Testing
Context: Test service interactions without browser - validate API contracts between services.
Implementation:
// tests/api/service-integration.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
test.describe('Service Integration', () => {
const USER_SERVICE_URL = process.env.USER_SERVICE_URL || 'http://localhost:3001';
const ORDER_SERVICE_URL = process.env.ORDER_SERVICE_URL || 'http://localhost:3002';
const INVENTORY_SERVICE_URL = process.env.INVENTORY_SERVICE_URL || 'http://localhost:3003';
test('order service should validate user exists', async ({ apiRequest }) => {
// Create user in user-service
const { body: user } = await apiRequest({
method: 'POST',
path: '/api/users',
baseUrl: USER_SERVICE_URL,
body: { name: 'Test User', email: 'test@example.com' },
});
// Create order in order-service (should validate user via user-service)
const { status, body: order } = await apiRequest({
method: 'POST',
path: '/api/orders',
baseUrl: ORDER_SERVICE_URL,
body: {
userId: user.id,
items: [{ productId: 'prod-1', quantity: 1 }],
},
});
expect(status).toBe(201);
expect(order.userId).toBe(user.id);
});
test('order service should reject invalid user', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/orders',
baseUrl: ORDER_SERVICE_URL,
body: {
userId: 'non-existent-user',
items: [{ productId: 'prod-1', quantity: 1 }],
},
});
expect(status).toBe(400);
expect(body.code).toBe('INVALID_USER');
});
test('order should decrease inventory', async ({ apiRequest, recurse }) => {
// Get initial inventory
const { body: initialInventory } = await apiRequest({
method: 'GET',
path: '/api/inventory/prod-1',
baseUrl: INVENTORY_SERVICE_URL,
});
// Create order
await apiRequest({
method: 'POST',
path: '/api/orders',
baseUrl: ORDER_SERVICE_URL,
body: {
userId: 'user-123',
items: [{ productId: 'prod-1', quantity: 2 }],
},
});
// Poll for inventory update (eventual consistency)
const { body: updatedInventory } = await recurse(
() =>
apiRequest({
method: 'GET',
path: '/api/inventory/prod-1',
baseUrl: INVENTORY_SERVICE_URL,
}),
(response) => response.body.quantity === initialInventory.quantity - 2,
{ timeout: 10000, interval: 500 },
);
expect(updatedInventory.quantity).toBe(initialInventory.quantity - 2);
});
});Key Points:
- Multiple service URLs for microservice testing
- Tests service-to-service communication
- Uses
recursefor eventual consistency - No browser needed for full integration testing
Example 4: GraphQL API Testing
Context: Test GraphQL endpoints with queries and mutations.
Implementation:
// tests/api/graphql.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
const GRAPHQL_ENDPOINT = '/graphql';
test.describe('GraphQL API', () => {
test('should query users', async ({ apiRequest }) => {
const query = `
query GetUsers($limit: Int) {
users(limit: $limit) {
id
name
email
role
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query,
variables: { limit: 10 },
},
});
expect(status).toBe(200);
expect(body.errors).toBeUndefined();
expect(body.data.users).toHaveLength(10);
expect(body.data.users[0]).toHaveProperty('id');
expect(body.data.users[0]).toHaveProperty('name');
});
test('should create user via mutation', async ({ apiRequest }) => {
const mutation = `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query: mutation,
variables: {
input: {
name: 'GraphQL User',
email: 'graphql@example.com',
},
},
},
});
expect(status).toBe(200);
expect(body.errors).toBeUndefined();
expect(body.data.createUser.id).toBeDefined();
expect(body.data.createUser.name).toBe('GraphQL User');
});
test('should handle GraphQL errors', async ({ apiRequest }) => {
const query = `
query GetUser($id: ID!) {
user(id: $id) {
id
name
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query,
variables: { id: 'non-existent' },
},
});
expect(status).toBe(200); // GraphQL returns 200 even for errors
expect(body.errors).toBeDefined();
expect(body.errors[0].message).toContain('not found');
expect(body.data.user).toBeNull();
});
test('should handle validation errors', async ({ apiRequest }) => {
const mutation = `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query: mutation,
variables: {
input: {
name: '', // Invalid: empty name
email: 'invalid-email', // Invalid: bad format
},
},
},
});
expect(status).toBe(200);
expect(body.errors).toBeDefined();
expect(body.errors[0].extensions.code).toBe('BAD_USER_INPUT');
});
});Key Points:
- GraphQL queries and mutations via POST
- Variables passed in request body
- GraphQL returns 200 even for errors (check
body.errors) - Test validation and business logic errors
Example 5: Database Seeding and Cleanup via API
Context: Use API calls to set up and tear down test data without direct database access.
Implementation:
// tests/api/with-data-setup.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
test.describe('Orders with Data Setup', () => {
let testUser: { id: string; email: string };
let testProducts: Array<{ id: string; name: string; price: number }>;
test.beforeAll(async ({ request }) => {
// Seed user via API
const userResponse = await request.post('/api/users', {
data: {
name: 'Test User',
email: `test-${Date.now()}@example.com`,
},
});
testUser = await userResponse.json();
// Seed products via API
testProducts = [];
for (const product of [
{ name: 'Widget A', price: 29.99 },
{ name: 'Widget B', price: 49.99 },
{ name: 'Widget C', price: 99.99 },
]) {
const productResponse = await request.post('/api/products', {
data: product,
});
testProducts.push(await productResponse.json());
}
});
test.afterAll(async ({ request }) => {
// Cleanup via API
if (testUser?.id) {
await request.delete(`/api/users/${testUser.id}`);
}
for (const product of testProducts) {
await request.delete(`/api/products/${product.id}`);
}
});
test('should create order with seeded data', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/orders',
body: {
userId: testUser.id,
items: [
{ productId: testProducts[0].id, quantity: 2 },
{ productId: testProducts[1].id, quantity: 1 },
],
},
});
expect(status).toBe(201);
expect(body.userId).toBe(testUser.id);
expect(body.items).toHaveLength(2);
expect(body.total).toBe(2 * 29.99 + 49.99);
});
test('should list user orders', async ({ apiRequest }) => {
// Create an order first
await apiRequest({
method: 'POST',
path: '/api/orders',
body: {
userId: testUser.id,
items: [{ productId: testProducts[2].id, quantity: 1 }],
},
});
// List orders for user
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/orders',
params: { userId: testUser.id },
});
expect(status).toBe(200);
expect(body.orders.length).toBeGreaterThanOrEqual(1);
expect(body.orders.every((o: any) => o.userId === testUser.id)).toBe(true);
});
});Key Points:
beforeAll/afterAllfor test data setup/cleanup- API-based seeding (no direct DB access needed)
- Unique emails to prevent conflicts in parallel runs
- Cleanup after all tests complete
Example 6: Background Job Testing with Recurse
Context: Test async operations like background jobs, webhooks, and eventual consistency.
Implementation:
// tests/api/background-jobs.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
test.describe('Background Jobs', () => {
test('should process export job', async ({ apiRequest, recurse }) => {
// Trigger export job
const { body: job } = await apiRequest({
method: 'POST',
path: '/api/exports',
body: {
type: 'users',
format: 'csv',
filters: { createdAfter: '2024-01-01' },
},
});
expect(job.id).toBeDefined();
expect(job.status).toBe('pending');
// Poll until job completes
const { body: completedJob } = await recurse(
() => apiRequest({ method: 'GET', path: `/api/exports/${job.id}` }),
(response) => response.body.status === 'completed',
{
timeout: 60000,
interval: 2000,
log: `Waiting for export job ${job.id} to complete`,
},
);
expect(completedJob.status).toBe('completed');
expect(completedJob.downloadUrl).toBeDefined();
expect(completedJob.recordCount).toBeGreaterThan(0);
});
test('should handle job failure gracefully', async ({ apiRequest, recurse }) => {
// Trigger job that will fail
const { body: job } = await apiRequest({
method: 'POST',
path: '/api/exports',
body: {
type: 'invalid-type', // This will cause failure
format: 'csv',
},
});
// Poll until job fails
const { body: failedJob } = await recurse(
() => apiRequest({ method: 'GET', path: `/api/exports/${job.id}` }),
(response) => ['completed', 'failed'].includes(response.body.status),
{ timeout: 30000 },
);
expect(failedJob.status).toBe('failed');
expect(failedJob.error).toBeDefined();
expect(failedJob.error.code).toBe('INVALID_EXPORT_TYPE');
});
test('should process webhook delivery', async ({ apiRequest, recurse }) => {
// Trigger action that sends webhook
const { body: order } = await apiRequest({
method: 'POST',
path: '/api/orders',
body: {
userId: 'user-123',
items: [{ productId: 'prod-1', quantity: 1 }],
webhookUrl: 'https://webhook.site/test-endpoint',
},
});
// Poll for webhook delivery status
const { body: webhookStatus } = await recurse(
() => apiRequest({ method: 'GET', path: `/api/webhooks/order/${order.id}` }),
(response) => response.body.delivered === true,
{ timeout: 30000, interval: 1000 },
);
expect(webhookStatus.delivered).toBe(true);
expect(webhookStatus.deliveredAt).toBeDefined();
expect(webhookStatus.responseStatus).toBe(200);
});
});Key Points:
recursefor polling async operations- Test both success and failure scenarios
- Configurable timeout and interval
- Log messages for debugging
Example 7: Service Authentication (No Browser)
Context: Test authenticated API endpoints using tokens directly - no browser login needed.
Implementation:
// tests/api/authenticated.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
test.describe('Authenticated API Tests', () => {
let authToken: string;
test.beforeAll(async ({ request }) => {
// Get token via API (no browser!)
const response = await request.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD,
},
});
const { token } = await response.json();
authToken = token;
});
test('should access protected endpoint with token', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/me',
headers: {
Authorization: `Bearer ${authToken}`,
},
});
expect(status).toBe(200);
expect(body.email).toBe(process.env.TEST_USER_EMAIL);
});
test('should reject request without token', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/me',
// No Authorization header
});
expect(status).toBe(401);
expect(body.code).toBe('UNAUTHORIZED');
});
test('should reject expired token', async ({ apiRequest }) => {
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'; // Expired token
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/me',
headers: {
Authorization: `Bearer ${expiredToken}`,
},
});
expect(status).toBe(401);
expect(body.code).toBe('TOKEN_EXPIRED');
});
test('should handle role-based access', async ({ apiRequest }) => {
// User token (non-admin)
const { status } = await apiRequest({
method: 'GET',
path: '/api/admin/users',
headers: {
Authorization: `Bearer ${authToken}`,
},
});
expect(status).toBe(403); // Forbidden for non-admin
});
});Key Points:
- Token obtained via API login (no browser)
- Token reused across all tests in describe block
- Test auth, expired tokens, and RBAC
- Pure API testing without UI
Example 8: Operation-Based API Testing (OpenAPI / Code Generators)
Context: When your project uses code-generated operation definitions from an OpenAPI spec, leverage the operation-based overload of apiRequest (v3.14.0+) instead of manual method/path extraction. This eliminates typeof assertions and provides full type inference for request body, response, and query parameters.
Implementation:
// tests/api/operations.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
test.describe('API Tests with Generated Operations', () => {
test('should create entity with full type safety', async ({ apiRequest }) => {
// Operation object from code generator — contains path, method, and type info
const { status, body } = await apiRequest({
operation: createEntityOp({ workspaceId }),
headers: getHeaders(workspaceId),
body: entityInput, // Compile-time typed from operation.request
});
expect(status).toBe(201);
expect(body.id).toBeDefined(); // body typed from operation.response
});
test('should list with typed query parameters', async ({ apiRequest }) => {
// query field replaces manual string concatenation
const { body } = await apiRequest({
operation: listEntitiesOp({ workspaceId }),
headers: getHeaders(workspaceId),
query: { page: 0, page_size: 10, status: 'active' },
});
expect(body.items).toHaveLength(10);
expect(body.total).toBeGreaterThan(10);
});
test('should poll async operation until complete', async ({ apiRequest, recurse }) => {
const { body: job } = await apiRequest({
operation: startJobOp({ workspaceId }),
headers: getHeaders(workspaceId),
body: { type: 'export' },
});
await recurse(
async () =>
apiRequest({
operation: getJobOp({ workspaceId, jobId: job.id }),
headers: getHeaders(workspaceId),
}),
(res) => res.body.status === 'completed',
{ timeout: 60000, interval: 2000 },
);
});
});Key Points:
operationreplacesmethod+path— mutually exclusive at compile time- Types for body, response, and query all inferred from the operation definition
- Works with any code generator using structural typing (no imports from playwright-utils needed in generator)
- Composable with
recurse,validateSchema, and all existingapiRequestfeatures - Preferred approach over
typeof operation.responsefor generated operations
API Test Configuration
Playwright Config for API-Only Tests
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/api',
// No browser needed for API tests
use: {
baseURL: process.env.API_URL || 'http://localhost:3000',
extraHTTPHeaders: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
// Faster without browser overhead
timeout: 30000,
// Run API tests in parallel
workers: 4,
fullyParallel: true,
// No screenshots/traces needed for API tests
reporter: [['html'], ['json', { outputFile: 'api-test-results.json' }]],
});Separate API Test Project
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'api',
testDir: './tests/api',
use: {
baseURL: process.env.API_URL,
},
},
{
name: 'e2e',
testDir: './tests/e2e',
use: {
baseURL: process.env.APP_URL,
...devices['Desktop Chrome'],
},
},
],
});Comparison: API Tests vs E2E Tests
| Aspect | API Test | E2E Test |
|---|---|---|
| Speed | ~50-100ms per test | ~2-10s per test |
| Stability | Very stable | More flaky (UI timing) |
| Setup | Minimal | Browser, context, page |
| Debugging | Clear request/response | DOM, screenshots, traces |
| Coverage | Service logic | User experience |
| Parallelization | Easy (stateless) | Complex (browser resources) |
| CI Cost | Low (no browser) | High (browser containers) |
Related Fragments
api-request.md- apiRequest utility detailsrecurse.md- Polling patterns for async operationsauth-session.md- Token managementcontract-testing.md- Pact contract testingtest-levels-framework.md- When to use which test leveldata-factories.md- Test data setup patterns
Anti-Patterns
DON'T use E2E for API validation:
// Bad: Testing API through UI
test('validate user creation', async ({ page }) => {
await page.goto('/admin/users');
await page.fill('#name', 'John');
await page.click('#submit');
await expect(page.getByText('User created')).toBeVisible();
});DO test APIs directly:
// Good: Direct API test
test('validate user creation', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/users',
body: { name: 'John' },
});
expect(status).toBe(201);
expect(body.id).toBeDefined();
});DON'T ignore API tests because "E2E covers it":
// Bad thinking: "Our E2E tests create users, so API is tested"
// Reality: E2E tests one happy path; API tests cover edge casesDO have dedicated API test coverage:
// Good: Explicit API test suite
test.describe('Users API', () => {
test('creates user', async ({ apiRequest }) => {
/* ... */
});
test('handles duplicate email', async ({ apiRequest }) => {
/* ... */
});
test('validates required fields', async ({ apiRequest }) => {
/* ... */
});
test('handles malformed JSON', async ({ apiRequest }) => {
/* ... */
});
test('rate limits requests', async ({ apiRequest }) => {
/* ... */
});
});Auth Session Utility
Principle
Persist authentication tokens to disk and reuse across test runs. Support multiple user identifiers, ephemeral authentication, and worker-specific accounts for parallel execution. Fetch tokens once, use everywhere. Works for both API-only tests and browser tests.
Rationale
Playwright's built-in authentication works but has limitations:
- Re-authenticates for every test run (slow)
- Single user per project setup
- No token expiration handling
- Manual session management
- Complex setup for multi-user scenarios
The auth-session utility provides:
- Token persistence: Authenticate once, reuse across runs
- Multi-user support: Different user identifiers in same test suite
- Ephemeral auth: On-the-fly user authentication without disk persistence
- Worker-specific accounts: Parallel execution with isolated user accounts
- Automatic token management: Checks validity, renews if expired
- Flexible provider pattern: Adapt to any auth system (OAuth2, JWT, custom)
- API-first design: Get tokens for API tests without browser overhead
Pattern Examples
Example 1: Basic Auth Session Setup
Context: Configure global authentication that persists across test runs.
Implementation:
// Step 1: Configure in global-setup.ts
import { authStorageInit, setAuthProvider, configureAuthSession, authGlobalInit } from '@seontechnologies/playwright-utils/auth-session';
import myCustomProvider from './auth/custom-auth-provider';
async function globalSetup() {
// Ensure storage directories exist
authStorageInit();
// Configure storage path
configureAuthSession({
authStoragePath: process.cwd() + '/playwright/auth-sessions',
debug: true,
});
// Set custom provider (HOW to authenticate)
setAuthProvider(myCustomProvider);
// Optional: pre-fetch token for default user
await authGlobalInit();
}
export default globalSetup;
// Step 2: Create auth fixture
import { test as base } from '@playwright/test';
import { createAuthFixtures, setAuthProvider } from '@seontechnologies/playwright-utils/auth-session';
import myCustomProvider from './custom-auth-provider';
// Register provider early
setAuthProvider(myCustomProvider);
export const test = base.extend(createAuthFixtures());
// Step 3: Use in tests
test('authenticated request', async ({ authToken, request }) => {
const response = await request.get('/api/protected', {
headers: { Authorization: `Bearer ${authToken}` },
});
expect(response.ok()).toBeTruthy();
});Key Points:
- Global setup runs once before all tests
- Token fetched once, reused across all tests
- Custom provider defines your auth mechanism
- Order matters: configure, then setProvider, then init
Example 2: Multi-User Authentication
Context: Testing with different user roles (admin, regular user, guest) in same test suite.
Implementation:
import { test } from '../support/auth/auth-fixture';
// Option 1: Per-test user override
test('admin actions', async ({ authToken, authOptions }) => {
// Override default user
authOptions.userIdentifier = 'admin';
const { authToken: adminToken } = await test.step('Get admin token', async () => {
return { authToken }; // Re-fetches with new identifier
});
// Use admin token
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${adminToken}` },
});
});
// Option 2: Parallel execution with different users
test.describe.parallel('multi-user tests', () => {
test('user 1 actions', async ({ authToken }) => {
// Uses default user (e.g., 'user1')
});
test('user 2 actions', async ({ authToken, authOptions }) => {
authOptions.userIdentifier = 'user2';
// Uses different token for user2
});
});Key Points:
- Override
authOptions.userIdentifierper test - Tokens cached separately per user identifier
- Parallel tests isolated with different users
- Worker-specific accounts possible
Example 3: Ephemeral User Authentication
Context: Create temporary test users that don't persist to disk (e.g., testing user creation flow).
Implementation:
import { applyUserCookiesToBrowserContext } from '@seontechnologies/playwright-utils/auth-session';
import { createTestUser } from '../utils/user-factory';
test('ephemeral user test', async ({ context, page }) => {
// Create temporary user (not persisted)
const ephemeralUser = await createTestUser({
role: 'admin',
permissions: ['delete-users'],
});
// Apply auth directly to browser context
await applyUserCookiesToBrowserContext(context, ephemeralUser);
// Page now authenticated as ephemeral user
await page.goto('/admin/users');
await expect(page.getByTestId('delete-user-btn')).toBeVisible();
// User and token cleaned up after test
});Key Points:
- No disk persistence (ephemeral)
- Apply cookies directly to context
- Useful for testing user lifecycle
- Clean up automatic when test ends
Example 4: Testing Multiple Users in Single Test
Context: Testing interactions between users (messaging, sharing, collaboration features).
Implementation:
test('user interaction', async ({ browser }) => {
// User 1 context
const user1Context = await browser.newContext({
storageState: './auth-sessions/local/user1/storage-state.json',
});
const user1Page = await user1Context.newPage();
// User 2 context
const user2Context = await browser.newContext({
storageState: './auth-sessions/local/user2/storage-state.json',
});
const user2Page = await user2Context.newPage();
// User 1 sends message
await user1Page.goto('/messages');
await user1Page.fill('#message', 'Hello from user 1');
await user1Page.click('#send');
// User 2 receives message
await user2Page.goto('/messages');
await expect(user2Page.getByText('Hello from user 1')).toBeVisible();
// Cleanup
await user1Context.close();
await user2Context.close();
});Key Points:
- Each user has separate browser context
- Reference storage state files directly
- Test real-time interactions
- Clean up contexts after test
Example 5: Worker-Specific Accounts (Parallel Testing)
Context: Running tests in parallel with isolated user accounts per worker to avoid conflicts.
Implementation:
// playwright.config.ts
export default defineConfig({
workers: 4, // 4 parallel workers
use: {
// Each worker uses different user
storageState: async ({}, use, testInfo) => {
const workerIndex = testInfo.workerIndex;
const userIdentifier = `worker-${workerIndex}`;
await use(`./auth-sessions/local/${userIdentifier}/storage-state.json`);
},
},
});
// Tests run in parallel, each worker with its own user
test('parallel test 1', async ({ page }) => {
// Worker 0 uses worker-0 account
await page.goto('/dashboard');
});
test('parallel test 2', async ({ page }) => {
// Worker 1 uses worker-1 account
await page.goto('/dashboard');
});Key Points:
- Each worker has isolated user account
- No conflicts in parallel execution
- Token management automatic per worker
- Scales to any number of workers
Example 6: Pure API Authentication (No Browser)
Context: Get auth tokens for API-only tests using auth-session disk persistence.
Implementation:
// Step 1: Create API-only auth provider (no browser needed)
// playwright/support/api-auth-provider.ts
import { type AuthProvider } from '@seontechnologies/playwright-utils/auth-session';
const apiAuthProvider: AuthProvider = {
getEnvironment: (options) => options.environment || 'local',
getUserIdentifier: (options) => options.userIdentifier || 'api-user',
extractToken: (storageState) => {
// Token stored in localStorage format for disk persistence
const tokenEntry = storageState.origins?.[0]?.localStorage?.find((item) => item.name === 'auth_token');
return tokenEntry?.value;
},
isTokenExpired: (storageState) => {
const expiryEntry = storageState.origins?.[0]?.localStorage?.find((item) => item.name === 'token_expiry');
if (!expiryEntry) return true;
return Date.now() > parseInt(expiryEntry.value, 10);
},
manageAuthToken: async (request, options) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
throw new Error('TEST_USER_EMAIL and TEST_USER_PASSWORD must be set');
}
// Pure API login - no browser!
const response = await request.post('/api/auth/login', {
data: { email, password },
});
if (!response.ok()) {
throw new Error(`Auth failed: ${response.status()}`);
}
const { token, expiresIn } = await response.json();
const expiryTime = Date.now() + expiresIn * 1000;
// Return storage state format for disk persistence
return {
cookies: [],
origins: [
{
origin: process.env.API_BASE_URL || 'http://localhost:3000',
localStorage: [
{ name: 'auth_token', value: token },
{ name: 'token_expiry', value: String(expiryTime) },
],
},
],
};
},
};
export default apiAuthProvider;
// Step 2: Create auth fixture
// playwright/support/fixtures.ts
import { test as base } from '@playwright/test';
import { createAuthFixtures, setAuthProvider } from '@seontechnologies/playwright-utils/auth-session';
import apiAuthProvider from './api-auth-provider';
setAuthProvider(apiAuthProvider);
export const test = base.extend(createAuthFixtures());
// Step 3: Use in tests - token persisted to disk!
// tests/api/authenticated-api.spec.ts
import { test } from '../support/fixtures';
import { expect } from '@playwright/test';
test('should access protected endpoint', async ({ authToken, apiRequest }) => {
// authToken is automatically loaded from disk or fetched if expired
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/me',
headers: { Authorization: `Bearer ${authToken}` },
});
expect(status).toBe(200);
});
test('should create resource with auth', async ({ authToken, apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/orders',
headers: { Authorization: `Bearer ${authToken}` },
body: { items: [{ productId: 'prod-1', quantity: 2 }] },
});
expect(status).toBe(201);
expect(body.id).toBeDefined();
});Key Points:
- Token persisted to disk (not in-memory) - survives test reruns
- Provider fetches token once, reuses until expired
- Pure API authentication - no browser context needed
authTokenfixture handles disk read/write automatically- Environment variables validated with clear error message
Example 7: Service-to-Service Authentication
Context: Test microservice authentication patterns (API keys, service tokens) with proper environment validation.
Implementation:
// tests/api/service-auth.spec.ts
import { test as base, expect } from '@playwright/test';
import { test as apiFixture } from '@seontechnologies/playwright-utils/api-request/fixtures';
import { mergeTests } from '@playwright/test';
// Validate environment variables at module load
const SERVICE_API_KEY = process.env.SERVICE_API_KEY;
const INTERNAL_SERVICE_URL = process.env.INTERNAL_SERVICE_URL;
if (!SERVICE_API_KEY) {
throw new Error('SERVICE_API_KEY environment variable is required');
}
if (!INTERNAL_SERVICE_URL) {
throw new Error('INTERNAL_SERVICE_URL environment variable is required');
}
const test = mergeTests(base, apiFixture);
test.describe('Service-to-Service Auth', () => {
test('should authenticate with API key', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/internal/health',
baseUrl: INTERNAL_SERVICE_URL,
headers: { 'X-API-Key': SERVICE_API_KEY },
});
expect(status).toBe(200);
expect(body.status).toBe('healthy');
});
test('should reject invalid API key', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/internal/health',
baseUrl: INTERNAL_SERVICE_URL,
headers: { 'X-API-Key': 'invalid-key' },
});
expect(status).toBe(401);
expect(body.code).toBe('INVALID_API_KEY');
});
test('should call downstream service with propagated auth', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/internal/aggregate-data',
baseUrl: INTERNAL_SERVICE_URL,
headers: {
'X-API-Key': SERVICE_API_KEY,
'X-Request-ID': `test-${Date.now()}`,
},
body: { sources: ['users', 'orders', 'inventory'] },
});
expect(status).toBe(200);
expect(body.aggregatedFrom).toHaveLength(3);
});
});Key Points:
- Environment variables validated at module load with clear errors
- API key authentication (simpler than OAuth - no disk persistence needed)
- Test internal/service endpoints
- Validate auth rejection scenarios
- Correlation ID for request tracing
Note: API keys are typically static secrets that don't expire, so disk persistence (auth-session) isn't needed. For rotating service tokens, use the auth-session provider pattern from Example 6.
Custom Auth Provider Pattern
Context: Adapt auth-session to your authentication system (OAuth2, JWT, SAML, custom).
Minimal provider structure:
import { type AuthProvider } from '@seontechnologies/playwright-utils/auth-session';
const myCustomProvider: AuthProvider = {
getEnvironment: (options) => options.environment || 'local',
getUserIdentifier: (options) => options.userIdentifier || 'default-user',
extractToken: (storageState) => {
// Extract token from your storage format
return storageState.cookies.find((c) => c.name === 'auth_token')?.value;
},
extractCookies: (tokenData) => {
// Convert token to cookies for browser context
return [
{
name: 'auth_token',
value: tokenData,
domain: 'example.com',
path: '/',
httpOnly: true,
secure: true,
},
];
},
isTokenExpired: (storageState) => {
// Check if token is expired
const expiresAt = storageState.cookies.find((c) => c.name === 'expires_at');
return Date.now() > parseInt(expiresAt?.value || '0');
},
manageAuthToken: async (request, options) => {
// Main token acquisition logic
// Return storage state with cookies/localStorage
},
};
export default myCustomProvider;Integration with API Request
import { test } from '@seontechnologies/playwright-utils/fixtures';
test('authenticated API call', async ({ apiRequest, authToken }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/protected',
headers: { Authorization: `Bearer ${authToken}` },
});
expect(status).toBe(200);
});Related Fragments
api-testing-patterns.md- Pure API testing patterns (no browser)overview.md- Installation and fixture compositionapi-request.md- Authenticated API requestsfixtures-composition.md- Merging auth with other utilities
Anti-Patterns
❌ Calling setAuthProvider after globalSetup:
async function globalSetup() {
configureAuthSession(...)
await authGlobalInit() // Provider not set yet!
setAuthProvider(provider) // Too late
}✅ Register provider before init:
async function globalSetup() {
authStorageInit()
configureAuthSession(...)
setAuthProvider(provider) // First
await authGlobalInit() // Then init
}❌ Hardcoding storage paths:
const storageState = './auth-sessions/local/user1/storage-state.json'; // Brittle✅ Use helper functions:
import { getTokenFilePath } from '@seontechnologies/playwright-utils/auth-session';
const tokenPath = getTokenFilePath({
environment: 'local',
userIdentifier: 'user1',
tokenFileName: 'storage-state.json',
});Burn-in Test Runner
Principle
Use smart test selection with git diff analysis to run only affected tests. Filter out irrelevant changes (configs, types, docs) and control test volume with percentage-based execution. Reduce unnecessary CI runs while maintaining reliability.
Rationale
Playwright's --only-changed triggers all affected tests:
- Config file changes trigger hundreds of tests
- Type definition changes cause full suite runs
- No volume control (all or nothing)
- Slow CI pipelines
The burn-in utility provides:
- Smart filtering: Skip patterns for irrelevant files (configs, types, docs)
- Volume control: Run percentage of affected tests after filtering
- Custom dependency analysis: More accurate than Playwright's built-in
- CI optimization: Faster pipelines without sacrificing confidence
- Process of elimination: Start with all → filter irrelevant → control volume
Pattern Examples
Example 1: Basic Burn-in Setup
Context: Run burn-in on changed files compared to main branch.
Implementation:
// Step 1: Create burn-in script
// playwright/scripts/burn-in-changed.ts
import { runBurnIn } from '@seontechnologies/playwright-utils/burn-in'
async function main() {
await runBurnIn({
configPath: 'playwright/config/.burn-in.config.ts',
baseBranch: 'main'
})
}
main().catch(console.error)
// Step 2: Create config
// playwright/config/.burn-in.config.ts
import type { BurnInConfig } from '@seontechnologies/playwright-utils/burn-in'
const config: BurnInConfig = {
// Files that never trigger tests (first filter)
skipBurnInPatterns: [
'**/config/**',
'**/*constants*',
'**/*types*',
'**/*.md',
'**/README*'
],
// Run 30% of remaining tests after skip filter
burnInTestPercentage: 0.3,
// Burn-in repetition
burnIn: {
repeatEach: 3, // Run each test 3 times
retries: 1 // Allow 1 retry
}
}
export default config
// Step 3: Add package.json script
{
"scripts": {
"test:pw:burn-in-changed": "tsx playwright/scripts/burn-in-changed.ts"
}
}Key Points:
- Two-stage filtering: skip patterns, then volume control
skipBurnInPatternseliminates irrelevant filesburnInTestPercentagecontrols test volume (0.3 = 30%)- Custom dependency analysis finds actually affected tests
Example 2: CI Integration
Context: Use burn-in in GitHub Actions for efficient CI runs.
Implementation:
# .github/workflows/burn-in.yml
name: Burn-in Changed Tests
on:
pull_request:
branches: [main]
jobs:
burn-in:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Need git history
- name: Setup Node
uses: actions/setup-node@v4
- name: Install dependencies
run: npm ci
- name: Run burn-in on changed tests
run: npm run test:pw:burn-in-changed -- --base-branch=origin/main
- name: Upload artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: burn-in-failures
path: test-results/Key Points:
fetch-depth: 0for full git history- Pass
--base-branch=origin/mainfor PR comparison - Upload artifacts only on failure
- Significantly faster than full suite
Example 3: How It Works (Process of Elimination)
Context: Understanding the filtering pipeline.
Scenario:
Git diff finds: 21 changed files
├─ Step 1: Skip patterns filter
│ Removed: 6 files (*.md, config/*, *types*)
│ Remaining: 15 files
│
├─ Step 2: Dependency analysis
│ Tests that import these 15 files: 45 tests
│
└─ Step 3: Volume control (30%)
Final tests to run: 14 tests (30% of 45)
Result: Run 14 targeted tests instead of 147 with --only-changed!Key Points:
- Three-stage pipeline: skip → analyze → control
- Custom dependency analysis (not just imports)
- Percentage applies AFTER filtering
- Dramatically reduces CI time
Example 4: Environment-Specific Configuration
Context: Different settings for local vs CI environments.
Implementation:
import type { BurnInConfig } from '@seontechnologies/playwright-utils/burn-in';
const config: BurnInConfig = {
skipBurnInPatterns: ['**/config/**', '**/*types*', '**/*.md'],
// CI runs fewer iterations, local runs more
burnInTestPercentage: process.env.CI ? 0.2 : 0.3,
burnIn: {
repeatEach: process.env.CI ? 2 : 3,
retries: process.env.CI ? 0 : 1, // No retries in CI
},
};
export default config;Key Points:
process.env.CIfor environment detection- Lower percentage in CI (20% vs 30%)
- Fewer iterations in CI (2 vs 3)
- No retries in CI (fail fast)
Example 5: Sharding Support
Context: Distribute burn-in tests across multiple CI workers.
Implementation:
// burn-in-changed.ts with sharding
import { runBurnIn } from '@seontechnologies/playwright-utils/burn-in';
async function main() {
const shardArg = process.argv.find((arg) => arg.startsWith('--shard='));
if (shardArg) {
process.env.PW_SHARD = shardArg.split('=')[1];
}
await runBurnIn({
configPath: 'playwright/config/.burn-in.config.ts',
});
}# GitHub Actions with sharding
jobs:
burn-in:
strategy:
matrix:
shard: [1/3, 2/3, 3/3]
steps:
- run: npm run test:pw:burn-in-changed -- --shard=${{ matrix.shard }}Key Points:
- Pass
--shard=1/3for parallel execution - Burn-in respects Playwright sharding
- Distribute across multiple workers
- Reduces total CI time further
Integration with CI Workflow
When setting up CI with *ci workflow, recommend burn-in for:
- Pull request validation
- Pre-merge checks
- Nightly builds (subset runs)
Related Fragments
ci-burn-in.md- Traditional burn-in patterns (10-iteration loops)selective-testing.md- Test selection strategiesoverview.md- Installation
Anti-Patterns
❌ Over-aggressive skip patterns:
skipBurnInPatterns: [
'**/*', // Skips everything!
];✅ Targeted skip patterns:
skipBurnInPatterns: ['**/config/**', '**/*types*', '**/*.md', '**/*constants*'];❌ Too low percentage (false confidence):
burnInTestPercentage: 0.05; // Only 5% - might miss issues✅ Balanced percentage:
burnInTestPercentage: 0.2; // 20% in CI, provides good coveragePact.js Utils Request Filter
Principle
Use createRequestFilter and noOpRequestFilter from @seontechnologies/pactjs-utils to inject authentication headers during provider verification. The pluggable token generator pattern prevents double-Bearer bugs and separates auth concerns from verification logic.
Rationale
Problems with manual request filters
- Express type gymnastics: Pact's
requestFilterexpects(req, res, next) => voidwith Express-compatible types — but Pact doesn't re-export these types - Double-Bearer bug: Easy to write
Authorization: Bearer Bearer ${token}when the token generator already includes the prefix - Inline complexity: Auth logic mixed with verifier config makes tests harder to read
- No-op boilerplate: Providers without auth still need a pass-through function or
undefined
Solutions
- `createRequestFilter`: Accepts
{ tokenGenerator: () => string }— generator returns raw token value synchronously, filter addsBearerprefix - `noOpRequestFilter`: Pre-built pass-through for providers without auth requirements
- Bearer prefix contract:
tokenGeneratorreturns raw value (e.g.,"abc123"), filter always adds"Bearer "— impossible to double-prefix
Pattern Examples
Example 1: Basic Auth Injection
import { buildVerifierOptions, createRequestFilter } from '@seontechnologies/pactjs-utils';
const opts = buildVerifierOptions({
provider: 'SampleMoviesAPI',
port: '3001',
includeMainAndDeployed: true,
stateHandlers: {
/* ... */
},
requestFilter: createRequestFilter({
// tokenGenerator returns raw token — filter adds "Bearer " prefix
tokenGenerator: () => 'test-auth-token-123',
}),
});
// Every request during verification will have:
// Authorization: Bearer test-auth-token-123Key Points:
tokenGeneratoris synchronous (() => string) — if you need async token fetching, resolve the token before creating the filter- Return the raw token value, NOT
"Bearer ..."— the filter adds the prefix - Filter sets
Authorizationheader on every request during verification
Example 2: Dynamic Token (Pre-resolved)
import { createRequestFilter } from '@seontechnologies/pactjs-utils';
// Since tokenGenerator is synchronous, fetch the token before creating the filter
let cachedToken: string;
async function setupRequestFilter() {
const response = await fetch('http://localhost:8080/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId: process.env.TEST_CLIENT_ID,
clientSecret: process.env.TEST_CLIENT_SECRET,
}),
});
const { access_token } = await response.json();
cachedToken = access_token;
}
const requestFilter = createRequestFilter({
tokenGenerator: () => cachedToken, // Synchronous — returns pre-fetched token
});
const opts = buildVerifierOptions({
provider: 'SecureAPI',
port: '3001',
includeMainAndDeployed: true,
stateHandlers: {
/* ... */
},
requestFilter,
});Example 3: No-Auth Provider
import { buildVerifierOptions, noOpRequestFilter } from '@seontechnologies/pactjs-utils';
// For providers that don't require authentication
const opts = buildVerifierOptions({
provider: 'PublicAPI',
port: '3001',
includeMainAndDeployed: true,
stateHandlers: {
/* ... */
},
requestFilter: noOpRequestFilter,
});
// noOpRequestFilter is equivalent to: (req, res, next) => next()Example 4: Integration with buildVerifierOptions
import { buildVerifierOptions, createRequestFilter } from '@seontechnologies/pactjs-utils';
import type { StateHandlers } from '@seontechnologies/pactjs-utils';
// Complete provider verification setup
const stateHandlers: StateHandlers = {
'user is authenticated': async () => {
// Auth state is handled by the request filter, not state handler
},
'movie exists': {
setup: async (params) => {
await db.seed({ movies: [{ id: params?.id }] });
},
teardown: async () => {
await db.clean('movies');
},
},
};
const requestFilter = createRequestFilter({
tokenGenerator: () => process.env.TEST_AUTH_TOKEN ?? 'fallback-token',
});
const opts = buildVerifierOptions({
provider: 'SampleMoviesAPI',
port: process.env.PORT ?? '3001',
includeMainAndDeployed: process.env.PACT_BREAKING_CHANGE !== 'true',
stateHandlers,
requestFilter,
});
// Run verification
await new Verifier(opts).verifyProvider();Key Points
- Bearer prefix contract:
tokenGeneratorreturns raw value → filter adds"Bearer "→ impossible to double-prefix - Synchronous only:
tokenGeneratormust returnstring(notPromise<string>) — pre-resolve async tokens before creating the filter - Separation of concerns: Auth logic in
createRequestFilter, verification logic inbuildVerifierOptions - noOpRequestFilter: Use for providers without auth — cleaner than
undefinedor inline no-op - Express compatible: The returned filter matches Pact's expected
(req, res, next) => voidsignature
Related Fragments
pactjs-utils-overview.md— installation, utility table, decision treepactjs-utils-provider-verifier.md— buildVerifierOptions integrationcontract-testing.md— foundational patterns with raw Pact.js
Anti-Patterns
Wrong: Manual Bearer prefix with double-prefix risk
// ❌ Risk of double-prefix: "Bearer Bearer token"
requestFilter: (req, res, next) => {
const token = getToken(); // What if getToken() returns "Bearer abc123"?
req.headers['authorization'] = `Bearer ${token}`;
next();
};Right: Use createRequestFilter with raw token
// ✅ tokenGenerator returns raw value — filter handles prefix
requestFilter: createRequestFilter({
tokenGenerator: () => getToken(), // Returns "abc123", not "Bearer abc123"
});Wrong: Inline auth logic in verifier config
// ❌ Auth logic mixed with verifier config
const opts: VerifierOptions = {
provider: 'my-api',
providerBaseUrl: 'http://localhost:3001',
requestFilter: (req, res, next) => {
const clientId = process.env.CLIENT_ID;
const clientSecret = process.env.CLIENT_SECRET;
// 10 lines of token fetching logic...
req.headers['authorization'] = `Bearer ${token}`;
next();
},
// ... rest of config
};Right: Separate auth into createRequestFilter
// ✅ Clean separation — async setup wraps token fetch (CommonJS-safe)
async function setupVerifierOptions() {
const token = await fetchAuthToken(); // Resolve async token BEFORE creating filter
const requestFilter = createRequestFilter({
tokenGenerator: () => token, // Synchronous — returns pre-fetched value
});
return buildVerifierOptions({
provider: 'my-api',
port: '3001',
includeMainAndDeployed: true,
requestFilter,
stateHandlers: {
/* ... */
},
});
}
// In tests/hooks, callers can await setupVerifierOptions():
// const opts = await setupVerifierOptions();_Source: @seontechnologies/pactjs-utils request-filter module, pact-js-example-provider verification tests_
Workflow Plan: testarch-atdd
Create Mode (steps-c)
- step-01-preflight-and-context.md
- step-02-generation-mode.md
- step-03-test-strategy.md
- step-04-generate-tests.md
- step-05-validate-and-complete.md
Validate Mode (steps-v)
- step-01-validate.md
Edit Mode (steps-e)
- step-01-assess.md
- step-02-apply-edit.md
Outputs
- {test_artifacts}/atdd-checklist-{story_key}.md
- Red-phase acceptance test scaffolds under {project-root}/tests
Related skills
FAQ
What does bmad-testarch-atdd generate?
Red-phase acceptance test scaffolds before implementation, following the TDD red-green-refactor cycle.
What modes does bmad-testarch-atdd support?
Create, Resume, Validate, and Edit, via a tri-modal step-file architecture.