
Bmad Testarch Test Design
- 7 installs
- 87 repo stars
- Updated August 4, 2026
- bmad-code-org/bmad-method-test-architecture-enterprise
bmad-testarch-test-design is a Claude Code skill that produces an epic-level test plan grounded in risk and testability assessment.
About
bmad-testarch-test-design is a BMAD Method Test Architect skill that produces an epic-level test plan grounded in risk and testability assessment. It creates system-level or epic-level test plans and strategies. Developers invoke it when they say 'lets design test plan' or 'I want to create test strategy.'
- Produces an epic-level test plan grounded in risk and testability assessment
- Creates system-level or epic-level test strategies
- Tri-modal step-file architecture with Create, Resume, Validate, and Edit modes
Bmad Testarch Test Design by the numbers
- 7 all-time installs (skills.sh)
- Ranked #1,580 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
bmad-testarch-test-design capabilities & compatibility
- Capabilities
- test design · test strategy · risk assessment
- Use cases
- testing
What bmad-testarch-test-design says it does
Create system-level or epic-level test plans.
Produce an epic-level test plan grounded in risk and testability assessment.
npx skills add https://github.com/bmad-code-org/bmad-method-test-architecture-enterprise --skill bmad-testarch-test-designAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 7 |
|---|---|
| repo stars | ★ 87 |
| Last updated | August 4, 2026 |
| Repository | bmad-code-org/bmad-method-test-architecture-enterprise ↗ |
What it does
Produce a risk-based epic-level or system-level test plan and strategy.
Who is it for?
Designing a risk-based epic- or system-level test plan
Skip if: Generating actual test code or CI pipelines
When should I use this skill?
The user says 'lets design test plan' or 'I want to create test strategy'
What you get
An epic-level test plan grounded in risk and testability assessment.
- test plan
- test strategy
Files
Test Design and Risk Assessment
Goal: Produce an epic-level test plan grounded in risk and testability assessment.
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-detect-mode.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
Resume mode reads explicit progress metadata from the progress file (workflowStatus, nextStep, totalSteps) and falls back to legacy lastStep data when needed.
Test Design and Risk Assessment - Validation Checklist
Prerequisites (Mode-Dependent)
System-Level Mode (Phase 3):
- [ ] PRD exists with functional and non-functional requirements
- [ ] ADR (Architecture Decision Record) exists
- [ ] Architecture document available (architecture.md or tech-spec)
- [ ] Requirements are testable and unambiguous
Epic-Level Mode (Phase 4):
- [ ] Story markdown with clear acceptance criteria exists
- [ ] PRD or epic documentation available
- [ ] Architecture documents available (test-design-architecture.md + test-design-qa.md from Phase 3, if exists)
- [ ] Requirements are testable and unambiguous
Process Steps
Step 1: Context Loading
- [ ] PRD.md read and requirements extracted
- [ ] Epics.md or specific epic documentation loaded
- [ ] Story markdown with acceptance criteria analyzed
- [ ] Architecture documents reviewed (if available)
- [ ] Existing test coverage analyzed
- [ ] Knowledge base fragments loaded (risk-governance, probability-impact, test-levels, test-priorities)
- [ ]
nfr-criteria.mdloaded for system-level mode, and for epic-level mode when NFRs are in scope
Step 2: Risk Assessment
- [ ] Genuine risks identified (not just features)
- [ ] Risks classified by category (TECH/SEC/PERF/DATA/BUS/OPS)
- [ ] Probability scored (1-3 for each risk)
- [ ] Impact scored (1-3 for each risk)
- [ ] Risk scores calculated (probability × impact)
- [ ] High-priority risks (score ≥6) flagged
- [ ] Mitigation plans defined for high-priority risks
- [ ] Owners assigned for each mitigation
- [ ] Timelines set for mitigations
- [ ] Residual risk documented
Step 2A: NFR Planning
- [ ] NFR categories in scope identified (security, performance, reliability, scalability, maintainability, compliance, custom)
- [ ] NFR thresholds extracted from PRD, architecture, ADRs, epics, or stories
- [ ] Unknown thresholds marked as UNKNOWN; no values guessed
- [ ] Missing thresholds converted into risks, assumptions, blockers, or clarification items
- [ ] Planned evidence sources identified for later
nfr-assess - [ ] NFR-derived risks mapped into the normal risk register using SEC/PERF/OPS/TECH/DATA categories
Step 3: Coverage Design
- [ ] Acceptance criteria broken into atomic scenarios
- [ ] Test levels selected (E2E/API/Component/Unit)
- [ ] No duplicate coverage across levels
- [ ] Priority levels assigned (P0/P1/P2/P3)
- [ ] P0 scenarios meet strict criteria (blocks core + high risk + no workaround)
- [ ] NFR-derived risks mapped to planned validation scenarios
- [ ] Planned NFR evidence artifacts documented without final PASS/CONCERNS/FAIL decisions
- [ ] Data prerequisites identified
- [ ] Tooling/access requirements documented when applicable
- [ ] Execution order defined (smoke → P0 → P1 → P2/P3)
Step 4: Deliverables Generation
- [ ] Risk assessment matrix created
- [ ] Coverage matrix created
- [ ] Execution order documented
- [ ] Resource estimates calculated
- [ ] Quality gate criteria defined
- [ ] NFR planning summary included when NFRs are in scope
- [ ] Output file written to correct location
- [ ] Output file uses template structure
Output Validation
Risk Assessment Matrix
- [ ] All risks have unique IDs (R-001, R-002, etc.)
- [ ] Each risk has category assigned
- [ ] Probability values are 1, 2, or 3
- [ ] Impact values are 1, 2, or 3
- [ ] Scores calculated correctly (P × I)
- [ ] High-priority risks (≥6) clearly marked
- [ ] Mitigation strategies specific and actionable
Coverage Matrix
- [ ] All requirements mapped to test levels
- [ ] Priorities assigned to all scenarios
- [ ] Risk linkage documented
- [ ] Test counts realistic
- [ ] Owners assigned where applicable
- [ ] No duplicate coverage (same behavior at multiple levels)
Execution Strategy
CRITICAL: Keep execution strategy simple, avoid redundancy
- [ ] Simple structure: PR / Nightly / Weekly (NOT complex smoke/P0/P1/P2 tiers)
- [ ] PR execution: All functional tests unless significant infrastructure overhead
- [ ] Nightly/Weekly: Only performance, chaos, long-running, manual tests
- [ ] No redundancy: Don't re-list all tests (already in coverage plan)
- [ ] Philosophy stated: "Run everything in PRs if <15 min, defer only if expensive/long"
- [ ] Playwright parallelization noted: 100s of tests in 10-15 min
Resource Estimates
CRITICAL: Use intervals/ranges, NOT exact numbers
- [ ] P0 effort provided as interval range (e.g., "~25-40 hours" NOT "36 hours")
- [ ] P1 effort provided as interval range (e.g., "~20-35 hours" NOT "27 hours")
- [ ] P2 effort provided as interval range (e.g., "~10-30 hours" NOT "15.5 hours")
- [ ] P3 effort provided as interval range (e.g., "~2-5 hours" NOT "2.5 hours")
- [ ] Total effort provided as interval range (e.g., "~55-110 hours" NOT "81 hours")
- [ ] Timeline provided as week range (e.g., "~1.5-3 weeks" NOT "11 days")
- [ ] Estimates include setup time and account for complexity variations
- [ ] No false precision: Avoid exact calculations like "18 tests × 2 hours = 36 hours"
Quality Gate Criteria
- [ ] P0 pass rate threshold defined (should be 100%)
- [ ] P1 pass rate threshold defined (typically ≥95%)
- [ ] High-risk mitigation completion required
- [ ] Coverage targets specified (≥80% recommended)
- [ ] NFR evidence expectation defined for each in-scope NFR category
- [ ] Full NFR evidence decision deferred to
nfr-assess
Quality Checks
Evidence-Based Assessment
- [ ] Risk assessment based on documented evidence
- [ ] No speculation on business impact
- [ ] Assumptions clearly documented
- [ ] Clarifications requested where needed
- [ ] Historical data referenced where available
Risk Classification Accuracy
- [ ] TECH risks are architecture/integration issues
- [ ] SEC risks are security vulnerabilities
- [ ] PERF risks are performance/scalability concerns
- [ ] DATA risks are data integrity issues
- [ ] BUS risks are business/revenue impacts
- [ ] OPS risks are deployment/operational issues
Priority Assignment Accuracy
CRITICAL: Priority classification is separate from execution timing
- [ ] Priority sections (P0/P1/P2/P3) do NOT include execution context (e.g., no "Run on every commit" in headers)
- [ ] Priority sections have only "Criteria" and "Purpose" (no "Execution:" field)
- [ ] Execution Strategy section is separate and handles timing based on infrastructure overhead
- [ ] P0: Truly blocks core functionality + High-risk (≥6) + No workaround
- [ ] P1: Important features + Medium-risk (3-4) + Common workflows
- [ ] P2: Secondary features + Low-risk (1-2) + Edge cases
- [ ] P3: Nice-to-have + Exploratory + Benchmarks
- [ ] Note at top of Test Coverage Plan: Clarifies P0/P1/P2/P3 = priority/risk, NOT execution timing
Test Level Selection
- [ ] E2E used only for critical paths
- [ ] API tests cover complex business logic
- [ ] Component tests for UI interactions
- [ ] Unit tests for edge cases and algorithms
- [ ] No redundant coverage
Integration Points
Knowledge Base Integration
- [ ] risk-governance.md consulted
- [ ] probability-impact.md applied
- [ ] test-levels-framework.md referenced
- [ ] test-priorities-matrix.md used
- [ ] nfr-criteria.md consulted for NFR planning when applicable
- [ ] Additional fragments loaded as needed
Status File Integration
- [ ] Test design logged in Quality & Testing Progress
- [ ] Epic number and scope documented
- [ ] Completion timestamp recorded
Workflow Dependencies
- [ ] Can proceed to
*atddworkflow with P0 scenarios - [ ]
*atddis a separate workflow and must be run explicitly (not auto-run) - [ ] Can proceed to
automateworkflow with full coverage plan - [ ] Risk assessment informs
gateworkflow criteria - [ ] Integrates with
ciworkflow execution order
Accountability & Logistics
Not in Scope
- [ ] Out-of-scope items explicitly listed with reasoning
- [ ] Mitigation noted for each excluded item
- [ ] Exclusions reviewed and accepted by stakeholders
Entry Criteria
- [ ] Prerequisites for testing start are clearly defined
- [ ] Environment readiness included
- [ ] Test data readiness included
- [ ] Pre-implementation blocker resolution referenced
Exit Criteria
- [ ] Pass/fail thresholds defined for each priority level
- [ ] Bug severity gate defined (e.g., no open P0/P1 bugs)
- [ ] Coverage sufficiency criteria specified
Project Team (Optional)
- [ ] If included, key roles identified (QA Lead, Dev Lead, PM, Architect minimum)
- [ ] If included, testing responsibilities mapped to roles
- [ ] If included, names populated where available (placeholders acceptable for draft)
Tooling & Access (System-Level Only, If Applicable)
- [ ] If non-standard tools or access requests exist, list them
- [ ] Access requirements identified for each tool/service
- [ ] Status tracked (Ready/Pending) when applicable
Interworking & Regression
- [ ] Impacted services/components identified
- [ ] Regression scope defined per impacted service
- [ ] Cross-team coordination noted where needed
System-Level Mode: Two-Document Validation
When in system-level mode (PRD + ADR input), validate BOTH documents:
test-design-architecture.md
- [ ] Purpose statement at top (serves as contract with Architecture team)
- [ ] Executive Summary with scope, business context, architecture decisions, risk summary
- [ ] Quick Guide section with three tiers:
- [ ] 🚨 BLOCKERS - Team Must Decide (pre-implementation critical path items)
- [ ] ⚠️ HIGH PRIORITY - Team Should Validate (recommendations for approval)
- [ ] 📋 INFO ONLY - Solutions Provided (no decisions needed)
- [ ] Risk Assessment section - ACTIONABLE
- [ ] Total risks identified count
- [ ] High-priority risks table (score ≥6) with all columns: Risk ID, Category, Description, Probability, Impact, Score, Mitigation, Owner, Timeline
- [ ] Medium and low-priority risks tables
- [ ] Risk category legend included
- [ ] Testability Concerns and Architectural Gaps section - ACTIONABLE
- [ ] Sub-section: 🚨 ACTIONABLE CONCERNS at TOP
- [ ] Blockers to Fast Feedback table (WHAT architecture must provide)
- [ ] Architectural Improvements Needed (WHAT must be changed)
- [ ] Each concern has: Owner, Timeline, Impact
- [ ] Sub-section: Testability Assessment Summary at BOTTOM (FYI)
- [ ] What Works Well (passing items)
- [ ] Accepted Trade-offs (no action required)
- [ ] This section only included if worth mentioning; otherwise omitted
- [ ] Risk Mitigation Plans for all high-priority risks (≥6)
- [ ] Each plan has: Strategy (numbered steps), Owner, Timeline, Status, Verification
- [ ] Only Backend/DevOps/Arch/Security mitigations (production code changes)
- [ ] QA-owned mitigations belong in QA doc instead
- [ ] Assumptions and Dependencies section
- [ ] Architectural assumptions only (SLO targets, replication lag, system design)
- [ ] Assumptions list (numbered)
- [ ] Dependencies list with required dates
- [ ] Risks to plan with impact and contingency
- [ ] QA execution assumptions belong in QA doc instead
- [ ] NO test implementation code (long examples belong in QA doc)
- [ ] NO test scripts (no Playwright test(...) blocks, no assertions, no test setup code)
- [ ] NFR Testability Requirements are concise (WHAT architecture must provide, thresholds, planned evidence; no full audit tables)
- [ ] NO test scenario checklists (belong in QA doc)
- [ ] NO bloat or repetition (consolidate repeated notes, avoid over-explanation)
- [ ] Cross-references to QA doc where appropriate (instead of duplication)
- [ ] RECIPE SECTIONS NOT IN ARCHITECTURE DOC:
- [ ] NO "Test Levels Strategy" section (unit/integration/E2E split belongs in QA doc only)
- [ ] NO "NFR Testing Approach" section with detailed test procedures (use concise NFR Testability Requirements only)
- [ ] NO "Test Environment Requirements" section (belongs in QA doc only)
- [ ] NO "Recommendations for pre-implementation" section with test framework setup (belongs in QA doc only)
- [ ] NO "Quality Gate Criteria" section (pass rates, coverage targets belong in QA doc only)
- [ ] NO "Tool Selection" section (Playwright, k6, etc. belongs in QA doc only)
test-design-qa.md
REQUIRED SECTIONS:
- [ ] Purpose statement at top (test execution recipe)
- [ ] Executive Summary with risk summary and coverage summary
- [ ] Dependencies & Test Blockers section appears near the top (immediately after Executive Summary, or after Not in Scope)
- [ ] Backend/Architecture dependencies listed (what QA needs from other teams)
- [ ] QA infrastructure setup listed (factories, fixtures, environments)
- [ ] Code example with playwright-utils if config.tea_use_playwright_utils is true
- [ ] Test from '@seontechnologies/playwright-utils/api-request/fixtures'
- [ ] Expect from '@playwright/test' (playwright-utils does not re-export expect)
- [ ] Code examples include assertions (no unused imports)
- [ ] Risk Assessment section (brief, references Architecture doc)
- [ ] High-priority risks table
- [ ] Medium/low-priority risks table
- [ ] Each risk shows "QA Test Coverage" column (how QA validates)
- [ ] Test Coverage Plan with P0/P1/P2/P3 sections
- [ ] Priority sections have ONLY "Criteria" (no execution context)
- [ ] Note at top: "P0/P1/P2/P3 = priority, NOT execution timing"
- [ ] Test tables with columns: Test ID | Requirement | Test Level | Risk Link | Notes
- [ ] NFR Test Coverage Plan section
- [ ] Maps NFR category to requirement/threshold, planned validation, tool/level, evidence artifact, and priority
- [ ] Lists missing thresholds or missing evidence sources
- [ ] Does not assign final PASS/CONCERNS/FAIL status
- [ ] Execution Strategy section (organized by TOOL TYPE)
- [ ] Every PR: Playwright tests (~10-15 min)
- [ ] Nightly: k6 performance tests (~30-60 min)
- [ ] Weekly: Chaos & long-running (~hours)
- [ ] Philosophy: "Run everything in PRs unless expensive/long-running"
- [ ] QA Effort Estimate section (QA effort ONLY)
- [ ] Interval-based estimates (e.g., "~1-2 weeks" NOT "36 hours")
- [ ] NO DevOps, Backend, Data Eng, Finance effort
- [ ] No per-milestone effort breakdowns in this section
- [ ] Implementation Planning Handoff section (optional)
- [ ] Only include if implementation tasks must be scheduled
- [ ] Owners assigned (QA/Dev/Platform/etc)
- [ ] Target milestone may be noted, but avoid detailed per-milestone breakdowns
- [ ] Appendix A: Code Examples & Tagging
- [ ] Appendix B: Knowledge Base References
DON'T INCLUDE (bloat):
- [ ] ❌ NO Quick Reference section
- [ ] ❌ NO System Architecture Summary
- [ ] ❌ NO Test Environment Requirements as separate section (integrate into Dependencies)
- [ ] ❌ NO Testability Assessment section (covered in Dependencies)
- [ ] ❌ NO Test Levels Strategy section (obvious from test scenarios)
- [ ] ❌ NO full NFR Evidence Audit or final PASS/CONCERNS/FAIL decisions
- [ ] ❌ NO Quality Gate Criteria section (teams decide for themselves)
- [ ] ❌ NO Follow-on Workflows section (BMAD commands self-explanatory)
- [ ] ❌ NO Approval section
- [ ] ❌ NO Infrastructure/DevOps/Finance effort tables (out of scope)
- [ ] ❌ NO detailed milestone-by-milestone breakdown tables (use Implementation Planning Handoff if needed)
- [ ] ❌ NO generic Next Steps section (use Implementation Planning Handoff if needed)
Cross-Document Consistency
- [ ] Both documents reference same risks by ID (R-001, R-002, etc.)
- [ ] Both documents use consistent priority levels (P0, P1, P2, P3)
- [ ] Both documents reference same pre-implementation blockers
- [ ] No duplicate content (cross-reference instead)
- [ ] Dates and authors match across documents
- [ ] ADR and PRD references consistent
Document Quality (Anti-Bloat Check)
CRITICAL: Check for bloat and repetition across BOTH documents
- [ ] No repeated notes 10+ times (e.g., "Timing is pessimistic until R-005 fixed" on every section)
- [ ] Repeated information consolidated (write once at top, reference briefly if needed)
- [ ] No excessive detail that doesn't add value (obvious concepts, redundant examples)
- [ ] Focus on unique/critical info (only document what's different from standard practice)
- [ ] Architecture doc: Concerns-focused, NOT implementation-focused
- [ ] QA doc: Implementation-focused, NOT theory-focused
- [ ] Clear separation: Architecture = WHAT and WHY, QA = HOW
- [ ] Professional tone: No AI slop markers
- [ ] Avoid excessive ✅/❌ emojis (use sparingly, only when adding clarity)
- [ ] Avoid "absolutely", "excellent", "fantastic", overly enthusiastic language
- [ ] Write professionally and directly
- [ ] Architecture doc length: Target ~150-200 lines max (focus on actionable concerns only)
- [ ] QA doc length: Keep concise, remove bloat sections
Architecture Doc Structure (Actionable-First Principle)
CRITICAL: Validate structure follows actionable-first, FYI-last principle
- [ ] Actionable sections at TOP:
- [ ] Quick Guide (🚨 BLOCKERS first, then ⚠️ HIGH PRIORITY, then 📋 INFO ONLY last)
- [ ] Risk Assessment (high-priority risks ≥6 at top)
- [ ] Testability Concerns (concerns/blockers at top, passing items at bottom)
- [ ] Risk Mitigation Plans (for high-priority risks ≥6)
- [ ] FYI sections at BOTTOM:
- [ ] Testability Assessment Summary (what works well - only if worth mentioning)
- [ ] Assumptions and Dependencies
- [ ] ASRs categorized correctly:
- [ ] Actionable ASRs included in 🚨 or ⚠️ sections
- [ ] FYI ASRs included in 📋 section or omitted if obvious
BMAD Handoff Validation (System-Level Mode Only)
- [ ] Handoff document generated at
{test_artifacts}/test-design/{project_name}-handoff.md - [ ] TEA Artifacts Inventory table populated with actual paths
- [ ] Epic-Level Integration Guidance populated with P0/P1 risks
- [ ] Story-Level Integration Guidance populated with critical test scenarios
- [ ] Risk-to-Story Mapping table populated from risk register
- [ ] Recommended workflow sequence is accurate
- [ ] Phase transition quality gates are defined
Completion Criteria
All must be true:
- [ ] All prerequisites met
- [ ] All process steps completed
- [ ] All output validations passed
- [ ] All quality checks passed
- [ ] All integration points verified
- [ ] Output file(s) complete and well-formatted
- [ ] System-level mode: Both documents validated (if applicable)
- [ ] System-level mode: Handoff document validated (if applicable)
- [ ] Epic-level mode: Single document validated (if applicable)
- [ ] Team review scheduled (if required)
Post-Workflow Actions
User must complete:
1. [ ] Review risk assessment with team 2. [ ] Prioritize mitigation for high-priority risks (score ≥6) 3. [ ] Allocate resources per estimates 4. [ ] Run *atdd workflow to generate P0 tests (separate workflow; not auto-run) 5. [ ] Set up test data factories and fixtures 6. [ ] Schedule team review of test design document
Recommended next workflows:
1. [ ] Run atdd workflow for P0 test generation 2. [ ] Run framework workflow if not already done 3. [ ] Run ci workflow to configure pipeline stages
Rollback Procedure
If workflow fails:
1. [ ] Delete output file 2. [ ] Review error logs 3. [ ] Fix missing context (PRD, architecture docs) 4. [ ] Clarify ambiguous requirements 5. [ ] Retry workflow
Notes
Common Issues
Issue: Too many P0 tests
- Solution: Apply strict P0 criteria - must block core AND high risk AND no workaround
Issue: Risk scores all high
- Solution: Differentiate between high-impact (3) and degraded (2) impacts
Issue: Duplicate coverage across levels
- Solution: Use test pyramid - E2E for critical paths only
Issue: Resource estimates too high or too precise
- Solution:
- Invest in fixtures/factories to reduce per-test setup time
- Use interval ranges (e.g., "~55-110 hours") instead of exact numbers (e.g., "81 hours")
- Widen intervals if high uncertainty exists
Issue: Execution order section too complex or redundant
- Solution:
- Default: Run everything in PRs (<15 min with Playwright parallelization)
- Only defer to nightly/weekly if expensive (k6, chaos, 4+ hour tests)
- Don't create smoke/P0/P1/P2/P3 tier structure
- Don't re-list all tests (already in coverage plan)
Best Practices
- Base risk assessment on evidence, not assumptions
- High-priority risks (≥6) require immediate mitigation
- P0 tests should cover <10% of total scenarios
- Avoid testing same behavior at multiple levels
- Use interval-based estimates (e.g., "~25-40 hours") instead of exact numbers to avoid false precision and provide flexibility
- Keep execution strategy simple: Default to "run everything in PRs" (<15 min with Playwright), only defer if expensive/long-running
- Avoid execution order redundancy: Don't create complex tier structures or re-list tests
---
Checklist Complete: Sign off when all items validated.
Completed by: {name} Date: {date} Epic: {epic title} Notes: {additional notes}
# DO NOT EDIT -- overwritten on every update.
#
# Workflow customization surface for bmad-testarch-test-design. 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 Design and Risk Assessment
Version: 5.0 (Step-File Architecture)
---
Overview
Plans comprehensive test coverage strategy with risk assessment, priority classification, and execution ordering. This workflow operates in two modes:
- System-Level Mode (Phase 3): Testability review of architecture before solutioning gate check
- Epic-Level Mode (Phase 4): Per-epic test planning with risk assessment
The workflow auto-detects which mode to use based on project phase and user intent.
---
WORKFLOW ARCHITECTURE
This workflow uses step-file architecture for disciplined execution:
Core Principles
- Micro-file Design: Each step is a self-contained instruction file
- Just-In-Time Loading: Only the current step file is in memory
- Sequential Enforcement: Execute steps in order without skipping
- State Tracking: Write outputs only when instructed, then proceed
Step Processing Rules (Non-Negotiable)
1. READ COMPLETELY: Read the entire step file before taking any action 2. FOLLOW SEQUENCE: Execute all numbered sections in order 3. WAIT FOR INPUT: Halt when user input is required 4. LOAD NEXT: Only load the next step file when directed
---
INITIALIZATION SEQUENCE
1. Configuration Loading
From workflow.yaml, resolve:
config_source,test_artifacts,user_name,communication_language,document_output_language,date
2. First Step
Load, read completely, and execute: {skill-root}/steps-c/step-01-detect-mode.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.
---
OUTPUT GENERATION GUIDANCE
When populating templates in step 5, apply the following guidance for these sections:
Not in Scope
- Identify components, third-party services, or subsystems NOT covered by this test plan
- For each excluded item, provide reasoning (why excluded) and mitigation (how risk is addressed elsewhere)
- Common exclusions: external vendor APIs tested by upstream teams, legacy modules outside the current phase scope, infrastructure already covered by platform team monitoring
Entry and Exit Criteria
- Entry criteria: Derive from Dependencies and Test Blockers -- what must be resolved before QA can start testing
- Exit criteria: Derive from Quality Gate Criteria -- what constitutes "done" for the testing phase
- Include project-specific criteria based on context (e.g., "feature flag enabled in staging", "seed data loaded", "pre-implementation blockers resolved")
Project Team (Optional)
- Include only if roles/names are known or responsibility mapping is needed
- Extract names and roles from PRD, ADR, or project context if available
- If names are unknown, either omit or use role placeholders for drafts
- Map testing responsibilities to each role (e.g., who owns E2E tests, who signs off)
Tooling and Access (System-Level QA Document Only)
- Include only if non-standard tools or access requests are required
- List notable tools/services needed for test execution and any access approvals
- Avoid assuming specific vendors unless the project context names them
- Mark each item's status as Ready or Pending based on available information
- This section applies only to
test-design-qa-template.mdoutput
Implementation Planning Handoff (Optional)
- Include only if test design produces implementation tasks that must be scheduled
- Derive items from Dependencies & Test Blockers, tooling/access needs, and QA infra setup
- If no dedicated QA, assign ownership to Dev/Platform as appropriate
- Keep the list short; avoid per-milestone breakdown tables
Interworking & Regression
- Identify services and components that interact with or are affected by the feature under test
- For each, define what existing regression tests must pass before release
- Note any cross-team coordination needed for regression validation (e.g., shared staging environments, upstream API contracts)
ADR Quality Readiness Checklist
Purpose: Standardized 8-category, 29-criteria framework for evaluating system testability and NFR compliance during architecture review (Phase 3) and NFR assessment.
When to Use:
- System-level test design (Phase 3): Identify testability gaps in architecture
- NFR assessment workflow: Structured evaluation with evidence
- Gate decisions: Quantifiable criteria (X/29 met = PASS/CONCERNS/FAIL)
How to Use:
1. For each criterion, assess status: ✅ Covered / ⚠️ Gap / ⬜ Not Assessed 2. Document gap description if ⚠️ 3. Describe risk if criterion unmet 4. Map to test scenarios (what tests validate this criterion)
---
1. Testability & Automation
Question: Can we verify this effectively without manual toil?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 1.1 | Isolation: Can the service be tested with all downstream dependencies (DBs, APIs, Queues) mocked or stubbed? | Flaky tests; inability to test in isolation | P1: Service runs with mocked DB, P1: Service runs with mocked API, P2: Integration tests with real deps |
| 1.2 | Headless Interaction: Is 100% of the business logic accessible via API (REST/gRPC) to bypass the UI for testing? | Slow, brittle UI-based automation | P0: All core logic callable via API, P1: No UI dependency for critical paths |
| 1.3 | State Control: Do we have "Seeding APIs" or scripts to inject specific data states (e.g., "User with expired subscription") instantly? | Long setup times; inability to test edge cases | P0: Seed baseline data, P0: Inject edge case data states, P1: Cleanup after tests |
| 1.4 | Sample Requests: Are there valid and invalid cURL/JSON sample requests provided in the design doc for QA to build upon? | Ambiguity on how to consume the service | P1: Valid request succeeds, P1: Invalid request fails with clear error |
Common Gaps:
- No mock endpoints for external services (Athena, Milvus, third-party APIs)
- Business logic tightly coupled to UI (requires E2E tests for everything)
- No seeding APIs (manual database setup required)
- ADR has architecture diagrams but no sample API requests
Mitigation Examples:
- 1.1 (Isolation): Provide mock endpoints, dependency injection, interface abstractions
- 1.2 (Headless): Expose all business logic via REST/GraphQL APIs
- 1.3 (State Control): Implement
/api/test-dataseeding endpoints (dev/staging only) - 1.4 (Sample Requests): Add "Example API Calls" section to ADR with cURL commands
---
2. Test Data Strategy
Question: How do we fuel our tests safely?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 2.1 | Segregation: Does the design support multi-tenancy or specific headers (e.g., x-test-user) to keep test data out of prod metrics? | Skewed business analytics; data pollution | P0: Multi-tenant isolation (customer A ≠ customer B), P1: Test data excluded from prod metrics |
| 2.2 | Generation: Can we use synthetic data, or do we rely on scrubbing production data (GDPR/PII risk)? | Privacy violations; dependency on stale data | P0: Faker-based synthetic data, P1: No production data in tests |
| 2.3 | Teardown: Is there a mechanism to "reset" the environment or clean up data after destructive tests? | Environment rot; subsequent test failures | P0: Automated cleanup after tests, P2: Environment reset script |
Common Gaps:
- No
customer_idscoping in queries (cross-tenant data leakage risk) - Reliance on production data dumps (GDPR/PII violations)
- No cleanup mechanism (tests leave data behind, polluting environment)
Mitigation Examples:
- 2.1 (Segregation): Enforce
customer_idin all queries, add test-specific headers - 2.2 (Generation): Use Faker library, create synthetic data generators, prohibit prod dumps
- 2.3 (Teardown): Auto-cleanup hooks in test framework, isolated test customer IDs
---
3. Scalability & Availability
Question: Can it grow, and will it stay up?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 3.1 | Statelessness: Is the service stateless? If not, how is session state replicated across instances? | Inability to auto-scale horizontally | P1: Service restart mid-request → no data loss, P2: Horizontal scaling under load |
| 3.2 | Bottlenecks: Have we identified the weakest link (e.g., database connections, API rate limits) under load? | System crash during peak traffic | P2: Load test identifies bottleneck, P2: Connection pool exhaustion handled |
| 3.3 | SLA Definitions: What is the target Availability (e.g., 99.9%) and does the architecture support redundancy to meet it? | Breach of contract; customer churn | P1: Availability target defined, P2: Redundancy validated (multi-region/zone) |
| 3.4 | Circuit Breakers: If a dependency fails, does this service fail fast or hang? | Cascading failures taking down the whole platform | P1: Circuit breaker opens on 5 failures, P1: Auto-reset after recovery, P2: Timeout prevents hanging |
Common Gaps:
- Stateful session management (can't scale horizontally)
- No load testing, bottlenecks unknown
- SLA undefined or unrealistic (99.99% without redundancy)
- No circuit breakers (cascading failures)
Mitigation Examples:
- 3.1 (Statelessness): Externalize session to Redis/JWT, design for horizontal scaling
- 3.2 (Bottlenecks): Load test with k6, monitor connection pools, identify weak links
- 3.3 (SLA): Define realistic SLA (99.9% = 43 min/month downtime), add redundancy
- 3.4 (Circuit Breakers): Implement circuit breakers (Hystrix pattern), fail fast on errors
---
4. Disaster Recovery (DR)
Question: What happens when the worst-case scenario occurs?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 4.1 | RTO/RPO: What is the Recovery Time Objective (how long to restore) and Recovery Point Objective (max data loss)? | Extended outages; data loss liability | P2: RTO defined and tested, P2: RPO validated (backup frequency) |
| 4.2 | Failover: Is region/zone failover automated or manual? Has it been practiced? | "Heroics" required during outages; human error | P2: Automated failover works, P2: Manual failover documented and tested |
| 4.3 | Backups: Are backups immutable and tested for restoration integrity? | Ransomware vulnerability; corrupted backups | P2: Backup restore succeeds, P2: Backup immutability validated |
Common Gaps:
- RTO/RPO undefined (no recovery plan)
- Failover never tested (manual process, prone to errors)
- Backups exist but restoration never validated (untested backups = no backups)
Mitigation Examples:
- 4.1 (RTO/RPO): Define RTO (e.g., 4 hours) and RPO (e.g., 1 hour), document recovery procedures
- 4.2 (Failover): Automate multi-region failover, practice failover drills quarterly
- 4.3 (Backups): Implement immutable backups (S3 versioning), test restore monthly
---
5. Security
Question: Is the design safe by default?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 5.1 | AuthN/AuthZ: Does it implement standard protocols (OAuth2/OIDC)? Are permissions granular (Least Privilege)? | Unauthorized access; data leaks | P0: OAuth flow works, P0: Expired token rejected, P0: Insufficient permissions return 403, P1: Scope enforcement |
| 5.2 | Encryption: Is data encrypted at rest (DB) and in transit (TLS)? | Compliance violations; data theft | P1: Milvus data-at-rest encrypted, P1: TLS 1.2+ enforced, P2: Certificate rotation works |
| 5.3 | Secrets: Are API keys/passwords stored in a Vault (not in code or config files)? | Credentials leaked in git history | P1: No hardcoded secrets in code, P1: Secrets loaded from AWS Secrets Manager |
| 5.4 | Input Validation: Are inputs sanitized against Injection attacks (SQLi, XSS)? | System compromise via malicious payloads | P1: SQL injection sanitized, P1: XSS escaped, P2: Command injection prevented |
Common Gaps:
- Weak authentication (no OAuth, hardcoded API keys)
- No encryption at rest (plaintext in database)
- Secrets in git (API keys, passwords in config files)
- No input validation (vulnerable to SQLi, XSS, command injection)
Mitigation Examples:
- 5.1 (AuthN/AuthZ): Implement OAuth 2.1/OIDC, enforce least privilege, validate scopes
- 5.2 (Encryption): Enable TDE (Transparent Data Encryption), enforce TLS 1.2+
- 5.3 (Secrets): Migrate to AWS Secrets Manager/Vault, scan git history for leaks
- 5.4 (Input Validation): Sanitize all inputs, use parameterized queries, escape outputs
---
6. Monitorability, Debuggability & Manageability
Question: Can we operate and fix this in production?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 6.1 | Tracing: Does the service propagate W3C Trace Context / Correlation IDs for distributed tracing? | Impossible to debug errors across microservices | P2: W3C Trace Context propagated (EventBridge → Lambda → Service), P2: Correlation ID in all logs |
| 6.2 | Logs: Can log levels (INFO vs DEBUG) be toggled dynamically without a redeploy? | Inability to diagnose issues in real-time | P2: Log level toggle works without redeploy, P2: Logs structured (JSON format) |
| 6.3 | Metrics: Does it expose RED metrics (Rate, Errors, Duration) for Prometheus/Datadog? | Flying blind regarding system health | P2: /metrics endpoint exposes RED metrics, P2: Prometheus/Datadog scrapes successfully |
| 6.4 | Config: Is configuration externalized? Can we change behavior without a code build? | Rigid system; full deploys needed for minor tweaks | P2: Config change without code build, P2: Feature flags toggle behavior |
Common Gaps:
- No distributed tracing (can't debug across microservices)
- Static log levels (requires redeploy to enable DEBUG)
- No metrics endpoint (blind to system health)
- Configuration hardcoded (requires full deploy for minor changes)
Mitigation Examples:
- 6.1 (Tracing): Implement W3C Trace Context, add correlation IDs to all logs
- 6.2 (Logs): Use dynamic log levels (environment variable), structured logging (JSON)
- 6.3 (Metrics): Expose /metrics endpoint, track RED metrics (Rate, Errors, Duration)
- 6.4 (Config): Externalize config (AWS SSM/AppConfig), use feature flags (LaunchDarkly)
---
7. QoS (Quality of Service) & QoE (Quality of Experience)
Question: How does it perform, and how does it feel?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 7.1 | Latency (QoS): What are the P95 and P99 latency targets? | Slow API responses affecting throughput | P3: P95 latency <Xs (load test), P3: P99 latency <Ys (load test) |
| 7.2 | Throttling (QoS): Is there Rate Limiting to prevent "noisy neighbors" or DDoS? | Service degradation for all users due to one bad actor | P2: Rate limiting enforced, P2: 429 returned when limit exceeded |
| 7.3 | Perceived Performance (QoE): Does the UI show optimistic updates or skeletons while loading? | App feels sluggish to the user | P2: Skeleton/spinner shown while loading (E2E), P2: Optimistic updates (E2E) |
| 7.4 | Degradation (QoE): If the service is slow, does it show a friendly message or a raw stack trace? | Poor user trust; frustration | P2: Friendly error message shown (not stack trace), P1: Error boundary catches exceptions (E2E) |
Common Gaps:
- Latency targets undefined (no SLOs)
- No rate limiting (vulnerable to DDoS, noisy neighbors)
- Poor perceived performance (blank screen while loading)
- Raw error messages (stack traces exposed to users)
Mitigation Examples:
- 7.1 (Latency): Define SLOs (P95 <2s, P99 <5s), load test to validate
- 7.2 (Throttling): Implement rate limiting (per-user, per-IP), return 429 with Retry-After
- 7.3 (Perceived Performance): Add skeleton screens, optimistic updates, progressive loading
- 7.4 (Degradation): Implement error boundaries, show friendly messages, log stack traces server-side
---
8. Deployability
Question: How easily can we ship this?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 8.1 | Zero Downtime: Does the design support Blue/Green or Canary deployments? | Maintenance windows required (downtime) | P2: Blue/Green deployment works, P2: Canary deployment gradual rollout |
| 8.2 | Backward Compatibility: Can we deploy the DB changes separately from the Code changes? | "Lock-step" deployments; high risk of breaking changes | P2: DB migration before code deploy, P2: Code handles old and new schema |
| 8.3 | Rollback: Is there an automated rollback trigger if Health Checks fail post-deploy? | Prolonged outages after a bad deploy | P2: Health check fails → automated rollback, P2: Rollback completes within RTO |
Common Gaps:
- No zero-downtime strategy (requires maintenance window)
- Tight coupling between DB and code (lock-step deployments)
- No automated rollback (manual intervention required)
Mitigation Examples:
- 8.1 (Zero Downtime): Implement Blue/Green or Canary deployments, use feature flags
- 8.2 (Backward Compatibility): Separate DB migrations from code deploys, support N-1 schema
- 8.3 (Rollback): Automate rollback on health check failures, test rollback procedures
---
Usage in Test Design Workflow
System-Level Mode (Phase 3):
In test-design-architecture.md:
- Add "NFR Testability Requirements" section after ASRs
- Use 8 categories with checkboxes (29 criteria)
- For each criterion: Status (⬜ Not Assessed, ⚠️ Gap, ✅ Covered), Gap description, Risk if unmet
- Example:
## NFR Testability Requirements
**Based on ADR Quality Readiness Checklist**
### 1. Testability & Automation
Can we verify this effectively without manual toil?
| Criterion | Status | Gap/Requirement | Risk if Unmet |
| ---------------------------------------------------------------- | --------------- | ------------------------------------ | --------------------------------------- |
| ⬜ Isolation: Can service be tested with downstream deps mocked? | ⚠️ Gap | No mock endpoints for Athena queries | Flaky tests; can't test in isolation |
| ⬜ Headless: 100% business logic accessible via API? | ✅ Covered | All MCP tools are REST APIs | N/A |
| ⬜ State Control: Seeding APIs to inject data states? | ⚠️ Gap | Need `/api/test-data` endpoints | Long setup times; can't test edge cases |
| ⬜ Sample Requests: Valid/invalid cURL/JSON samples provided? | ⬜ Not Assessed | Pending ADR Tool schemas finalized | Ambiguity on how to consume service |
**Actions Required:**
- [ ] Backend: Implement mock endpoints for Athena (R-002 blocker)
- [ ] Backend: Implement `/api/test-data` seeding APIs (R-002 blocker)
- [ ] PM: Finalize ADR Tool schemas with sample requests (Q4)In test-design-qa.md:
- Map each criterion to test scenarios
- Add "NFR Test Coverage Plan" section with P0/P1/P2 priority for each category
- Reference Architecture doc gaps
- Example:
## NFR Test Coverage Plan
**Based on ADR Quality Readiness Checklist**
### 1. Testability & Automation (4 criteria)
**Prerequisites from Architecture doc:**
- [ ] R-002: Test data seeding APIs implemented (blocker)
- [ ] Mock endpoints available for Athena queries
| Criterion | Test Scenarios | Priority | Test Count | Owner |
| ------------------------------- | -------------------------------------------------------------------- | -------- | ---------- | ---------------- |
| Isolation: Mock downstream deps | Mock Athena queries, Mock Milvus, Service runs isolated | P1 | 3 | Backend Dev + QA |
| Headless: API-accessible logic | All MCP tools callable via REST, No UI dependency for business logic | P0 | 5 | QA |
| State Control: Seeding APIs | Create test customer, Seed 1000 transactions, Inject edge cases | P0 | 4 | QA |
| Sample Requests: cURL examples | Valid request succeeds, Invalid request fails with clear error | P1 | 2 | QA |
**Detailed Test Scenarios:**
- [ ] Isolation: Service runs with Athena mocked (returns fixture data)
- [ ] Isolation: Service runs with Milvus mocked (returns ANN fixture)
- [ ] State Control: Seed test customer with 1000 baseline transactions
- [ ] State Control: Inject edge case (expired subscription user)---
Usage in NFR Assessment Workflow
Output Structure:
# NFR Assessment: {Feature Name}
**Based on ADR Quality Readiness Checklist (8 categories, 29 criteria)**
## Assessment Summary
| Category | Status | Criteria Met | Evidence | Next Action |
| ----------------------------- | ----------- | ------------ | -------------------------------------- | -------------------- |
| 1. Testability & Automation | ⚠️ CONCERNS | 2/4 | Mock endpoints missing | Implement R-002 |
| 2. Test Data Strategy | ✅ PASS | 3/3 | Faker + auto-cleanup | None |
| 3. Scalability & Availability | ⚠️ CONCERNS | 1/4 | SLA undefined | Define SLA |
| 4. Disaster Recovery | ⚠️ CONCERNS | 0/3 | No RTO/RPO defined | Define recovery plan |
| 5. Security | ✅ PASS | 4/4 | OAuth 2.1 + TLS + Vault + Sanitization | None |
| 6. Monitorability | ⚠️ CONCERNS | 2/4 | No metrics endpoint | Add /metrics |
| 7. QoS & QoE | ⚠️ CONCERNS | 1/4 | Latency targets undefined | Define SLOs |
| 8. Deployability | ✅ PASS | 3/3 | Blue/Green + DB migrations + Rollback | None |
**Overall:** 14/29 criteria met (48%) → ⚠️ CONCERNS
**Gate Decision:** CONCERNS (requires mitigation plan before GA)
---
## Detailed Assessment
### 1. Testability & Automation (2/4 criteria met)
**Question:** Can we verify this effectively without manual toil?
| Criterion | Status | Evidence | Gap/Action |
| ---------------------------- | ------ | ------------------------ | -------------------------- |
| ⬜ Isolation: Mock deps | ⚠️ | No Athena mock | Implement mock endpoints |
| ⬜ Headless: API-accessible | ✅ | All MCP tools are REST | N/A |
| ⬜ State Control: Seeding | ⚠️ | `/api/test-data` pending | Pre-implementation blocker |
| ⬜ Sample Requests: Examples | ⬜ | Pending schemas | Finalize ADR Tools |
**Overall Status:** ⚠️ CONCERNS (2/4 criteria met)
**Next Actions:**
- [ ] Backend: Implement Athena mock endpoints (pre-implementation)
- [ ] Backend: Implement `/api/test-data` (pre-implementation)
- [ ] PM: Finalize sample requests (implementation phase)
{Repeat for all 8 categories}---
Benefits
For test-design workflow:
- ✅ Standard NFR structure (same 8 categories every project)
- ✅ Clear testability requirements for Architecture team
- ✅ Direct mapping: criterion → requirement → test scenario
- ✅ Comprehensive coverage (29 criteria = no blind spots)
For nfr-assess workflow:
- ✅ Structured assessment (not ad-hoc)
- ✅ Quantifiable (X/29 criteria met)
- ✅ Evidence-based (each criterion has evidence field)
- ✅ Actionable (gaps → next actions with owners)
For Architecture teams:
- ✅ Clear checklist (29 yes/no questions)
- ✅ Risk-aware (each criterion has "risk if unmet")
- ✅ Scoped work (only implement what's needed, not everything)
For QA teams:
- ✅ Comprehensive test coverage (29 criteria → test scenarios)
- ✅ Clear priorities (P0 for security/isolation, P1 for monitoring, etc.)
- ✅ No ambiguity (each criterion has specific test scenarios)
API Request Utility
Principle
Use typed HTTP client with built-in schema validation and automatic retry for server errors. The utility handles URL resolution, header management, response parsing, and single-line response validation with proper TypeScript support. Works without a browser - ideal for pure API/service testing.
Rationale
Vanilla Playwright's request API requires boilerplate for common patterns:
- Manual JSON parsing (
await response.json()) - Repetitive status code checking
- No built-in retry logic for transient failures
- No schema validation
- Complex URL construction
The apiRequest utility provides:
- Automatic JSON parsing: Response body pre-parsed
- Built-in retry: 5xx errors retry with exponential backoff
- Schema validation: Single-line validation (JSON Schema, Zod, OpenAPI)
- URL resolution: Four-tier strategy (explicit > config > Playwright > direct)
- TypeScript generics: Type-safe response bodies
- No browser required: Pure API testing without browser overhead
Pattern Examples
Example 1: Basic API Request
Context: Making authenticated API requests with automatic retry and type safety.
Implementation:
import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';
test('should fetch user data', async ({ apiRequest }) => {
const { status, body } = await apiRequest<User>({
method: 'GET',
path: '/api/users/123',
headers: { Authorization: 'Bearer token' },
});
expect(status).toBe(200);
expect(body.name).toBe('John Doe'); // TypeScript knows body is User
});Key Points:
- Generic type
<User>provides TypeScript autocomplete forbody - Status and body destructured from response
- Headers passed as object
- Automatic retry for 5xx errors (configurable)
Example 2: Schema Validation (Single Line)
Context: Validate API responses match expected schema with single-line syntax.
Implementation:
import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';
import { z } from 'zod';
// JSON Schema validation
test('should validate response schema (JSON Schema)', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/users/123',
validateSchema: {
type: 'object',
required: ['id', 'name', 'email'],
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string', format: 'email' },
},
},
});
// Throws if schema validation fails
expect(status).toBe(200);
});
// Zod schema validation
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
test('should validate response schema (Zod)', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/users/123',
validateSchema: UserSchema,
});
// Response body is type-safe AND validated
expect(status).toBe(200);
expect(body.email).toContain('@');
});Key Points:
- Single
validateSchemaparameter - Supports JSON Schema, Zod, YAML files, OpenAPI specs
- Throws on validation failure with detailed errors
- Zero boilerplate validation code
Example 3: POST with Body and Retry Configuration
Context: Creating resources with custom retry behavior for error testing.
Implementation:
test('should create user', async ({ apiRequest }) => {
const newUser = {
name: 'Jane Doe',
email: 'jane@example.com',
};
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/users',
body: newUser, // Automatically sent as JSON
headers: { Authorization: 'Bearer token' },
});
expect(status).toBe(201);
expect(body.id).toBeDefined();
});
// Disable retry for error testing
test('should handle 500 errors', async ({ apiRequest }) => {
await expect(
apiRequest({
method: 'GET',
path: '/api/error',
retryConfig: { maxRetries: 0 }, // Disable retry
}),
).rejects.toThrow('Request failed with status 500');
});Key Points:
bodyparameter auto-serializes to JSON- Default retry: 5xx errors, 3 retries, exponential backoff
- Disable retry with
retryConfig: { maxRetries: 0 } - Only 5xx errors retry (4xx errors fail immediately)
Example 4: URL Resolution Strategy
Context: Flexible URL handling for different environments and test contexts.
Implementation:
// Strategy 1: Explicit baseUrl (highest priority)
await apiRequest({
method: 'GET',
path: '/users',
baseUrl: 'https://api.example.com', // Uses https://api.example.com/users
});
// Strategy 2: Config baseURL (from fixture)
import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';
test.use({ configBaseUrl: 'https://staging-api.example.com' });
test('uses config baseURL', async ({ apiRequest }) => {
await apiRequest({
method: 'GET',
path: '/users', // Uses https://staging-api.example.com/users
});
});
// Strategy 3: Playwright baseURL (from playwright.config.ts)
// playwright.config.ts
export default defineConfig({
use: {
baseURL: 'https://api.example.com',
},
});
test('uses Playwright baseURL', async ({ apiRequest }) => {
await apiRequest({
method: 'GET',
path: '/users', // Uses https://api.example.com/users
});
});
// Strategy 4: Direct path (full URL)
await apiRequest({
method: 'GET',
path: 'https://api.example.com/users', // Full URL works too
});Key Points:
- Four-tier resolution: explicit > config > Playwright > direct
- Trailing slashes normalized automatically
- Environment-specific baseUrl easy to configure
Example 5: Integration with Recurse (Polling)
Context: Waiting for async operations to complete (background jobs, eventual consistency).
Implementation:
import { test } from '@seontechnologies/playwright-utils/fixtures';
test('should poll until job completes', async ({ apiRequest, recurse }) => {
// Create job
const { body } = await apiRequest({
method: 'POST',
path: '/api/jobs',
body: { type: 'export' },
});
const jobId = body.id;
// Poll until ready
const completedJob = await recurse(
() => apiRequest({ method: 'GET', path: `/api/jobs/${jobId}` }),
(response) => response.body.status === 'completed',
{ timeout: 60000, interval: 2000 },
);
expect(completedJob.body.result).toBeDefined();
});Key Points:
apiRequestreturns full response objectrecursepolls until predicate returns true- Composable utilities work together seamlessly
Example 6: Microservice Testing (Multiple Services)
Context: Test interactions between microservices without a browser.
Implementation:
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
const USER_SERVICE = process.env.USER_SERVICE_URL || 'http://localhost:3001';
const ORDER_SERVICE = process.env.ORDER_SERVICE_URL || 'http://localhost:3002';
test.describe('Microservice Integration', () => {
test('should validate cross-service user lookup', async ({ apiRequest }) => {
// Create user in user-service
const { body: user } = await apiRequest({
method: 'POST',
path: '/api/users',
baseUrl: USER_SERVICE,
body: { name: 'Test User', email: 'test@example.com' },
});
// Create order in order-service (validates user via user-service)
const { status, body: order } = await apiRequest({
method: 'POST',
path: '/api/orders',
baseUrl: ORDER_SERVICE,
body: {
userId: user.id,
items: [{ productId: 'prod-1', quantity: 2 }],
},
});
expect(status).toBe(201);
expect(order.userId).toBe(user.id);
});
test('should reject order for invalid user', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/orders',
baseUrl: ORDER_SERVICE,
body: {
userId: 'non-existent-user',
items: [{ productId: 'prod-1', quantity: 1 }],
},
});
expect(status).toBe(400);
expect(body.code).toBe('INVALID_USER');
});
});Key Points:
- Test multiple services without browser
- Use
baseUrlto target different services - Validate cross-service communication
- Pure API testing - fast and reliable
Example 7: GraphQL API Testing
Context: Test GraphQL endpoints with queries and mutations.
Implementation:
test.describe('GraphQL API', () => {
const GRAPHQL_ENDPOINT = '/graphql';
test('should query users via GraphQL', async ({ apiRequest }) => {
const query = `
query GetUsers($limit: Int) {
users(limit: $limit) {
id
name
email
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query,
variables: { limit: 10 },
},
});
expect(status).toBe(200);
expect(body.errors).toBeUndefined();
expect(body.data.users).toHaveLength(10);
});
test('should create user via mutation', async ({ apiRequest }) => {
const mutation = `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query: mutation,
variables: {
input: { name: 'GraphQL User', email: 'gql@example.com' },
},
},
});
expect(status).toBe(200);
expect(body.data.createUser.id).toBeDefined();
});
});Key Points:
- GraphQL via POST request
- Variables in request body
- Check
body.errorsfor GraphQL errors (not status code) - Works for queries and mutations
Example 8: Operation-Based Overload (OpenAPI / Code Generators)
Context: When using a code generator (orval, openapi-generator, custom scripts) that produces typed operation definitions from an OpenAPI spec, pass the operation object directly to apiRequest. This eliminates manual method/path extraction and typeof assertions while preserving full type inference for request body, response, and query parameters. Available since v3.14.0.
Implementation:
// Generated operation definition — structural typing, no import from playwright-utils needed
// type OperationShape = { path: string; method: 'POST'|'GET'|'PUT'|'DELETE'|'PATCH'|'HEAD'; response: unknown; request: unknown; query?: unknown }
import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
// --- Basic usage: operation replaces method + path ---
test('should upsert person via operation overload', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
operation: upsertPersonv2({ customerId }),
headers: getHeaders(customerId),
body: personInput, // compile-time typed as Schemas.PersonInput
});
expect(status).toBe(200);
expect(body.id).toBeDefined(); // body typed as Schemas.Person
});
// --- Typed query parameters (replaces string concatenation) ---
test('should list people with typed query', async ({ apiRequest }) => {
const { body } = await apiRequest({
operation: getPeoplev2({ customerId }),
headers: getHeaders(customerId),
query: { page: 0, page_size: 5 }, // typed from operation's query definition
});
expect(body.items).toHaveLength(5);
});
// --- Params escape hatch (pre-formatted query strings) ---
test('should fetch billing history with raw params', async ({ apiRequest }) => {
const { body } = await apiRequest({
operation: getBillingHistoryv2({ customerId }),
headers: getHeaders(customerId),
params: {
'filters[start_date]': getThisMonthTimestamp(),
'filters[date_type]': 'MONTH',
},
});
expect(body.entries.length).toBeGreaterThan(0);
});
// --- Works with recurse (polling) ---
test('should poll until person is reviewed', async ({ apiRequest, recurse }) => {
await recurse(
async () =>
apiRequest({
operation: getPersonv2({ customerId, hash }),
headers: getHeaders(customerId),
}),
(res) => {
expect(res.status).toBe(200);
expect(res.body.status).toBe('REVIEWED');
},
{ timeout: 30000, interval: 1000 },
);
});
// --- Schema validation chains work identically ---
test('should create movie with schema validation', async ({ apiRequest }) => {
const { body } = await apiRequest({
operation: createMovieOp,
headers: commonHeaders(authToken),
body: movie,
}).validateSchema(CreateMovieResponseSchema, {
shape: { status: 200, data: { name: movie.name } },
});
expect(body.data.id).toBeDefined();
});Key Points:
- Pass
operationinstead ofmethod+path— mutually exclusive at compile time - Response body, request body, and query types inferred from operation definition
- Uses structural typing (duck typing) — works with any code generator producing
{ path, method, response, request, query? } queryfield auto-serializes to bracket notation (filters[type]=pep,ids[0]=10)paramsescape hatch for pre-formatted strings — wins overqueryon conflict- Fully composable with
recurse,validateSchema, and all existing features response/request/queryon the operation are type-level only — runtime never reads their values
Comparison with Vanilla Playwright
| Vanilla Playwright | playwright-utils apiRequest |
|---|---|
const resp = await request.get('/api/users') | const { status, body } = await apiRequest({ method: 'GET', path: '/api/users' }) |
const body = await resp.json() | Response already parsed |
expect(resp.ok()).toBeTruthy() | Status code directly accessible |
| No retry logic | Auto-retry 5xx errors with backoff |
| No schema validation | Built-in multi-format validation |
| Manual error handling | Descriptive error messages |
When to Use
Use apiRequest for:
- ✅ Pure API/service testing (no browser needed)
- ✅ Microservice integration testing
- ✅ GraphQL API testing
- ✅ Schema validation needs
- ✅ Tests requiring retry logic
- ✅ Background API calls in UI tests
- ✅ Contract testing support
- ✅ Type-safe API testing with OpenAPI-generated operations (v3.14.0+)
Stick with vanilla Playwright for:
- Simple one-off requests where utility overhead isn't worth it
- Testing Playwright's native features specifically
- Legacy tests where migration isn't justified
Related Fragments
api-testing-patterns.md- Comprehensive pure API testing patternsoverview.md- Installation and design principlesauth-session.md- Authentication token managementrecurse.md- Polling for async operationsfixtures-composition.md- Combining utilities with mergeTestslog.md- Logging API requestscontract-testing.md- Pact contract testing
Anti-Patterns
❌ Ignoring retry failures:
try {
await apiRequest({ method: 'GET', path: '/api/unstable' });
} catch {
// Silent failure - loses retry information
}✅ Let retries happen, handle final failure:
await expect(apiRequest({ method: 'GET', path: '/api/unstable' })).rejects.toThrow(); // Retries happen automatically, then final error caught❌ Disabling TypeScript benefits:
const response: any = await apiRequest({ method: 'GET', path: '/users' });✅ Use generic types:
const { body } = await apiRequest<User[]>({ method: 'GET', path: '/users' });
// body is typed as User[]❌ Mixing operation overload with explicit generics:
// Don't pass a generic when using operation — types are inferred from the operation
const { body } = await apiRequest<MyType>({
operation: getPersonv2({ customerId }),
headers: getHeaders(customerId),
});✅ Let the operation infer the types:
const { body } = await apiRequest({
operation: getPersonv2({ customerId }),
headers: getHeaders(customerId),
});
// body type inferred from operation.response❌ Mixing operation with method/path:
// Compile error — operation and method/path are mutually exclusive
await apiRequest({
operation: getPersonv2({ customerId }),
method: 'GET', // Error: method?: never
path: '/api/person', // Error: path?: never
});API Testing Patterns
Principle
Test APIs and backend services directly without browser overhead. Use Playwright's request context for HTTP operations, apiRequest utility for enhanced features, and recurse for async operations. Pure API tests run faster, are more stable, and provide better coverage for service-layer logic.
Rationale
Many teams over-rely on E2E/browser tests when API tests would be more appropriate:
- Slower feedback: Browser tests take seconds, API tests take milliseconds
- More brittle: UI changes break tests even when API works correctly
- Wrong abstraction: Testing business logic through UI layers adds noise
- Resource heavy: Browsers consume memory and CPU
API-first testing provides:
- Fast execution: No browser startup, no rendering, no JavaScript execution
- Direct validation: Test exactly what the service returns
- Better isolation: Test service logic independent of UI
- Easier debugging: Clear request/response without DOM noise
- Contract validation: Verify API contracts explicitly
When to Use API Tests vs E2E Tests
| Scenario | API Test | E2E Test |
|---|---|---|
| CRUD operations | ✅ Primary | ❌ Overkill |
| Business logic validation | ✅ Primary | ❌ Overkill |
| Error handling (4xx, 5xx) | ✅ Primary | ⚠️ Supplement |
| Authentication flows | ✅ Primary | ⚠️ Supplement |
| Data transformation | ✅ Primary | ❌ Overkill |
| User journeys | ❌ Can't test | ✅ Primary |
| Visual regression | ❌ Can't test | ✅ Primary |
| Cross-browser issues | ❌ Can't test | ✅ Primary |
Rule of thumb: If you're testing what the server returns (not how it looks), use API tests.
Pattern Examples
Example 1: Pure API Test (No Browser)
Context: Test REST API endpoints directly without any browser context.
Implementation:
// tests/api/users.spec.ts
import { test, expect } from '@playwright/test';
// No page, no browser - just API
test.describe('Users API', () => {
test('should create user', async ({ request }) => {
const response = await request.post('/api/users', {
data: {
name: 'John Doe',
email: 'john@example.com',
role: 'user',
},
});
expect(response.status()).toBe(201);
const user = await response.json();
expect(user.id).toBeDefined();
expect(user.name).toBe('John Doe');
expect(user.email).toBe('john@example.com');
});
test('should get user by ID', async ({ request }) => {
// Create user first
const createResponse = await request.post('/api/users', {
data: { name: 'Jane Doe', email: 'jane@example.com' },
});
const { id } = await createResponse.json();
// Get user
const getResponse = await request.get(`/api/users/${id}`);
expect(getResponse.status()).toBe(200);
const user = await getResponse.json();
expect(user.id).toBe(id);
expect(user.name).toBe('Jane Doe');
});
test('should return 404 for non-existent user', async ({ request }) => {
const response = await request.get('/api/users/non-existent-id');
expect(response.status()).toBe(404);
const error = await response.json();
expect(error.code).toBe('USER_NOT_FOUND');
});
test('should validate required fields', async ({ request }) => {
const response = await request.post('/api/users', {
data: { name: 'Missing Email' }, // email is required
});
expect(response.status()).toBe(400);
const error = await response.json();
expect(error.code).toBe('VALIDATION_ERROR');
expect(error.details).toContainEqual(expect.objectContaining({ field: 'email', message: expect.any(String) }));
});
});Key Points:
- No
pagefixture needed - onlyrequest - Tests run without browser overhead
- Direct HTTP assertions
- Clear error handling tests
Example 2: API Test with apiRequest Utility
Context: Use enhanced apiRequest for schema validation, retry, and type safety.
Implementation:
// tests/api/orders.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
import { z } from 'zod';
// Define schema for type safety and validation
const OrderSchema = z.object({
id: z.string().uuid(),
userId: z.string(),
items: z.array(
z.object({
productId: z.string(),
quantity: z.number().positive(),
price: z.number().positive(),
}),
),
total: z.number().positive(),
status: z.enum(['pending', 'processing', 'shipped', 'delivered']),
createdAt: z.string().datetime(),
});
type Order = z.infer<typeof OrderSchema>;
test.describe('Orders API', () => {
test('should create order with schema validation', async ({ apiRequest }) => {
const { status, body } = await apiRequest<Order>({
method: 'POST',
path: '/api/orders',
body: {
userId: 'user-123',
items: [
{ productId: 'prod-1', quantity: 2, price: 29.99 },
{ productId: 'prod-2', quantity: 1, price: 49.99 },
],
},
validateSchema: OrderSchema, // Validates response matches schema
});
expect(status).toBe(201);
expect(body.id).toBeDefined();
expect(body.status).toBe('pending');
expect(body.total).toBe(109.97); // 2*29.99 + 49.99
});
test('should handle server errors with retry', async ({ apiRequest }) => {
// apiRequest retries 5xx errors by default
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/orders/order-123',
retryConfig: {
maxRetries: 3,
retryDelay: 1000,
},
});
expect(status).toBe(200);
});
test('should list orders with pagination', async ({ apiRequest }) => {
const { status, body } = await apiRequest<{ orders: Order[]; total: number; page: number }>({
method: 'GET',
path: '/api/orders',
params: { page: 1, limit: 10, status: 'pending' },
});
expect(status).toBe(200);
expect(body.orders).toHaveLength(10);
expect(body.total).toBeGreaterThan(10);
expect(body.page).toBe(1);
});
});Key Points:
- Zod schema for runtime validation AND TypeScript types
validateSchemathrows if response doesn't match- Built-in retry for transient failures
- Type-safe
bodyaccess - Note: If your project uses code-generated operations from an OpenAPI spec, see Example 8 for the preferred
operation-based overload (v3.14.0+)
Example 3: Microservice-to-Microservice Testing
Context: Test service interactions without browser - validate API contracts between services.
Implementation:
// tests/api/service-integration.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
test.describe('Service Integration', () => {
const USER_SERVICE_URL = process.env.USER_SERVICE_URL || 'http://localhost:3001';
const ORDER_SERVICE_URL = process.env.ORDER_SERVICE_URL || 'http://localhost:3002';
const INVENTORY_SERVICE_URL = process.env.INVENTORY_SERVICE_URL || 'http://localhost:3003';
test('order service should validate user exists', async ({ apiRequest }) => {
// Create user in user-service
const { body: user } = await apiRequest({
method: 'POST',
path: '/api/users',
baseUrl: USER_SERVICE_URL,
body: { name: 'Test User', email: 'test@example.com' },
});
// Create order in order-service (should validate user via user-service)
const { status, body: order } = await apiRequest({
method: 'POST',
path: '/api/orders',
baseUrl: ORDER_SERVICE_URL,
body: {
userId: user.id,
items: [{ productId: 'prod-1', quantity: 1 }],
},
});
expect(status).toBe(201);
expect(order.userId).toBe(user.id);
});
test('order service should reject invalid user', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/orders',
baseUrl: ORDER_SERVICE_URL,
body: {
userId: 'non-existent-user',
items: [{ productId: 'prod-1', quantity: 1 }],
},
});
expect(status).toBe(400);
expect(body.code).toBe('INVALID_USER');
});
test('order should decrease inventory', async ({ apiRequest, recurse }) => {
// Get initial inventory
const { body: initialInventory } = await apiRequest({
method: 'GET',
path: '/api/inventory/prod-1',
baseUrl: INVENTORY_SERVICE_URL,
});
// Create order
await apiRequest({
method: 'POST',
path: '/api/orders',
baseUrl: ORDER_SERVICE_URL,
body: {
userId: 'user-123',
items: [{ productId: 'prod-1', quantity: 2 }],
},
});
// Poll for inventory update (eventual consistency)
const { body: updatedInventory } = await recurse(
() =>
apiRequest({
method: 'GET',
path: '/api/inventory/prod-1',
baseUrl: INVENTORY_SERVICE_URL,
}),
(response) => response.body.quantity === initialInventory.quantity - 2,
{ timeout: 10000, interval: 500 },
);
expect(updatedInventory.quantity).toBe(initialInventory.quantity - 2);
});
});Key Points:
- Multiple service URLs for microservice testing
- Tests service-to-service communication
- Uses
recursefor eventual consistency - No browser needed for full integration testing
Example 4: GraphQL API Testing
Context: Test GraphQL endpoints with queries and mutations.
Implementation:
// tests/api/graphql.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
const GRAPHQL_ENDPOINT = '/graphql';
test.describe('GraphQL API', () => {
test('should query users', async ({ apiRequest }) => {
const query = `
query GetUsers($limit: Int) {
users(limit: $limit) {
id
name
email
role
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query,
variables: { limit: 10 },
},
});
expect(status).toBe(200);
expect(body.errors).toBeUndefined();
expect(body.data.users).toHaveLength(10);
expect(body.data.users[0]).toHaveProperty('id');
expect(body.data.users[0]).toHaveProperty('name');
});
test('should create user via mutation', async ({ apiRequest }) => {
const mutation = `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query: mutation,
variables: {
input: {
name: 'GraphQL User',
email: 'graphql@example.com',
},
},
},
});
expect(status).toBe(200);
expect(body.errors).toBeUndefined();
expect(body.data.createUser.id).toBeDefined();
expect(body.data.createUser.name).toBe('GraphQL User');
});
test('should handle GraphQL errors', async ({ apiRequest }) => {
const query = `
query GetUser($id: ID!) {
user(id: $id) {
id
name
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query,
variables: { id: 'non-existent' },
},
});
expect(status).toBe(200); // GraphQL returns 200 even for errors
expect(body.errors).toBeDefined();
expect(body.errors[0].message).toContain('not found');
expect(body.data.user).toBeNull();
});
test('should handle validation errors', async ({ apiRequest }) => {
const mutation = `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query: mutation,
variables: {
input: {
name: '', // Invalid: empty name
email: 'invalid-email', // Invalid: bad format
},
},
},
});
expect(status).toBe(200);
expect(body.errors).toBeDefined();
expect(body.errors[0].extensions.code).toBe('BAD_USER_INPUT');
});
});Key Points:
- GraphQL queries and mutations via POST
- Variables passed in request body
- GraphQL returns 200 even for errors (check
body.errors) - Test validation and business logic errors
Example 5: Database Seeding and Cleanup via API
Context: Use API calls to set up and tear down test data without direct database access.
Implementation:
// tests/api/with-data-setup.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
test.describe('Orders with Data Setup', () => {
let testUser: { id: string; email: string };
let testProducts: Array<{ id: string; name: string; price: number }>;
test.beforeAll(async ({ request }) => {
// Seed user via API
const userResponse = await request.post('/api/users', {
data: {
name: 'Test User',
email: `test-${Date.now()}@example.com`,
},
});
testUser = await userResponse.json();
// Seed products via API
testProducts = [];
for (const product of [
{ name: 'Widget A', price: 29.99 },
{ name: 'Widget B', price: 49.99 },
{ name: 'Widget C', price: 99.99 },
]) {
const productResponse = await request.post('/api/products', {
data: product,
});
testProducts.push(await productResponse.json());
}
});
test.afterAll(async ({ request }) => {
// Cleanup via API
if (testUser?.id) {
await request.delete(`/api/users/${testUser.id}`);
}
for (const product of testProducts) {
await request.delete(`/api/products/${product.id}`);
}
});
test('should create order with seeded data', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/orders',
body: {
userId: testUser.id,
items: [
{ productId: testProducts[0].id, quantity: 2 },
{ productId: testProducts[1].id, quantity: 1 },
],
},
});
expect(status).toBe(201);
expect(body.userId).toBe(testUser.id);
expect(body.items).toHaveLength(2);
expect(body.total).toBe(2 * 29.99 + 49.99);
});
test('should list user orders', async ({ apiRequest }) => {
// Create an order first
await apiRequest({
method: 'POST',
path: '/api/orders',
body: {
userId: testUser.id,
items: [{ productId: testProducts[2].id, quantity: 1 }],
},
});
// List orders for user
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/orders',
params: { userId: testUser.id },
});
expect(status).toBe(200);
expect(body.orders.length).toBeGreaterThanOrEqual(1);
expect(body.orders.every((o: any) => o.userId === testUser.id)).toBe(true);
});
});Key Points:
beforeAll/afterAllfor test data setup/cleanup- API-based seeding (no direct DB access needed)
- Unique emails to prevent conflicts in parallel runs
- Cleanup after all tests complete
Example 6: Background Job Testing with Recurse
Context: Test async operations like background jobs, webhooks, and eventual consistency.
Implementation:
// tests/api/background-jobs.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
test.describe('Background Jobs', () => {
test('should process export job', async ({ apiRequest, recurse }) => {
// Trigger export job
const { body: job } = await apiRequest({
method: 'POST',
path: '/api/exports',
body: {
type: 'users',
format: 'csv',
filters: { createdAfter: '2024-01-01' },
},
});
expect(job.id).toBeDefined();
expect(job.status).toBe('pending');
// Poll until job completes
const { body: completedJob } = await recurse(
() => apiRequest({ method: 'GET', path: `/api/exports/${job.id}` }),
(response) => response.body.status === 'completed',
{
timeout: 60000,
interval: 2000,
log: `Waiting for export job ${job.id} to complete`,
},
);
expect(completedJob.status).toBe('completed');
expect(completedJob.downloadUrl).toBeDefined();
expect(completedJob.recordCount).toBeGreaterThan(0);
});
test('should handle job failure gracefully', async ({ apiRequest, recurse }) => {
// Trigger job that will fail
const { body: job } = await apiRequest({
method: 'POST',
path: '/api/exports',
body: {
type: 'invalid-type', // This will cause failure
format: 'csv',
},
});
// Poll until job fails
const { body: failedJob } = await recurse(
() => apiRequest({ method: 'GET', path: `/api/exports/${job.id}` }),
(response) => ['completed', 'failed'].includes(response.body.status),
{ timeout: 30000 },
);
expect(failedJob.status).toBe('failed');
expect(failedJob.error).toBeDefined();
expect(failedJob.error.code).toBe('INVALID_EXPORT_TYPE');
});
test('should process webhook delivery', async ({ apiRequest, recurse }) => {
// Trigger action that sends webhook
const { body: order } = await apiRequest({
method: 'POST',
path: '/api/orders',
body: {
userId: 'user-123',
items: [{ productId: 'prod-1', quantity: 1 }],
webhookUrl: 'https://webhook.site/test-endpoint',
},
});
// Poll for webhook delivery status
const { body: webhookStatus } = await recurse(
() => apiRequest({ method: 'GET', path: `/api/webhooks/order/${order.id}` }),
(response) => response.body.delivered === true,
{ timeout: 30000, interval: 1000 },
);
expect(webhookStatus.delivered).toBe(true);
expect(webhookStatus.deliveredAt).toBeDefined();
expect(webhookStatus.responseStatus).toBe(200);
});
});Key Points:
recursefor polling async operations- Test both success and failure scenarios
- Configurable timeout and interval
- Log messages for debugging
Example 7: Service Authentication (No Browser)
Context: Test authenticated API endpoints using tokens directly - no browser login needed.
Implementation:
// tests/api/authenticated.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
test.describe('Authenticated API Tests', () => {
let authToken: string;
test.beforeAll(async ({ request }) => {
// Get token via API (no browser!)
const response = await request.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD,
},
});
const { token } = await response.json();
authToken = token;
});
test('should access protected endpoint with token', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/me',
headers: {
Authorization: `Bearer ${authToken}`,
},
});
expect(status).toBe(200);
expect(body.email).toBe(process.env.TEST_USER_EMAIL);
});
test('should reject request without token', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/me',
// No Authorization header
});
expect(status).toBe(401);
expect(body.code).toBe('UNAUTHORIZED');
});
test('should reject expired token', async ({ apiRequest }) => {
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'; // Expired token
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/me',
headers: {
Authorization: `Bearer ${expiredToken}`,
},
});
expect(status).toBe(401);
expect(body.code).toBe('TOKEN_EXPIRED');
});
test('should handle role-based access', async ({ apiRequest }) => {
// User token (non-admin)
const { status } = await apiRequest({
method: 'GET',
path: '/api/admin/users',
headers: {
Authorization: `Bearer ${authToken}`,
},
});
expect(status).toBe(403); // Forbidden for non-admin
});
});Key Points:
- Token obtained via API login (no browser)
- Token reused across all tests in describe block
- Test auth, expired tokens, and RBAC
- Pure API testing without UI
Example 8: Operation-Based API Testing (OpenAPI / Code Generators)
Context: When your project uses code-generated operation definitions from an OpenAPI spec, leverage the operation-based overload of apiRequest (v3.14.0+) instead of manual method/path extraction. This eliminates typeof assertions and provides full type inference for request body, response, and query parameters.
Implementation:
// tests/api/operations.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
test.describe('API Tests with Generated Operations', () => {
test('should create entity with full type safety', async ({ apiRequest }) => {
// Operation object from code generator — contains path, method, and type info
const { status, body } = await apiRequest({
operation: createEntityOp({ workspaceId }),
headers: getHeaders(workspaceId),
body: entityInput, // Compile-time typed from operation.request
});
expect(status).toBe(201);
expect(body.id).toBeDefined(); // body typed from operation.response
});
test('should list with typed query parameters', async ({ apiRequest }) => {
// query field replaces manual string concatenation
const { body } = await apiRequest({
operation: listEntitiesOp({ workspaceId }),
headers: getHeaders(workspaceId),
query: { page: 0, page_size: 10, status: 'active' },
});
expect(body.items).toHaveLength(10);
expect(body.total).toBeGreaterThan(10);
});
test('should poll async operation until complete', async ({ apiRequest, recurse }) => {
const { body: job } = await apiRequest({
operation: startJobOp({ workspaceId }),
headers: getHeaders(workspaceId),
body: { type: 'export' },
});
await recurse(
async () =>
apiRequest({
operation: getJobOp({ workspaceId, jobId: job.id }),
headers: getHeaders(workspaceId),
}),
(res) => res.body.status === 'completed',
{ timeout: 60000, interval: 2000 },
);
});
});Key Points:
operationreplacesmethod+path— mutually exclusive at compile time- Types for body, response, and query all inferred from the operation definition
- Works with any code generator using structural typing (no imports from playwright-utils needed in generator)
- Composable with
recurse,validateSchema, and all existingapiRequestfeatures - Preferred approach over
typeof operation.responsefor generated operations
API Test Configuration
Playwright Config for API-Only Tests
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/api',
// No browser needed for API tests
use: {
baseURL: process.env.API_URL || 'http://localhost:3000',
extraHTTPHeaders: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
// Faster without browser overhead
timeout: 30000,
// Run API tests in parallel
workers: 4,
fullyParallel: true,
// No screenshots/traces needed for API tests
reporter: [['html'], ['json', { outputFile: 'api-test-results.json' }]],
});Separate API Test Project
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'api',
testDir: './tests/api',
use: {
baseURL: process.env.API_URL,
},
},
{
name: 'e2e',
testDir: './tests/e2e',
use: {
baseURL: process.env.APP_URL,
...devices['Desktop Chrome'],
},
},
],
});Comparison: API Tests vs E2E Tests
| Aspect | API Test | E2E Test |
|---|---|---|
| Speed | ~50-100ms per test | ~2-10s per test |
| Stability | Very stable | More flaky (UI timing) |
| Setup | Minimal | Browser, context, page |
| Debugging | Clear request/response | DOM, screenshots, traces |
| Coverage | Service logic | User experience |
| Parallelization | Easy (stateless) | Complex (browser resources) |
| CI Cost | Low (no browser) | High (browser containers) |
Related Fragments
api-request.md- apiRequest utility detailsrecurse.md- Polling patterns for async operationsauth-session.md- Token managementcontract-testing.md- Pact contract testingtest-levels-framework.md- When to use which test leveldata-factories.md- Test data setup patterns
Anti-Patterns
DON'T use E2E for API validation:
// Bad: Testing API through UI
test('validate user creation', async ({ page }) => {
await page.goto('/admin/users');
await page.fill('#name', 'John');
await page.click('#submit');
await expect(page.getByText('User created')).toBeVisible();
});DO test APIs directly:
// Good: Direct API test
test('validate user creation', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/users',
body: { name: 'John' },
});
expect(status).toBe(201);
expect(body.id).toBeDefined();
});DON'T ignore API tests because "E2E covers it":
// Bad thinking: "Our E2E tests create users, so API is tested"
// Reality: E2E tests one happy path; API tests cover edge casesDO have dedicated API test coverage:
// Good: Explicit API test suite
test.describe('Users API', () => {
test('creates user', async ({ apiRequest }) => {
/* ... */
});
test('handles duplicate email', async ({ apiRequest }) => {
/* ... */
});
test('validates required fields', async ({ apiRequest }) => {
/* ... */
});
test('handles malformed JSON', async ({ apiRequest }) => {
/* ... */
});
test('rate limits requests', async ({ apiRequest }) => {
/* ... */
});
});Auth Session Utility
Principle
Persist authentication tokens to disk and reuse across test runs. Support multiple user identifiers, ephemeral authentication, and worker-specific accounts for parallel execution. Fetch tokens once, use everywhere. Works for both API-only tests and browser tests.
Rationale
Playwright's built-in authentication works but has limitations:
- Re-authenticates for every test run (slow)
- Single user per project setup
- No token expiration handling
- Manual session management
- Complex setup for multi-user scenarios
The auth-session utility provides:
- Token persistence: Authenticate once, reuse across runs
- Multi-user support: Different user identifiers in same test suite
- Ephemeral auth: On-the-fly user authentication without disk persistence
- Worker-specific accounts: Parallel execution with isolated user accounts
- Automatic token management: Checks validity, renews if expired
- Flexible provider pattern: Adapt to any auth system (OAuth2, JWT, custom)
- API-first design: Get tokens for API tests without browser overhead
Pattern Examples
Example 1: Basic Auth Session Setup
Context: Configure global authentication that persists across test runs.
Implementation:
// Step 1: Configure in global-setup.ts
import { authStorageInit, setAuthProvider, configureAuthSession, authGlobalInit } from '@seontechnologies/playwright-utils/auth-session';
import myCustomProvider from './auth/custom-auth-provider';
async function globalSetup() {
// Ensure storage directories exist
authStorageInit();
// Configure storage path
configureAuthSession({
authStoragePath: process.cwd() + '/playwright/auth-sessions',
debug: true,
});
// Set custom provider (HOW to authenticate)
setAuthProvider(myCustomProvider);
// Optional: pre-fetch token for default user
await authGlobalInit();
}
export default globalSetup;
// Step 2: Create auth fixture
import { test as base } from '@playwright/test';
import { createAuthFixtures, setAuthProvider } from '@seontechnologies/playwright-utils/auth-session';
import myCustomProvider from './custom-auth-provider';
// Register provider early
setAuthProvider(myCustomProvider);
export const test = base.extend(createAuthFixtures());
// Step 3: Use in tests
test('authenticated request', async ({ authToken, request }) => {
const response = await request.get('/api/protected', {
headers: { Authorization: `Bearer ${authToken}` },
});
expect(response.ok()).toBeTruthy();
});Key Points:
- Global setup runs once before all tests
- Token fetched once, reused across all tests
- Custom provider defines your auth mechanism
- Order matters: configure, then setProvider, then init
Example 2: Multi-User Authentication
Context: Testing with different user roles (admin, regular user, guest) in same test suite.
Implementation:
import { test } from '../support/auth/auth-fixture';
// Option 1: Per-test user override
test('admin actions', async ({ authToken, authOptions }) => {
// Override default user
authOptions.userIdentifier = 'admin';
const { authToken: adminToken } = await test.step('Get admin token', async () => {
return { authToken }; // Re-fetches with new identifier
});
// Use admin token
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${adminToken}` },
});
});
// Option 2: Parallel execution with different users
test.describe.parallel('multi-user tests', () => {
test('user 1 actions', async ({ authToken }) => {
// Uses default user (e.g., 'user1')
});
test('user 2 actions', async ({ authToken, authOptions }) => {
authOptions.userIdentifier = 'user2';
// Uses different token for user2
});
});Key Points:
- Override
authOptions.userIdentifierper test - Tokens cached separately per user identifier
- Parallel tests isolated with different users
- Worker-specific accounts possible
Example 3: Ephemeral User Authentication
Context: Create temporary test users that don't persist to disk (e.g., testing user creation flow).
Implementation:
import { applyUserCookiesToBrowserContext } from '@seontechnologies/playwright-utils/auth-session';
import { createTestUser } from '../utils/user-factory';
test('ephemeral user test', async ({ context, page }) => {
// Create temporary user (not persisted)
const ephemeralUser = await createTestUser({
role: 'admin',
permissions: ['delete-users'],
});
// Apply auth directly to browser context
await applyUserCookiesToBrowserContext(context, ephemeralUser);
// Page now authenticated as ephemeral user
await page.goto('/admin/users');
await expect(page.getByTestId('delete-user-btn')).toBeVisible();
// User and token cleaned up after test
});Key Points:
- No disk persistence (ephemeral)
- Apply cookies directly to context
- Useful for testing user lifecycle
- Clean up automatic when test ends
Example 4: Testing Multiple Users in Single Test
Context: Testing interactions between users (messaging, sharing, collaboration features).
Implementation:
test('user interaction', async ({ browser }) => {
// User 1 context
const user1Context = await browser.newContext({
storageState: './auth-sessions/local/user1/storage-state.json',
});
const user1Page = await user1Context.newPage();
// User 2 context
const user2Context = await browser.newContext({
storageState: './auth-sessions/local/user2/storage-state.json',
});
const user2Page = await user2Context.newPage();
// User 1 sends message
await user1Page.goto('/messages');
await user1Page.fill('#message', 'Hello from user 1');
await user1Page.click('#send');
// User 2 receives message
await user2Page.goto('/messages');
await expect(user2Page.getByText('Hello from user 1')).toBeVisible();
// Cleanup
await user1Context.close();
await user2Context.close();
});Key Points:
- Each user has separate browser context
- Reference storage state files directly
- Test real-time interactions
- Clean up contexts after test
Example 5: Worker-Specific Accounts (Parallel Testing)
Context: Running tests in parallel with isolated user accounts per worker to avoid conflicts.
Implementation:
// playwright.config.ts
export default defineConfig({
workers: 4, // 4 parallel workers
use: {
// Each worker uses different user
storageState: async ({}, use, testInfo) => {
const workerIndex = testInfo.workerIndex;
const userIdentifier = `worker-${workerIndex}`;
await use(`./auth-sessions/local/${userIdentifier}/storage-state.json`);
},
},
});
// Tests run in parallel, each worker with its own user
test('parallel test 1', async ({ page }) => {
// Worker 0 uses worker-0 account
await page.goto('/dashboard');
});
test('parallel test 2', async ({ page }) => {
// Worker 1 uses worker-1 account
await page.goto('/dashboard');
});Key Points:
- Each worker has isolated user account
- No conflicts in parallel execution
- Token management automatic per worker
- Scales to any number of workers
Example 6: Pure API Authentication (No Browser)
Context: Get auth tokens for API-only tests using auth-session disk persistence.
Implementation:
// Step 1: Create API-only auth provider (no browser needed)
// playwright/support/api-auth-provider.ts
import { type AuthProvider } from '@seontechnologies/playwright-utils/auth-session';
const apiAuthProvider: AuthProvider = {
getEnvironment: (options) => options.environment || 'local',
getUserIdentifier: (options) => options.userIdentifier || 'api-user',
extractToken: (storageState) => {
// Token stored in localStorage format for disk persistence
const tokenEntry = storageState.origins?.[0]?.localStorage?.find((item) => item.name === 'auth_token');
return tokenEntry?.value;
},
isTokenExpired: (storageState) => {
const expiryEntry = storageState.origins?.[0]?.localStorage?.find((item) => item.name === 'token_expiry');
if (!expiryEntry) return true;
return Date.now() > parseInt(expiryEntry.value, 10);
},
manageAuthToken: async (request, options) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
throw new Error('TEST_USER_EMAIL and TEST_USER_PASSWORD must be set');
}
// Pure API login - no browser!
const response = await request.post('/api/auth/login', {
data: { email, password },
});
if (!response.ok()) {
throw new Error(`Auth failed: ${response.status()}`);
}
const { token, expiresIn } = await response.json();
const expiryTime = Date.now() + expiresIn * 1000;
// Return storage state format for disk persistence
return {
cookies: [],
origins: [
{
origin: process.env.API_BASE_URL || 'http://localhost:3000',
localStorage: [
{ name: 'auth_token', value: token },
{ name: 'token_expiry', value: String(expiryTime) },
],
},
],
};
},
};
export default apiAuthProvider;
// Step 2: Create auth fixture
// playwright/support/fixtures.ts
import { test as base } from '@playwright/test';
import { createAuthFixtures, setAuthProvider } from '@seontechnologies/playwright-utils/auth-session';
import apiAuthProvider from './api-auth-provider';
setAuthProvider(apiAuthProvider);
export const test = base.extend(createAuthFixtures());
// Step 3: Use in tests - token persisted to disk!
// tests/api/authenticated-api.spec.ts
import { test } from '../support/fixtures';
import { expect } from '@playwright/test';
test('should access protected endpoint', async ({ authToken, apiRequest }) => {
// authToken is automatically loaded from disk or fetched if expired
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/me',
headers: { Authorization: `Bearer ${authToken}` },
});
expect(status).toBe(200);
});
test('should create resource with auth', async ({ authToken, apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/orders',
headers: { Authorization: `Bearer ${authToken}` },
body: { items: [{ productId: 'prod-1', quantity: 2 }] },
});
expect(status).toBe(201);
expect(body.id).toBeDefined();
});Key Points:
- Token persisted to disk (not in-memory) - survives test reruns
- Provider fetches token once, reuses until expired
- Pure API authentication - no browser context needed
authTokenfixture handles disk read/write automatically- Environment variables validated with clear error message
Example 7: Service-to-Service Authentication
Context: Test microservice authentication patterns (API keys, service tokens) with proper environment validation.
Implementation:
// tests/api/service-auth.spec.ts
import { test as base, expect } from '@playwright/test';
import { test as apiFixture } from '@seontechnologies/playwright-utils/api-request/fixtures';
import { mergeTests } from '@playwright/test';
// Validate environment variables at module load
const SERVICE_API_KEY = process.env.SERVICE_API_KEY;
const INTERNAL_SERVICE_URL = process.env.INTERNAL_SERVICE_URL;
if (!SERVICE_API_KEY) {
throw new Error('SERVICE_API_KEY environment variable is required');
}
if (!INTERNAL_SERVICE_URL) {
throw new Error('INTERNAL_SERVICE_URL environment variable is required');
}
const test = mergeTests(base, apiFixture);
test.describe('Service-to-Service Auth', () => {
test('should authenticate with API key', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/internal/health',
baseUrl: INTERNAL_SERVICE_URL,
headers: { 'X-API-Key': SERVICE_API_KEY },
});
expect(status).toBe(200);
expect(body.status).toBe('healthy');
});
test('should reject invalid API key', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/internal/health',
baseUrl: INTERNAL_SERVICE_URL,
headers: { 'X-API-Key': 'invalid-key' },
});
expect(status).toBe(401);
expect(body.code).toBe('INVALID_API_KEY');
});
test('should call downstream service with propagated auth', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/internal/aggregate-data',
baseUrl: INTERNAL_SERVICE_URL,
headers: {
'X-API-Key': SERVICE_API_KEY,
'X-Request-ID': `test-${Date.now()}`,
},
body: { sources: ['users', 'orders', 'inventory'] },
});
expect(status).toBe(200);
expect(body.aggregatedFrom).toHaveLength(3);
});
});Key Points:
- Environment variables validated at module load with clear errors
- API key authentication (simpler than OAuth - no disk persistence needed)
- Test internal/service endpoints
- Validate auth rejection scenarios
- Correlation ID for request tracing
Note: API keys are typically static secrets that don't expire, so disk persistence (auth-session) isn't needed. For rotating service tokens, use the auth-session provider pattern from Example 6.
Custom Auth Provider Pattern
Context: Adapt auth-session to your authentication system (OAuth2, JWT, SAML, custom).
Minimal provider structure:
import { type AuthProvider } from '@seontechnologies/playwright-utils/auth-session';
const myCustomProvider: AuthProvider = {
getEnvironment: (options) => options.environment || 'local',
getUserIdentifier: (options) => options.userIdentifier || 'default-user',
extractToken: (storageState) => {
// Extract token from your storage format
return storageState.cookies.find((c) => c.name === 'auth_token')?.value;
},
extractCookies: (tokenData) => {
// Convert token to cookies for browser context
return [
{
name: 'auth_token',
value: tokenData,
domain: 'example.com',
path: '/',
httpOnly: true,
secure: true,
},
];
},
isTokenExpired: (storageState) => {
// Check if token is expired
const expiresAt = storageState.cookies.find((c) => c.name === 'expires_at');
return Date.now() > parseInt(expiresAt?.value || '0');
},
manageAuthToken: async (request, options) => {
// Main token acquisition logic
// Return storage state with cookies/localStorage
},
};
export default myCustomProvider;Integration with API Request
import { test } from '@seontechnologies/playwright-utils/fixtures';
test('authenticated API call', async ({ apiRequest, authToken }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/protected',
headers: { Authorization: `Bearer ${authToken}` },
});
expect(status).toBe(200);
});Related Fragments
api-testing-patterns.md- Pure API testing patterns (no browser)overview.md- Installation and fixture compositionapi-request.md- Authenticated API requestsfixtures-composition.md- Merging auth with other utilities
Anti-Patterns
❌ Calling setAuthProvider after globalSetup:
async function globalSetup() {
configureAuthSession(...)
await authGlobalInit() // Provider not set yet!
setAuthProvider(provider) // Too late
}✅ Register provider before init:
async function globalSetup() {
authStorageInit()
configureAuthSession(...)
setAuthProvider(provider) // First
await authGlobalInit() // Then init
}❌ Hardcoding storage paths:
const storageState = './auth-sessions/local/user1/storage-state.json'; // Brittle✅ Use helper functions:
import { getTokenFilePath } from '@seontechnologies/playwright-utils/auth-session';
const tokenPath = getTokenFilePath({
environment: 'local',
userIdentifier: 'user1',
tokenFileName: 'storage-state.json',
});Burn-in Test Runner
Principle
Use smart test selection with git diff analysis to run only affected tests. Filter out irrelevant changes (configs, types, docs) and control test volume with percentage-based execution. Reduce unnecessary CI runs while maintaining reliability.
Rationale
Playwright's --only-changed triggers all affected tests:
- Config file changes trigger hundreds of tests
- Type definition changes cause full suite runs
- No volume control (all or nothing)
- Slow CI pipelines
The burn-in utility provides:
- Smart filtering: Skip patterns for irrelevant files (configs, types, docs)
- Volume control: Run percentage of affected tests after filtering
- Custom dependency analysis: More accurate than Playwright's built-in
- CI optimization: Faster pipelines without sacrificing confidence
- Process of elimination: Start with all → filter irrelevant → control volume
Pattern Examples
Example 1: Basic Burn-in Setup
Context: Run burn-in on changed files compared to main branch.
Implementation:
// Step 1: Create burn-in script
// playwright/scripts/burn-in-changed.ts
import { runBurnIn } from '@seontechnologies/playwright-utils/burn-in'
async function main() {
await runBurnIn({
configPath: 'playwright/config/.burn-in.config.ts',
baseBranch: 'main'
})
}
main().catch(console.error)
// Step 2: Create config
// playwright/config/.burn-in.config.ts
import type { BurnInConfig } from '@seontechnologies/playwright-utils/burn-in'
const config: BurnInConfig = {
// Files that never trigger tests (first filter)
skipBurnInPatterns: [
'**/config/**',
'**/*constants*',
'**/*types*',
'**/*.md',
'**/README*'
],
// Run 30% of remaining tests after skip filter
burnInTestPercentage: 0.3,
// Burn-in repetition
burnIn: {
repeatEach: 3, // Run each test 3 times
retries: 1 // Allow 1 retry
}
}
export default config
// Step 3: Add package.json script
{
"scripts": {
"test:pw:burn-in-changed": "tsx playwright/scripts/burn-in-changed.ts"
}
}Key Points:
- Two-stage filtering: skip patterns, then volume control
skipBurnInPatternseliminates irrelevant filesburnInTestPercentagecontrols test volume (0.3 = 30%)- Custom dependency analysis finds actually affected tests
Example 2: CI Integration
Context: Use burn-in in GitHub Actions for efficient CI runs.
Implementation:
# .github/workflows/burn-in.yml
name: Burn-in Changed Tests
on:
pull_request:
branches: [main]
jobs:
burn-in:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Need git history
- name: Setup Node
uses: actions/setup-node@v4
- name: Install dependencies
run: npm ci
- name: Run burn-in on changed tests
run: npm run test:pw:burn-in-changed -- --base-branch=origin/main
- name: Upload artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: burn-in-failures
path: test-results/Key Points:
fetch-depth: 0for full git history- Pass
--base-branch=origin/mainfor PR comparison - Upload artifacts only on failure
- Significantly faster than full suite
Example 3: How It Works (Process of Elimination)
Context: Understanding the filtering pipeline.
Scenario:
Git diff finds: 21 changed files
├─ Step 1: Skip patterns filter
│ Removed: 6 files (*.md, config/*, *types*)
│ Remaining: 15 files
│
├─ Step 2: Dependency analysis
│ Tests that import these 15 files: 45 tests
│
└─ Step 3: Volume control (30%)
Final tests to run: 14 tests (30% of 45)
Result: Run 14 targeted tests instead of 147 with --only-changed!Key Points:
- Three-stage pipeline: skip → analyze → control
- Custom dependency analysis (not just imports)
- Percentage applies AFTER filtering
- Dramatically reduces CI time
Example 4: Environment-Specific Configuration
Context: Different settings for local vs CI environments.
Implementation:
import type { BurnInConfig } from '@seontechnologies/playwright-utils/burn-in';
const config: BurnInConfig = {
skipBurnInPatterns: ['**/config/**', '**/*types*', '**/*.md'],
// CI runs fewer iterations, local runs more
burnInTestPercentage: process.env.CI ? 0.2 : 0.3,
burnIn: {
repeatEach: process.env.CI ? 2 : 3,
retries: process.env.CI ? 0 : 1, // No retries in CI
},
};
export default config;Key Points:
process.env.CIfor environment detection- Lower percentage in CI (20% vs 30%)
- Fewer iterations in CI (2 vs 3)
- No retries in CI (fail fast)
Example 5: Sharding Support
Context: Distribute burn-in tests across multiple CI workers.
Implementation:
// burn-in-changed.ts with sharding
import { runBurnIn } from '@seontechnologies/playwright-utils/burn-in';
async function main() {
const shardArg = process.argv.find((arg) => arg.startsWith('--shard='));
if (shardArg) {
process.env.PW_SHARD = shardArg.split('=')[1];
}
await runBurnIn({
configPath: 'playwright/config/.burn-in.config.ts',
});
}# GitHub Actions with sharding
jobs:
burn-in:
strategy:
matrix:
shard: [1/3, 2/3, 3/3]
steps:
- run: npm run test:pw:burn-in-changed -- --shard=${{ matrix.shard }}Key Points:
- Pass
--shard=1/3for parallel execution - Burn-in respects Playwright sharding
- Distribute across multiple workers
- Reduces total CI time further
Integration with CI Workflow
When setting up CI with *ci workflow, recommend burn-in for:
- Pull request validation
- Pre-merge checks
- Nightly builds (subset runs)
Related Fragments
ci-burn-in.md- Traditional burn-in patterns (10-iteration loops)selective-testing.md- Test selection strategiesoverview.md- Installation
Anti-Patterns
❌ Over-aggressive skip patterns:
skipBurnInPatterns: [
'**/*', // Skips everything!
];✅ Targeted skip patterns:
skipBurnInPatterns: ['**/config/**', '**/*types*', '**/*.md', '**/*constants*'];❌ Too low percentage (false confidence):
burnInTestPercentage: 0.05; // Only 5% - might miss issues✅ Balanced percentage:
burnInTestPercentage: 0.2; // 20% in CI, provides good coveragePact.js Utils Request Filter
Principle
Use createRequestFilter and noOpRequestFilter from @seontechnologies/pactjs-utils to inject authentication headers during provider verification. The pluggable token generator pattern prevents double-Bearer bugs and separates auth concerns from verification logic.
Rationale
Problems with manual request filters
- Express type gymnastics: Pact's
requestFilterexpects(req, res, next) => voidwith Express-compatible types — but Pact doesn't re-export these types - Double-Bearer bug: Easy to write
Authorization: Bearer Bearer ${token}when the token generator already includes the prefix - Inline complexity: Auth logic mixed with verifier config makes tests harder to read
- No-op boilerplate: Providers without auth still need a pass-through function or
undefined
Solutions
- `createRequestFilter`: Accepts
{ tokenGenerator: () => string }— generator returns raw token value synchronously, filter addsBearerprefix - `noOpRequestFilter`: Pre-built pass-through for providers without auth requirements
- Bearer prefix contract:
tokenGeneratorreturns raw value (e.g.,"abc123"), filter always adds"Bearer "— impossible to double-prefix
Pattern Examples
Example 1: Basic Auth Injection
import { buildVerifierOptions, createRequestFilter } from '@seontechnologies/pactjs-utils';
const opts = buildVerifierOptions({
provider: 'SampleMoviesAPI',
port: '3001',
includeMainAndDeployed: true,
stateHandlers: {
/* ... */
},
requestFilter: createRequestFilter({
// tokenGenerator returns raw token — filter adds "Bearer " prefix
tokenGenerator: () => 'test-auth-token-123',
}),
});
// Every request during verification will have:
// Authorization: Bearer test-auth-token-123Key Points:
tokenGeneratoris synchronous (() => string) — if you need async token fetching, resolve the token before creating the filter- Return the raw token value, NOT
"Bearer ..."— the filter adds the prefix - Filter sets
Authorizationheader on every request during verification
Example 2: Dynamic Token (Pre-resolved)
import { createRequestFilter } from '@seontechnologies/pactjs-utils';
// Since tokenGenerator is synchronous, fetch the token before creating the filter
let cachedToken: string;
async function setupRequestFilter() {
const response = await fetch('http://localhost:8080/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId: process.env.TEST_CLIENT_ID,
clientSecret: process.env.TEST_CLIENT_SECRET,
}),
});
const { access_token } = await response.json();
cachedToken = access_token;
}
const requestFilter = createRequestFilter({
tokenGenerator: () => cachedToken, // Synchronous — returns pre-fetched token
});
const opts = buildVerifierOptions({
provider: 'SecureAPI',
port: '3001',
includeMainAndDeployed: true,
stateHandlers: {
/* ... */
},
requestFilter,
});Example 3: No-Auth Provider
import { buildVerifierOptions, noOpRequestFilter } from '@seontechnologies/pactjs-utils';
// For providers that don't require authentication
const opts = buildVerifierOptions({
provider: 'PublicAPI',
port: '3001',
includeMainAndDeployed: true,
stateHandlers: {
/* ... */
},
requestFilter: noOpRequestFilter,
});
// noOpRequestFilter is equivalent to: (req, res, next) => next()Example 4: Integration with buildVerifierOptions
import { buildVerifierOptions, createRequestFilter } from '@seontechnologies/pactjs-utils';
import type { StateHandlers } from '@seontechnologies/pactjs-utils';
// Complete provider verification setup
const stateHandlers: StateHandlers = {
'user is authenticated': async () => {
// Auth state is handled by the request filter, not state handler
},
'movie exists': {
setup: async (params) => {
await db.seed({ movies: [{ id: params?.id }] });
},
teardown: async () => {
await db.clean('movies');
},
},
};
const requestFilter = createRequestFilter({
tokenGenerator: () => process.env.TEST_AUTH_TOKEN ?? 'fallback-token',
});
const opts = buildVerifierOptions({
provider: 'SampleMoviesAPI',
port: process.env.PORT ?? '3001',
includeMainAndDeployed: process.env.PACT_BREAKING_CHANGE !== 'true',
stateHandlers,
requestFilter,
});
// Run verification
await new Verifier(opts).verifyProvider();Key Points
- Bearer prefix contract:
tokenGeneratorreturns raw value → filter adds"Bearer "→ impossible to double-prefix - Synchronous only:
tokenGeneratormust returnstring(notPromise<string>) — pre-resolve async tokens before creating the filter - Separation of concerns: Auth logic in
createRequestFilter, verification logic inbuildVerifierOptions - noOpRequestFilter: Use for providers without auth — cleaner than
undefinedor inline no-op - Express compatible: The returned filter matches Pact's expected
(req, res, next) => voidsignature
Related Fragments
pactjs-utils-overview.md— installation, utility table, decision treepactjs-utils-provider-verifier.md— buildVerifierOptions integrationcontract-testing.md— foundational patterns with raw Pact.js
Anti-Patterns
Wrong: Manual Bearer prefix with double-prefix risk
// ❌ Risk of double-prefix: "Bearer Bearer token"
requestFilter: (req, res, next) => {
const token = getToken(); // What if getToken() returns "Bearer abc123"?
req.headers['authorization'] = `Bearer ${token}`;
next();
};Right: Use createRequestFilter with raw token
// ✅ tokenGenerator returns raw value — filter handles prefix
requestFilter: createRequestFilter({
tokenGenerator: () => getToken(), // Returns "abc123", not "Bearer abc123"
});Wrong: Inline auth logic in verifier config
// ❌ Auth logic mixed with verifier config
const opts: VerifierOptions = {
provider: 'my-api',
providerBaseUrl: 'http://localhost:3001',
requestFilter: (req, res, next) => {
const clientId = process.env.CLIENT_ID;
const clientSecret = process.env.CLIENT_SECRET;
// 10 lines of token fetching logic...
req.headers['authorization'] = `Bearer ${token}`;
next();
},
// ... rest of config
};Right: Separate auth into createRequestFilter
// ✅ Clean separation — async setup wraps token fetch (CommonJS-safe)
async function setupVerifierOptions() {
const token = await fetchAuthToken(); // Resolve async token BEFORE creating filter
const requestFilter = createRequestFilter({
tokenGenerator: () => token, // Synchronous — returns pre-fetched value
});
return buildVerifierOptions({
provider: 'my-api',
port: '3001',
includeMainAndDeployed: true,
requestFilter,
stateHandlers: {
/* ... */
},
});
}
// In tests/hooks, callers can await setupVerifierOptions():
// const opts = await setupVerifierOptions();_Source: @seontechnologies/pactjs-utils request-filter module, pact-js-example-provider verification tests_
Workflow Plan: testarch-test-design
Create Mode (steps-c)
- step-01-detect-mode.md
- step-02-load-context.md
- step-03-risk-and-testability.md
- step-04-coverage-plan.md
- step-05-generate-output.md
Validate Mode (steps-v)
- step-01-validate.md
Edit Mode (steps-e)
- step-01-assess.md
- step-02-apply-edit.md
Outputs
- {test_artifacts}/test-design-architecture.md (system-level)
- {test_artifacts}/test-design-qa.md (system-level)
- {test_artifacts}/test-design-epic-{epic_num}.md (epic-level)
Related skills
FAQ
What does bmad-testarch-test-design produce?
An epic-level test plan grounded in risk and testability assessment, at the system or epic level.
When should I use it?
When you say 'lets design test plan' or 'I want to create test strategy.'