
Bmad Testarch Automate
- 6 installs
- 87 repo stars
- Updated August 4, 2026
- bmad-code-org/bmad-method-test-architecture-enterprise
bmad-testarch-automate is a Claude Code skill that expands test automation coverage after implementation or generates a test suite from an existing codebase.
About
bmad-testarch-automate is a BMAD Method Test Architect skill that expands test automation coverage after implementation. It can analyze an existing codebase to generate a comprehensive test suite. Developers invoke it when they say 'lets expand test coverage' or 'I want to automate tests.'
- Expands test automation coverage after implementation
- Analyzes an existing codebase to generate a comprehensive test suite
- Tri-modal step-file architecture with Create, Resume, Validate, and Edit modes
Bmad Testarch Automate 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-automate capabilities & compatibility
- Capabilities
- test automation · test generation · coverage analysis
- Works with
- playwright
- Use cases
- testing
What bmad-testarch-automate says it does
Expand test automation coverage for codebase.
Expand test automation coverage after implementation or analyze existing codebase to generate a comprehensive test suite.
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-automateAdd 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
Expand automated test coverage or generate a test suite from an existing codebase.
Who is it for?
Growing automated coverage or generating a test suite from an existing codebase
Skip if: Writing red-phase acceptance tests before implementation (use ATDD instead)
When should I use this skill?
The user says 'lets expand test coverage' or 'I want to automate tests'
What you get
Expanded test automation coverage or a generated comprehensive test suite.
- test suite
- expanded test coverage
Files
Test Automation Expansion
Goal: Expand test automation coverage after implementation or analyze existing codebase to generate a comprehensive test suite.
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
Automate Workflow Validation Checklist
Use this checklist to validate that the automate workflow has been executed correctly and all deliverables meet quality standards.
Prerequisites
Before starting this workflow, verify:
- [ ] Framework scaffolding configured (playwright.config.ts or cypress.config.ts exists)
- [ ] Test directory structure exists (tests/ folder with subdirectories)
- [ ] Package.json has test framework dependencies installed
Halt only if: Framework scaffolding is completely missing (run framework workflow first)
Note: BMad artifacts (story, tech-spec, PRD) are OPTIONAL - workflow can run without them Note: automate generates tests; it does not run *atdd or *test-review. If ATDD outputs exist, use them as input and avoid duplicate coverage.
---
Step 1: Execution Mode Determination and Context Loading
Mode Detection
- [ ] Execution mode correctly determined:
- [ ] BMad-Integrated Mode (story_file variable set) OR
- [ ] Standalone Mode (target_feature or target_files set) OR
- [ ] Auto-discover Mode (no targets specified)
BMad Artifacts (If Available - OPTIONAL)
- [ ] Story markdown loaded (if
{story_file}provided) - [ ] Acceptance criteria extracted from story (if available)
- [ ] Tech-spec.md loaded (if
{use_tech_spec}true and file exists) - [ ] Test-design.md loaded (if
{use_test_design}true and file exists) - [ ] PRD.md loaded (if
{use_prd}true and file exists) - [ ] Note: Absence of BMad artifacts does NOT halt workflow
Framework Configuration
- [ ] Test framework config loaded (playwright.config.ts or cypress.config.ts)
- [ ] Test directory structure identified from
{test_dir} - [ ] Existing test patterns reviewed
- [ ] Test runner capabilities noted (parallel execution, fixtures, etc.)
Coverage Analysis
- [ ] Existing test files searched in
{test_dir}(if{analyze_coverage}true) - [ ] Tested features vs untested features identified
- [ ] Coverage gaps mapped (tests to source files)
- [ ] Existing fixture and factory patterns checked
Knowledge Base Fragments Loaded
- [ ]
test-levels-framework.md- Test level selection - [ ]
test-priorities.md- Priority classification (P0-P3) - [ ]
fixture-architecture.md- Fixture patterns with auto-cleanup - [ ]
data-factories.md- Factory patterns using faker - [ ]
selective-testing.md- Targeted test execution strategies - [ ]
ci-burn-in.md- Flaky test detection patterns - [ ]
test-quality.md- Test design principles
---
Step 2: Automation Targets Identification
Target Determination
BMad-Integrated Mode (if story available):
- [ ] Acceptance criteria mapped to test scenarios
- [ ] Features implemented in story identified
- [ ] Existing ATDD tests checked (if any)
- [ ] Expansion beyond ATDD planned (edge cases, negative paths)
Standalone Mode (if no story):
- [ ] Specific feature analyzed (if
{target_feature}specified) - [ ] Specific files analyzed (if
{target_files}specified) - [ ] Features auto-discovered (if
{auto_discover_features}true) - [ ] Features prioritized by:
- [ ] No test coverage (highest priority)
- [ ] Complex business logic
- [ ] External integrations (API, database, auth)
- [ ] Critical user paths (login, checkout, etc.)
Test Level Selection
- [ ] Test level selection framework applied (from
test-levels-framework.md) - [ ] E2E tests identified: Critical user journeys, multi-system integration
- [ ] API tests identified: Business logic, service contracts, data transformations
- [ ] Component tests identified: UI behavior, interactions, state management
- [ ] Unit tests identified: Pure logic, edge cases, error handling
Duplicate Coverage Avoidance
- [ ] Same behavior NOT tested at multiple levels unnecessarily
- [ ] E2E used for critical happy path only
- [ ] API tests used for business logic variations
- [ ] Component tests used for UI interaction edge cases
- [ ] Unit tests used for pure logic edge cases
Priority Assignment
- [ ] Test priorities assigned using
test-priorities.mdframework - [ ] P0 tests: Critical paths, security-critical, data integrity
- [ ] P1 tests: Important features, integration points, error handling
- [ ] P2 tests: Edge cases, less-critical variations, performance
- [ ] P3 tests: Nice-to-have, rarely-used features, exploratory
- [ ] Priority variables respected:
- [ ]
{include_p0}= true (always include) - [ ]
{include_p1}= true (high priority) - [ ]
{include_p2}= true (medium priority) - [ ]
{include_p3}= false (low priority, skip by default)
Coverage Plan Created
- [ ] Test coverage plan documented
- [ ] What will be tested at each level listed
- [ ] Priorities assigned to each test
- [ ] Coverage strategy clear (critical-paths, comprehensive, or selective)
---
Step 3: Test Infrastructure Generated
Fixture Architecture
- [ ] Existing fixtures checked in
tests/support/fixtures/ - [ ] Fixture architecture created/enhanced (if
{generate_fixtures}true) - [ ] All fixtures use Playwright's
test.extend()pattern - [ ] All fixtures have auto-cleanup in teardown
- [ ] Common fixtures created/enhanced:
- [ ] authenticatedUser (with auto-delete)
- [ ] apiRequest (authenticated client)
- [ ] mockNetwork (external service mocking)
- [ ] testDatabase (with auto-cleanup)
Data Factories
- [ ] Existing factories checked in
tests/support/factories/ - [ ] Factory architecture created/enhanced (if
{generate_factories}true) - [ ] All factories use
@faker-js/fakerfor random data (no hardcoded values) - [ ] All factories support overrides for specific scenarios
- [ ] Common factories created/enhanced:
- [ ] User factory (email, password, name, role)
- [ ] Product factory (name, price, SKU)
- [ ] Order factory (items, total, status)
- [ ] Cleanup helpers provided (e.g., deleteUser(), deleteProduct())
Helper Utilities
- [ ] Existing helpers checked in
tests/support/helpers/(if{update_helpers}true) - [ ] Common utilities created/enhanced:
- [ ] waitFor (polling for complex conditions)
- [ ] retry (retry helper for flaky operations)
- [ ] testData (test data generation)
- [ ] assertions (custom assertion helpers)
---
Step 4: Test Files Generated
Test File Structure
- [ ] Test files organized correctly:
- [ ]
tests/e2e/for E2E tests - [ ]
tests/api/for API tests - [ ]
tests/component/for component tests - [ ]
tests/unit/for unit tests - [ ]
tests/support/for fixtures/factories/helpers
E2E Tests (If Applicable)
- [ ] E2E test files created in
tests/e2e/ - [ ] All tests follow Given-When-Then format
- [ ] All tests have priority tags ([P0], [P1], [P2], [P3]) in test name
- [ ] All tests use data-testid selectors (not CSS classes)
- [ ] One assertion per test (atomic design)
- [ ] No hard waits or sleeps (explicit waits only)
- [ ] Network-first pattern applied (route interception BEFORE navigation)
- [ ] Clear Given-When-Then comments in test code
API Tests (If Applicable)
- [ ] API test files created in
tests/api/ - [ ] All tests follow Given-When-Then format
- [ ] All tests have priority tags in test name
- [ ] API contracts validated (request/response structure)
- [ ] HTTP status codes verified
- [ ] Response body validation includes required fields
- [ ] Error cases tested (400, 401, 403, 404, 500)
- [ ] JWT token format validated (if auth tests)
Consumer Contract Tests / CDC (If use_pactjs_utils Enabled)
Provider Endpoint Comments:
- [ ] Every Pact interaction has
// Provider endpoint:comment - [ ] Comment includes exact file path to provider route handler, OR uses the TODO form when provider is inaccessible
- [ ] Comment follows format:
// Provider endpoint: <path> -> <METHOD> <route>or// Provider endpoint: TODO — provider source not accessible, verify manually
Provider Source Scrutiny:
- [ ] Provider route handlers and/or OpenAPI spec read before generating each interaction
- [ ] Status codes verified against provider source (e.g., 201 not assumed 200)
- [ ] Field names cross-referenced with provider type/DTO definitions
- [ ] Data types verified (string ID vs number ID, date formats)
- [ ] Enum/union values extracted from provider validation schemas
- [ ] Required request fields and headers checked against provider validation
- [ ] Nested response structures match provider's actual response construction
- [ ] Scrutiny evidence documented as block comment in each test file
CDC Quality Gates:
- [ ] Postel's Law enforced: exact values in
withRequest, matchers inwillRespondWith - [ ] Response matchers (
like,eachLike,string,integer) used only inwillRespondWith - [ ] Provider state names are consistent with provider's state handler naming
- [ ] DI pattern used for consumer function imports (actual consumer code, not raw
fetch()) - [ ] One logical endpoint per Pact interaction (no multi-endpoint interactions)
Component Tests (If Applicable)
- [ ] Component test files created in
tests/component/ - [ ] All tests follow Given-When-Then format
- [ ] All tests have priority tags in test name
- [ ] Component mounting works correctly
- [ ] Interaction testing covers user actions (click, hover, keyboard)
- [ ] State management validated
- [ ] Props and events tested
Unit Tests (If Applicable)
- [ ] Unit test files created in
tests/unit/ - [ ] All tests follow Given-When-Then format
- [ ] All tests have priority tags in test name
- [ ] Pure logic tested (no dependencies)
- [ ] Edge cases covered
- [ ] Error handling tested
Quality Standards Enforced
- [ ] All tests use Given-When-Then format with clear comments
- [ ] All tests have descriptive names with priority tags
- [ ] 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)
- [ ] All tests use data-testid selectors (E2E tests)
- [ ] No hard waits:
await page.waitForTimeout()(forbidden) - [ ] No conditional flow:
if (await element.isVisible())(forbidden) - [ ] No try-catch for test logic (only for cleanup)
- [ ] No hardcoded test data (use factories with faker)
- [ ] No page object classes (tests are direct and simple)
- [ ] No shared state between tests
Network-First Pattern Applied
- [ ] Route interception set up BEFORE navigation (E2E tests with network requests)
- [ ]
page.route()called beforepage.goto()to prevent race conditions - [ ] Network-first pattern verified in all E2E tests that make API calls
---
Step 5: Test Validation and Healing (NEW - Phase 2.5)
Healing Configuration
- [ ] Healing configuration checked:
- [ ]
{auto_validate}setting noted (default: true) - [ ]
{auto_heal_failures}setting noted (default: false) - [ ]
{max_healing_iterations}setting noted (default: 3) - [ ]
{use_mcp_healing}setting noted (default: true)
Healing Knowledge Fragments Loaded (If Healing Enabled)
- [ ]
test-healing-patterns.mdloaded (common failure patterns and fixes) - [ ]
selector-resilience.mdloaded (selector refactoring guide) - [ ]
timing-debugging.mdloaded (race condition fixes)
Test Execution and Validation
- [ ] Generated tests executed (if
{auto_validate}true) - [ ] Test results captured:
- [ ] Total tests run
- [ ] Passing tests count
- [ ] Failing tests count
- [ ] Error messages and stack traces captured
Healing Loop (If Enabled and Tests Failed)
- [ ] Healing loop entered (if
{auto_heal_failures}true AND tests failed) - [ ] For each failing test:
- [ ] Failure pattern identified (selector, timing, data, network, hard wait)
- [ ] Appropriate healing strategy applied:
- [ ] Stale selector → Replaced with data-testid or ARIA role
- [ ] Race condition → Added network-first interception or state waits
- [ ] Dynamic data → Replaced hardcoded values with regex/dynamic generation
- [ ] Network error → Added route mocking
- [ ] Hard wait → Replaced with event-based wait
- [ ] Healed test re-run to validate fix
- [ ] Iteration count tracked (max 3 attempts)
Unfixable Tests Handling
- [ ] Tests that couldn't be healed after 3 iterations marked with
test.fixme()(if{mark_unhealable_as_fixme}true) - [ ] Detailed comment added to test.fixme() tests:
- [ ] What failure occurred
- [ ] What healing was attempted (3 iterations)
- [ ] Why healing failed
- [ ] Manual investigation steps needed
- [ ] Original test logic preserved in comments
Healing Report Generated
- [ ] Healing report generated (if healing attempted)
- [ ] Report includes:
- [ ] Auto-heal enabled status
- [ ] Healing mode (MCP-assisted or Pattern-based)
- [ ] Iterations allowed (max_healing_iterations)
- [ ] Validation results (total, passing, failing)
- [ ] Successfully healed tests (count, file:line, fix applied)
- [ ] Unable to heal tests (count, file:line, reason)
- [ ] Healing patterns applied (selector fixes, timing fixes, data fixes)
- [ ] Knowledge base references used
---
Step 6: Documentation and Scripts Updated
Test README Updated
- [ ]
tests/README.mdcreated or updated (if{update_readme}true) - [ ] Test suite structure overview included
- [ ] Test execution instructions provided (all, specific files, by priority)
- [ ] Fixture usage examples provided
- [ ] Factory usage examples provided
- [ ] Priority tagging convention explained ([P0], [P1], [P2], [P3])
- [ ] How to write new tests documented
- [ ] Common patterns documented
- [ ] Anti-patterns documented (what to avoid)
package.json Scripts Updated
- [ ] package.json scripts added/updated (if
{update_package_scripts}true) - [ ]
test:e2escript for all E2E tests - [ ]
test:e2e:p0script for P0 tests only - [ ]
test:e2e:p1script for P0 + P1 tests - [ ]
test:apiscript for API tests - [ ]
test:componentscript for component tests - [ ]
test:unitscript for unit tests (if applicable)
Test Suite Executed
- [ ] Test suite run locally (if
{run_tests_after_generation}true) - [ ] Test results captured (passing/failing counts)
- [ ] No flaky patterns detected (tests are deterministic)
- [ ] Setup requirements documented (if any)
- [ ] Known issues documented (if any)
---
Step 6: Automation Summary Generated
Automation Summary Document
- [ ] Output file created at
{output_summary} - [ ] Document includes execution mode (BMad-Integrated, Standalone, Auto-discover)
- [ ] Feature analysis included (source files, coverage gaps) - Standalone mode
- [ ] Tests created listed (E2E, API, Component, Unit) with counts and paths
- [ ] Infrastructure created listed (fixtures, factories, helpers)
- [ ] Test execution instructions provided
- [ ] Coverage analysis included:
- [ ] Total test count
- [ ] Priority breakdown (P0, P1, P2, P3 counts)
- [ ] Test level breakdown (E2E, API, Component, Unit counts)
- [ ] Coverage percentage (if calculated)
- [ ] Coverage status (acceptance criteria covered, gaps identified)
- [ ] Definition of Done checklist included
- [ ] Next steps provided
- [ ] Recommendations included (if Standalone mode)
Summary Provided to User
- [ ] Concise summary output provided
- [ ] Total tests created across test levels
- [ ] Priority breakdown (P0, P1, P2, P3 counts)
- [ ] Infrastructure counts (fixtures, factories, helpers)
- [ ] Test execution command provided
- [ ] Output file path provided
- [ ] Next steps listed
---
Quality Checks
Test Design Quality
- [ ] Tests are readable (clear Given-When-Then structure)
- [ ] Tests are maintainable (use factories/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)
- [ ] Tests are lean (files under {max_file_lines} lines)
Knowledge Base Integration
- [ ] Test level selection framework applied (from
test-levels-framework.md) - [ ] Priority classification applied (from
test-priorities.md) - [ ] Fixture architecture patterns applied (from
fixture-architecture.md) - [ ] Data factory patterns applied (from
data-factories.md) - [ ] Selective testing strategies considered (from
selective-testing.md) - [ ] Flaky test detection patterns considered (from
ci-burn-in.md) - [ ] Test quality principles applied (from
test-quality.md)
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
- [ ] No console.log or debug statements in test code
---
Integration Points
With Framework Workflow
- [ ] Test framework configuration detected and used
- [ ] Directory structure matches framework setup
- [ ] Fixtures and helpers follow established patterns
- [ ] Naming conventions consistent with framework standards
With BMad Workflows (If Available - OPTIONAL)
With Story Workflow:
- [ ] Story ID correctly referenced in output (if story available)
- [ ] Acceptance criteria from story reflected in tests (if story available)
- [ ] Technical constraints from story considered (if story available)
With test-design Workflow:
- [ ] P0 scenarios from test-design prioritized (if test-design available)
- [ ] Risk assessment from test-design considered (if test-design available)
- [ ] Coverage strategy aligned with test-design (if test-design available)
With atdd Workflow:
- [ ] ATDD artifacts provided or located (manual handoff;
atddnot auto-run) - [ ] Existing ATDD tests checked (if story had ATDD workflow run)
- [ ] Expansion beyond ATDD planned (edge cases, negative paths)
- [ ] No duplicate coverage with ATDD tests
With CI Pipeline
- [ ] Tests can run in CI environment
- [ ] Tests are parallelizable (no shared state)
- [ ] Tests have appropriate timeouts
- [ ] Tests clean up their data (no CI environment pollution)
---
Completion Criteria
All of the following must be true before marking this workflow as complete:
- [ ] Execution mode determined (BMad-Integrated, Standalone, or Auto-discover)
- [ ] Framework configuration loaded and validated
- [ ] Coverage analysis completed (gaps identified if analyze_coverage true)
- [ ] Automation targets identified (what needs testing)
- [ ] Test levels selected appropriately (E2E, API, Component, Unit)
- [ ] Duplicate coverage avoided (same behavior not tested at multiple levels)
- [ ] Test priorities assigned (P0, P1, P2, P3)
- [ ] Fixture architecture created/enhanced with auto-cleanup
- [ ] Data factories created/enhanced using faker (no hardcoded data)
- [ ] Helper utilities created/enhanced (if needed)
- [ ] Test files generated at appropriate levels (E2E, API, Component, Unit)
- [ ] Given-When-Then format used consistently across all tests
- [ ] Priority tags added to all test names ([P0], [P1], [P2], [P3])
- [ ] data-testid selectors used in E2E tests (not CSS classes)
- [ ] Network-first pattern applied (route interception before navigation)
- [ ] Quality standards enforced (no hard waits, no flaky patterns, self-cleaning, deterministic)
- [ ] Test README updated with execution instructions and patterns
- [ ] package.json scripts updated with test execution commands
- [ ] Test suite run locally (if run_tests_after_generation true)
- [ ] Tests validated (if auto_validate enabled)
- [ ] Failures healed (if auto_heal_failures enabled and tests failed)
- [ ] Healing report generated (if healing attempted)
- [ ] Unfixable tests marked with test.fixme() and detailed comments (if any)
- [ ] Automation summary created and saved to correct location
- [ ] Output file formatted correctly
- [ ] Knowledge base references applied and documented (including healing fragments if used)
- [ ] No test quality issues (flaky patterns, race conditions, hardcoded data, page objects)
- [ ] Provider scrutiny completed or gracefully degraded for all CDC interactions — each interaction either has scrutiny evidence or a TODO marker (if
use_pactjs_utilsenabled) - [ ] Provider endpoint comments present on every Pact interaction (if
use_pactjs_utilsenabled)
---
Common Issues and Resolutions
Issue: BMad artifacts not found
Problem: Story, tech-spec, or PRD files not found when variables are set.
Resolution:
- automate does NOT require BMad artifacts - they are OPTIONAL enhancements
- If files not found, switch to Standalone Mode automatically
- Analyze source code directly without BMad context
- Continue workflow without halting
Issue: Framework configuration not found
Problem: No playwright.config.ts or cypress.config.ts found.
Resolution:
- HALT workflow - framework is required
- Message: "Framework scaffolding required. Run
bmad tea *frameworkfirst." - User must run framework workflow before automate
Issue: No automation targets identified
Problem: Neither story, target_feature, nor target_files specified, and auto-discover finds nothing.
Resolution:
- Check if source_dir variable is correct
- Verify source code exists in project
- Ask user to specify target_feature or target_files explicitly
- Provide examples:
target_feature: "src/auth/"ortarget_files: "src/auth/login.ts,src/auth/session.ts"
Issue: Duplicate coverage detected
Problem: Same behavior tested at multiple levels (E2E + API + Component).
Resolution:
- Review test level selection framework (test-levels-framework.md)
- Use E2E for critical happy path ONLY
- Use API for business logic variations
- Use Component for UI edge cases
- Remove redundant tests that duplicate coverage
Issue: Tests have hardcoded data
Problem: Tests use hardcoded email addresses, passwords, or other data.
Resolution:
- Replace all hardcoded data with factory function calls
- Use faker for all random data generation
- Update data-factories to support all required test scenarios
- Example:
createUser({ email: faker.internet.email() })
Issue: Tests are flaky
Problem: Tests fail intermittently, pass on retry.
Resolution:
- Remove all hard waits (
page.waitForTimeout()) - Use explicit waits (
page.waitForSelector()) - Apply network-first pattern (route interception before navigation)
- Remove conditional flow (
if (await element.isVisible())) - Ensure tests are deterministic (no race conditions)
- Run burn-in loop (10 iterations) to detect flakiness
Issue: Fixtures don't clean up data
Problem: Test data persists after test run, causing test pollution.
Resolution:
- Ensure all fixtures have cleanup in teardown phase
- Cleanup happens AFTER
await use(data) - Call deletion/cleanup functions (deleteUser, deleteProduct, etc.)
- Verify cleanup works by checking database/storage after test run
Issue: Tests too slow
Problem: Tests take longer than 90 seconds (max_test_duration).
Resolution:
- Remove unnecessary waits and delays
- Use parallel execution where possible
- Mock external services (don't make real API calls)
- Use API tests instead of E2E for business logic
- Optimize test data creation (use in-memory database, etc.)
---
Notes for TEA Agent
- automate is flexible: Can work with or without BMad artifacts (story, tech-spec, PRD are OPTIONAL)
- Standalone mode is powerful: Analyze any codebase and generate tests independently
- Auto-discover mode: Scan codebase for features needing tests when no targets specified
- Framework is the ONLY hard requirement: HALT if framework config missing, otherwise proceed
- Avoid duplicate coverage: E2E for critical paths only, API/Component for variations
- Priority tagging enables selective execution: P0 tests run on every commit, P1 on PR, P2 nightly
- Network-first pattern prevents race conditions: Route interception BEFORE navigation
- No page objects: Keep tests simple, direct, and maintainable
- Use knowledge base: Load relevant fragments (test-levels, test-priorities, fixture-architecture, data-factories, healing patterns) for guidance
- Deterministic tests only: No hard waits, no conditional flow, no flaky patterns allowed
- Optional healing: auto_heal_failures disabled by default (opt-in for automatic test healing)
- Graceful degradation: Healing works without Playwright MCP (pattern-based fallback)
- Unfixable tests handled: Mark with test.fixme() and detailed comments (not silently broken)
# DO NOT EDIT -- overwritten on every update.
#
# Workflow customization surface for bmad-testarch-automate. 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™ -->
Test Automation Expansion
Version: 5.0 (Step-File Architecture)
---
Overview
Expands test automation coverage by generating prioritized tests at the appropriate level (E2E, API, Component, Unit) with supporting fixtures and helpers.
Modes:
- BMad-Integrated: Uses story/PRD/test-design artifacts when available
- Standalone: Analyzes existing codebase without BMad artifacts
---
WORKFLOW ARCHITECTURE
This workflow uses step-file architecture for disciplined execution:
- 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,source_dir,coverage_target,standalone_mode
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_
Related skills
FAQ
What does bmad-testarch-automate do?
It expands test automation coverage after implementation, or analyzes an existing codebase to generate a comprehensive test suite.
When should I use it instead of the ATDD skill?
Use automate to grow coverage or generate a suite for existing code; use the ATDD skill to write red-phase acceptance tests before implementation.