
Bmad Testarch Trace
- 6 installs
- 87 repo stars
- Updated August 4, 2026
- bmad-code-org/bmad-method-test-architecture-enterprise
bmad-testarch-trace is a Claude Code skill that generates a requirements-to-tests traceability matrix and issues a PASS/CONCERNS/FAIL/WAIVED quality gate decision.
About
This skill builds a traceability matrix that maps requirements or user journeys to tests, then analyzes coverage. It produces a quality gate decision of PASS, CONCERNS, FAIL, or WAIVED. Developers use it to see which requirements are untested before a release.
- Generates a requirements-to-tests traceability matrix
- Analyzes coverage and issues a quality gate decision (PASS / CONCERNS / FAIL / WAIVED)
- Resolves a coverage oracle from requirements, specs, or synthetic journeys
Bmad Testarch Trace by the numbers
- 6 all-time installs (skills.sh)
- Ranked #1,591 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
bmad-testarch-trace capabilities & compatibility
- Capabilities
- test coverage analysis · traceability matrix · quality gate
- Use cases
- testing · code review
What bmad-testarch-trace says it does
Generate a requirements-or-journeys-to-tests traceability matrix, analyze coverage, and make a quality gate decision (PASS / CONCERNS / FAIL / WAIVED).
Generate traceability matrix and quality gate decision. Use when the user says "lets create traceability matrix" or "I want to analyze test coverage"
**Role:** You are the Master Test Architect.
npx skills add https://github.com/bmad-code-org/bmad-method-test-architecture-enterprise --skill bmad-testarch-traceAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 6 |
|---|---|
| repo stars | ★ 87 |
| Last updated | August 4, 2026 |
| Repository | bmad-code-org/bmad-method-test-architecture-enterprise ↗ |
What it does
Generate a requirements-to-tests traceability matrix, analyze coverage, and issue a quality gate decision.
Who is it for?
Developers needing to prove test coverage of requirements before a release gate
Skip if: Writing tests or reviewing test code quality
When should I use this skill?
the user says 'lets create traceability matrix' or 'I want to analyze test coverage'
What you get
A traceability matrix, coverage analysis, and a quality gate decision.
By the numbers
- 4 quality gate outcomes: PASS / CONCERNS / FAIL / WAIVED
- 4 workflow modes: Create, Resume, Validate, Edit
Files
Coverage Traceability & Quality Gate
Goal: Generate a requirements-or-journeys-to-tests traceability matrix, analyze coverage, and make a quality gate decision (PASS / CONCERNS / FAIL / WAIVED).
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-load-context.md - If R: Load
{skill-root}/steps-c/step-01b-resume.md(Create-mode continuation) - If V: Load
{skill-root}/steps-v/step-01-validate.md - If E: Load
{skill-root}/steps-e/step-01-assess.md
Create mode resolves the coverage oracle automatically in this order: formal requirements, contract/spec artifacts, resolvable external pointers (when allow_external_pointer_resolution is enabled), then synthetic journeys/requirements inferred from source (when allow_synthetic_oracle is enabled and no formal oracle exists).
Requirements Traceability & Gate Decision - Validation Checklist
Workflow: testarch-trace Purpose: Ensure complete traceability matrix with actionable gap analysis AND make deployment readiness decision (PASS/CONCERNS/FAIL/WAIVED)
This checklist covers two sequential phases:
- PHASE 1: Requirements Traceability (always executed)
- PHASE 2: Quality Gate Decision (decision fields emitted only when
allow_gate: trueand the collection is gate-eligible)
---
PHASE 1: REQUIREMENTS TRACEABILITY
Prerequisites Validation
- [ ] A coverage oracle is available or inferred (formal requirements, spec, resolvable external pointer, or synthetic journeys)
- [ ] Test suite exists (or gaps are acknowledged and documented)
- [ ] If tests are missing, recommend
*atdd(trace does not run it automatically) - [ ] Test directory path is correct (
test_dirvariable) - [ ] Story file is accessible (if using BMad mode)
- [ ] Knowledge base is loaded (test-priorities, traceability, risk-governance)
---
Context Loading
- [ ] Story file read successfully (if applicable)
- [ ] Oracle items extracted or inferred correctly
- [ ] Story ID identified (e.g., 1.3)
- [ ]
test-design.mdloaded (if available) - [ ]
tech-spec.mdloaded (if available) - [ ]
PRD.mdloaded (if available) - [ ] Relevant knowledge fragments loaded from
tea-index.csv
---
Test Discovery and Cataloging
- [ ] Tests auto-discovered using multiple strategies (test IDs, describe blocks, file paths)
- [ ] Tests categorized by level (E2E, API, Component, Unit)
- [ ] Test metadata extracted:
- [ ] Test IDs (e.g., 1.3-E2E-001)
- [ ] Describe/context blocks
- [ ] It blocks (individual test cases)
- [ ] Given-When-Then structure (if BDD)
- [ ] Priority markers (P0/P1/P2/P3)
- [ ] All relevant test files found (no tests missed due to naming conventions)
---
Criteria-to-Test Mapping
- [ ] Each oracle item mapped to tests (or marked as NONE)
- [ ] Explicit references found (test IDs, describe blocks mentioning criterion)
- [ ] Test level documented (E2E, API, Component, Unit)
- [ ] Given-When-Then narrative verified for alignment
- [ ] Traceability matrix table generated:
- [ ] Criterion ID
- [ ] Description
- [ ] Test ID
- [ ] Test File
- [ ] Test Level
- [ ] Coverage Status
---
Coverage Classification
- [ ] Coverage status classified for each criterion:
- [ ] FULL - All scenarios validated at appropriate level(s)
- [ ] PARTIAL - Some coverage but missing edge cases or levels
- [ ] NONE - No test coverage at any level
- [ ] UNIT-ONLY - Only unit tests (missing integration/E2E validation)
- [ ] INTEGRATION-ONLY - Only API/Component tests (missing unit confidence)
- [ ] Classification justifications provided
- [ ] Edge cases considered in FULL vs PARTIAL determination
---
Duplicate Coverage Detection
- [ ] Duplicate coverage checked across test levels
- [ ] Acceptable overlap identified (defense in depth for critical paths)
- [ ] Unacceptable duplication flagged (same validation at multiple levels)
- [ ] Recommendations provided for consolidation
- [ ] Selective testing principles applied
---
Gap Analysis
- [ ] Coverage gaps identified:
- [ ] Criteria with NONE status
- [ ] Criteria with PARTIAL status
- [ ] Criteria with UNIT-ONLY status
- [ ] Criteria with INTEGRATION-ONLY status
- [ ] Coverage heuristics gaps identified:
- [ ] Endpoints referenced in requirements/specs but not covered by API tests
- [ ] Auth/authz criteria missing denied/invalid path tests
- [ ] Criteria with happy-path-only coverage (missing error scenarios)
- [ ] Inferred UI journeys missing E2E/component coverage
- [ ] Inferred UI journeys missing loading/empty/error/permission state coverage
- [ ] Gaps prioritized by risk level using test-priorities framework:
- [ ] CRITICAL - P0 criteria without FULL coverage (BLOCKER)
- [ ] HIGH - P1 criteria without FULL coverage (PR blocker)
- [ ] MEDIUM - P2 criteria without FULL coverage (nightly gap)
- [ ] LOW - P3 criteria without FULL coverage (acceptable)
- [ ] Specific test recommendations provided for each gap:
- [ ] Suggested test level (E2E, API, Component, Unit)
- [ ] Test description (Given-When-Then)
- [ ] Recommended test ID (e.g., 1.3-E2E-004)
- [ ] Explanation of why test is needed
---
Coverage Metrics
- [ ] Overall coverage percentage calculated (FULL coverage / total criteria)
- [ ] P0 coverage percentage calculated
- [ ] P1 coverage percentage calculated
- [ ] P2 coverage percentage calculated (if applicable)
- [ ] Coverage by level calculated:
- [ ] E2E coverage %
- [ ] API coverage %
- [ ] Component coverage %
- [ ] Unit coverage %
---
Test Quality Verification
For each mapped test, verify:
- [ ] Explicit assertions are present (not hidden in helpers)
- [ ] Test follows Given-When-Then structure
- [ ] No hard waits or sleeps (deterministic waiting only)
- [ ] Self-cleaning (test cleans up its data)
- [ ] File size < 300 lines
- [ ] Test duration < 90 seconds
Quality issues flagged:
- [ ] BLOCKER issues identified (missing assertions, hard waits, flaky patterns)
- [ ] WARNING issues identified (large files, slow tests, unclear structure)
- [ ] INFO issues identified (style inconsistencies, missing documentation)
Knowledge fragments referenced:
- [ ]
test-quality.mdfor Definition of Done - [ ]
fixture-architecture.mdfor self-cleaning patterns - [ ]
network-first.mdfor Playwright best practices - [ ]
data-factories.mdfor test data patterns
---
Phase 1 Deliverables Generated
Traceability Matrix Markdown
- [ ] File created at
{test_artifacts}/traceability-matrix.md - [ ] Template from
trace-template.mdused - [ ] Full mapping table included
- [ ] Coverage status section included
- [ ] Gap analysis section included
- [ ] Quality assessment section included
- [ ] Recommendations section included
Machine-Readable JSON Output
- [ ]
e2e-trace-summary.jsonwritten to{e2e_trace_summary_output} - [ ] JSON is valid and parseable
- [ ]
schema_versionfield present - [ ]
repo,collection_mode,collection_status,inventory_basis, andsource_shafields populated - [ ]
gate_basispopulated (priority_thresholdswhen gate-eligible,noneotherwise) - [ ]
snapshot_atreplaces the oldgenerated_attimestamp field - [ ] Oracle metadata populated (
resolution_mode,confidence,sources,external_pointer_status,synthetic) - [ ]
target.typeandtarget.ididentify the evaluated story / epic / release / hotfix - [ ]
gate_statuspopulated only whenallow_gate: trueandcollection_statusisCOLLECTED - [ ]
coverage.inventoryincludescovered,total, andpct - [ ]
coverage.priority_breakdownincludes P0–P3 andcoverage.by_levelincludes e2e/api/component/unit/other - [ ]
testscounts are deduplicated from unique discovered tests (no per-requirement double counting) - [ ]
risk_summarycounts match Phase 1 gap analysis - [ ]
heuristicsfields populated (endpoint_gaps,auth_negative_path_status,error_path_status) - [ ] UI heuristic fields populated when using a source-derived oracle (
ui_journey_status,ui_state_status) - [ ]
gate_criteriathresholds and actuals match gate decision - [ ]
blockersarray present (may be empty) - [ ]
recommendationsarray present (may be empty) - [ ]
links.trace_report_pathpoints totraceability-matrix.md - [ ]
links.trace_report_url,links.artifact_url, andlinks.journey_evidence_urlfields present (may be empty) - [ ]
gate-decision.jsonwritten to{gate_decision_output}when gate-eligible - [ ]
gate-decision.jsoncontainsevaluated_at,gate_basis,gate_status,rationale, and per-criterion status fields
Updated Story File (if enabled)
- [ ] "Traceability" section added to story markdown
- [ ] Link to traceability matrix included
- [ ] Coverage summary included
---
Phase 1 Quality Assurance
Accuracy Checks
- [ ] All oracle items accounted for (none skipped)
- [ ] Test IDs correctly formatted (e.g., 1.3-E2E-001)
- [ ] File paths are correct and accessible
- [ ] Coverage percentages calculated correctly
- [ ] No false positives (tests incorrectly mapped to criteria)
- [ ] No false negatives (existing tests missed in mapping)
Completeness Checks
- [ ] All test levels considered (E2E, API, Component, Unit)
- [ ] All priorities considered (P0, P1, P2, P3)
- [ ] All coverage statuses used appropriately (FULL, PARTIAL, NONE, UNIT-ONLY, INTEGRATION-ONLY)
- [ ] All gaps have recommendations
- [ ] All quality issues have severity and remediation guidance
Actionability Checks
- [ ] Recommendations are specific (not generic)
- [ ] Test IDs suggested for new tests
- [ ] Given-When-Then provided for recommended tests
- [ ] Impact explained for each gap
- [ ] Priorities clear (CRITICAL, HIGH, MEDIUM, LOW)
---
Phase 1 Documentation
- [ ] Traceability matrix is readable and well-formatted
- [ ] Tables render correctly in markdown
- [ ] Code blocks have proper syntax highlighting
- [ ] Links are valid and accessible
- [ ] Recommendations are clear and prioritized
---
PHASE 2: QUALITY GATE DECISION
Note: Phase 2 always emits e2e-trace-summary.json; gate decision fields are populated only when allow_gate: true and collection_status resolves to COLLECTED.
---
Prerequisites
Evidence Gathering
- [ ] Test execution results obtained (CI/CD pipeline, test framework reports)
- [ ] Story/epic/release file identified and read
- [ ] Test design document discovered or explicitly provided (if available)
- [ ] Traceability matrix discovered or explicitly provided (available from Phase 1)
- [ ] NFR assessment discovered or explicitly provided (if available)
- [ ] Code coverage report discovered or explicitly provided (if available)
- [ ] Burn-in results discovered or explicitly provided (if available)
Evidence Validation
- [ ] Evidence freshness validated (warn if >7 days old, recommend re-running workflows)
- [ ] All required assessments available or user acknowledged gaps
- [ ] Test results are complete (not partial or interrupted runs)
- [ ] Test results match current codebase (not from outdated branch)
Knowledge Base Loading
- [ ]
risk-governance.mdloaded successfully - [ ]
probability-impact.mdloaded successfully - [ ]
test-quality.mdloaded successfully - [ ]
test-priorities.mdloaded successfully - [ ]
ci-burn-in.mdloaded (if burn-in results available)
---
Process Steps
Step 1: Context Loading
- [ ] Gate type identified (story/epic/release/hotfix)
- [ ] Target ID extracted (story_id, epic_num, or release_version)
- [ ] Decision thresholds loaded from workflow variables
- [ ] Risk tolerance configuration loaded
- [ ] Waiver policy loaded
Step 2: Evidence Parsing
Test Results:
- [ ] Total test count extracted
- [ ] Passed test count extracted
- [ ] Failed test count extracted
- [ ] Skipped test count extracted
- [ ] Test duration extracted
- [ ] P0 test pass rate calculated
- [ ] P1 test pass rate calculated
- [ ] Overall test pass rate calculated
Quality Assessments:
- [ ] P0/P1/P2/P3 scenarios extracted from test-design.md (if available)
- [ ] Risk scores extracted from test-design.md (if available)
- [ ] Coverage percentages extracted from traceability-matrix.md (available from Phase 1)
- [ ] Coverage gaps extracted from traceability-matrix.md (available from Phase 1)
- [ ] NFR status extracted from nfr-assessment.md (if available)
- [ ] Security issues count extracted from nfr-assessment.md (if available)
Code Coverage:
- [ ] Line coverage percentage extracted (if available)
- [ ] Branch coverage percentage extracted (if available)
- [ ] Function coverage percentage extracted (if available)
- [ ] Critical path coverage validated (if available)
Burn-in Results:
- [ ] Burn-in iterations count extracted (if available)
- [ ] Flaky tests count extracted (if available)
- [ ] Stability score calculated (if available)
Step 3: Decision Rules Application
P0 Criteria Evaluation:
- [ ] P0 test pass rate evaluated (must be 100%)
- [ ] P0 oracle-item coverage evaluated (must be 100%)
- [ ] Security issues count evaluated (must be 0)
- [ ] Critical NFR failures evaluated (must be 0)
- [ ] Flaky tests evaluated (must be 0 if burn-in enabled)
- [ ] P0 decision recorded: PASS or FAIL
P1 Criteria Evaluation:
- [ ] P1 test pass rate evaluated (threshold: min_p1_pass_rate)
- [ ] P1 oracle-item coverage evaluated (PASS >=90%, CONCERNS 80-89%, FAIL <80%)
- [ ] Overall test pass rate evaluated (threshold: min_overall_pass_rate)
- [ ] Overall oracle coverage evaluated (threshold: >=80%)
- [ ] Code coverage considered if available (informational unless explicitly required by policy)
- [ ] P1 decision recorded: PASS or CONCERNS
P2/P3 Criteria Evaluation:
- [ ] P2 failures tracked (informational, don't block if allow_p2_failures: true)
- [ ] P3 failures tracked (informational, don't block if allow_p3_failures: true)
- [ ] Residual risks documented
Final Decision:
- [ ] Decision determined: PASS / CONCERNS / FAIL / WAIVED
- [ ] Decision rationale documented
- [ ] Decision is deterministic (follows rules, not arbitrary)
Step 4: Documentation
Gate Decision Document Created:
- [ ] Story/epic/release info section complete (ID, title, description, links)
- [ ] Decision clearly stated (PASS / CONCERNS / FAIL / WAIVED)
- [ ] Decision date recorded
- [ ] Evaluator recorded (user or agent name)
Evidence Summary Documented:
- [ ] Test results summary complete (total, passed, failed, pass rates)
- [ ] Coverage summary complete (P0/P1 criteria, code coverage)
- [ ] NFR validation summary complete (security, performance, reliability, maintainability)
- [ ] Flakiness summary complete (burn-in iterations, flaky test count)
Rationale Documented:
- [ ] Decision rationale clearly explained
- [ ] Key evidence highlighted
- [ ] Assumptions and caveats noted (if any)
Residual Risks Documented (if CONCERNS or WAIVED):
- [ ] Unresolved P1/P2 issues listed
- [ ] Probability × impact estimated for each risk
- [ ] Mitigations or workarounds described
Waivers Documented (if WAIVED):
- [ ] Waiver reason documented (business justification)
- [ ] Waiver approver documented (name, role)
- [ ] Waiver expiry date documented
- [ ] Remediation plan documented (fix in next release, due date)
- [ ] Monitoring plan documented
Critical Issues Documented (if FAIL or CONCERNS):
- [ ] Top 5-10 critical issues listed
- [ ] Priority assigned to each issue (P0/P1/P2)
- [ ] Owner assigned to each issue
- [ ] Due date assigned to each issue
Recommendations Documented:
- [ ] Next steps clearly stated for decision type
- [ ] Deployment recommendation provided
- [ ] Monitoring recommendations provided (if applicable)
- [ ] Remediation recommendations provided (if applicable)
Step 5: Status Updates and Notifications
Gate YAML Created:
- [ ] Gate YAML snippet generated with decision and criteria
- [ ] Evidence references included in YAML
- [ ] Next steps included in YAML
- [ ] YAML file saved to output folder
Stakeholder Notification Generated:
- [ ] Notification subject line created
- [ ] Notification body created with summary
- [ ] Recipients identified (PM, SM, DEV lead, stakeholders)
- [ ] Notification ready for delivery (if notify_stakeholders: true)
Outputs Saved:
- [ ] Gate decision document saved to
{outputFile} - [ ]
e2e-trace-summary.jsonsaved to{e2e_trace_summary_output}(always) - [ ]
gate-decision.jsonsaved to{gate_decision_output}(when gate-eligible) - [ ] All outputs are valid and readable
---
Phase 2 Output Validation
Gate Decision Document
Completeness:
- [ ] All required sections present (info, decision, evidence, rationale, next steps)
- [ ] No placeholder text or TODOs left in document
- [ ] All evidence references are accurate and complete
- [ ] All links to artifacts are valid
Accuracy:
- [ ] Decision matches applied criteria rules
- [ ] Test results match CI/CD pipeline output
- [ ] Coverage percentages match reports
- [ ] NFR status matches assessment document
- [ ] No contradictions or inconsistencies
Clarity:
- [ ] Decision rationale is clear and unambiguous
- [ ] Technical jargon is explained or avoided
- [ ] Stakeholders can understand next steps
- [ ] Recommendations are actionable
Gate YAML
Format:
- [ ] YAML is valid (no syntax errors)
- [ ] All required fields present (target, decision, date, evaluator, criteria, evidence)
- [ ] Field values are correct data types (numbers, strings, dates)
Content:
- [ ] Criteria values match decision document
- [ ] Evidence references are accurate
- [ ] Next steps align with decision type
---
Phase 2 Quality Checks
Decision Integrity
- [ ] Decision is deterministic (follows rules, not arbitrary)
- [ ] P0 failures result in FAIL decision (unless waived)
- [ ] Security issues result in FAIL decision (unless waived - but should never be waived)
- [ ] Waivers have business justification and approver (if WAIVED)
- [ ] Residual risks are documented (if CONCERNS or WAIVED)
Evidence-Based
- [ ] Decision is based on actual test results (not guesses)
- [ ] All claims are supported by evidence
- [ ] No assumptions without documentation
- [ ] Evidence sources are cited (CI run IDs, report URLs)
Transparency
- [ ] Decision rationale is transparent and auditable
- [ ] Criteria evaluation is documented step-by-step
- [ ] Any deviations from standard process are explained
- [ ] Waiver justifications are clear (if applicable)
Consistency
- [ ] Decision aligns with risk-governance knowledge fragment
- [ ] Priority framework (P0/P1/P2/P3) applied consistently
- [ ] Terminology consistent with test-quality knowledge fragment
- [ ] Decision matrix followed correctly
---
Phase 2 Integration Points
CI/CD Pipeline
- [ ] Gate YAML is CI/CD-compatible
- [ ] YAML can be parsed by pipeline automation
- [ ] Decision can be used to block/allow deployments
- [ ] Evidence references are accessible to pipeline
Stakeholders
- [ ] Notification message is clear and actionable
- [ ] Decision is explained in non-technical terms
- [ ] Next steps are specific and time-bound
- [ ] Recipients are appropriate for decision type
---
Phase 2 Compliance and Audit
Audit Trail
- [ ] Decision date and time recorded
- [ ] Evaluator identified (user or agent)
- [ ] All evidence sources cited
- [ ] Decision criteria documented
- [ ] Rationale clearly explained
Traceability
- [ ] Gate decision traceable to story/epic/release
- [ ] Evidence traceable to specific test runs
- [ ] Assessments traceable to workflows that created them
- [ ] Waiver traceable to approver (if applicable)
Compliance
- [ ] Security requirements validated (no unresolved vulnerabilities)
- [ ] Quality standards met or waived with justification
- [ ] Regulatory requirements addressed (if applicable)
- [ ] Documentation sufficient for external audit
---
Phase 2 Edge Cases and Exceptions
Missing Evidence
- [ ] If test-design.md missing, decision still possible with test results + trace
- [ ] If traceability-matrix.md missing, decision still possible with test results (but Phase 1 should provide it)
- [ ] If nfr-assessment.md missing, NFR validation marked as NOT ASSESSED
- [ ] If code coverage missing, coverage criterion marked as NOT ASSESSED
- [ ] User acknowledged gaps in evidence or provided alternative proof
Stale Evidence
- [ ] Evidence freshness checked (if validate_evidence_freshness: true)
- [ ] Warnings issued for assessments >7 days old
- [ ] User acknowledged stale evidence or re-ran workflows
- [ ] Decision document notes any stale evidence used
Conflicting Evidence
- [ ] Conflicts between test results and assessments resolved
- [ ] Most recent/authoritative source identified
- [ ] Conflict resolution documented in decision rationale
- [ ] User consulted if conflict cannot be resolved
Waiver Scenarios
- [ ] Waiver only used for FAIL decision (not PASS or CONCERNS)
- [ ] Waiver has business justification (not technical convenience)
- [ ] Waiver has named approver with authority (VP/CTO/PO)
- [ ] Waiver has expiry date (does NOT apply to future releases)
- [ ] Waiver has remediation plan with concrete due date
- [ ] Security vulnerabilities are NOT waived (enforced)
---
FINAL VALIDATION (Both Phases)
Non-Prescriptive Validation
- [ ] Traceability format adapted to team needs (not rigid template)
- [ ] Examples are minimal and focused on patterns
- [ ] Teams can extend with custom classifications
- [ ] Integration with external systems supported (JIRA, Azure DevOps)
- [ ] Compliance requirements considered (if applicable)
---
Documentation and Communication
- [ ] All documents are readable and well-formatted
- [ ] Tables render correctly in markdown
- [ ] Code blocks have proper syntax highlighting
- [ ] Links are valid and accessible
- [ ] Recommendations are clear and prioritized
- [ ] Gate decision is prominent and unambiguous (Phase 2)
---
Final Validation
Phase 1 (Traceability):
- [ ] All prerequisites met
- [ ] All oracle items mapped or gaps documented
- [ ] P0 coverage is 100% OR documented as BLOCKER
- [ ] Gap analysis is complete and prioritized
- [ ] Test quality issues identified and flagged
- [ ] Deliverables generated and saved
Phase 2 (Gate Decision):
- [ ] All quality evidence gathered
- [ ] Decision criteria applied correctly
- [ ] Decision rationale documented
- [ ]
e2e-trace-summary.jsonwritten and valid JSON - [ ]
gate-decision.jsonwritten when gate-eligible - [ ] Status file updated (if enabled)
- [ ] Stakeholders notified (if enabled)
Workflow Complete:
- [ ] Phase 1 completed successfully
- [ ] Phase 2 completed successfully (if enabled)
- [ ] All outputs validated and saved
- [ ] Ready to proceed based on gate decision
---
Sign-Off
Phase 1 - Traceability Status:
- [ ] ✅ PASS - All quality gates met, no critical gaps
- [ ] ⚠️ WARN - P1 gaps exist, address before PR merge
- [ ] ❌ FAIL - P0 gaps exist, BLOCKER for release
Phase 2 - Gate Decision Status (if enabled):
- [ ] ✅ PASS - Deploy to production
- [ ] ⚠️ CONCERNS - Deploy with monitoring
- [ ] ❌ FAIL - Block deployment, fix issues
- [ ] 🔓 WAIVED - Deploy with business approval and remediation plan
Next Actions:
- If PASS (both phases): Proceed to deployment
- If WARN/CONCERNS: Address gaps/issues, proceed with monitoring
- If FAIL (either phase): Run
*atddfor missing tests, fix issues, re-run*trace - If WAIVED: Deploy with approved waiver, schedule remediation
---
Notes
Record any issues, deviations, or important observations during workflow execution:
- Phase 1 Issues: [Note any traceability mapping challenges, missing tests, quality concerns]
- Phase 2 Issues: [Note any missing, stale, or conflicting evidence]
- Decision Rationale: [Document any nuanced reasoning or edge cases]
- Waiver Details: [Document waiver negotiations or approvals]
- Follow-up Actions: [List any actions required after gate decision]
---
<!-- Powered by BMAD-CORE™ -->
# DO NOT EDIT -- overwritten on every update.
#
# Workflow customization surface for bmad-testarch-trace. 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 = ""
Coverage Traceability & Quality Gate
Workflow: bmad-testarch-trace Version: 5.0 (Step-File Architecture)
---
Overview
Create a coverage-oracle-to-tests traceability matrix, analyze coverage gaps, and optionally make a gate decision (PASS/CONCERNS/FAIL/WAIVED) based on evidence.
When formal requirements are unavailable, the workflow should resolve the best available coverage oracle automatically: specs/contracts first, external pointers second, and synthetic journeys/requirements inferred from source as the final brownfield fallback.
---
WORKFLOW ARCHITECTURE
This workflow uses step-file architecture:
- Micro-file Design: Each step is self-contained
- JIT Loading: Only the current step file is in memory
- Sequential Enforcement: Execute steps in order
---
INITIALIZATION SEQUENCE
1. Configuration Loading
From workflow.yaml, resolve:
config_source,test_artifacts,user_name,communication_language,document_output_language,datetest_dir,source_dir,coverage_levels,gate_type,decision_mode
2. First Step
Load, read completely, and execute: {skill-root}/steps-c/step-01-load-context.md
3. Resume Support
If the user selects Resume mode, load, read completely, and execute: {skill-root}/steps-c/step-01b-resume.md
This checks the output document for progress tracking frontmatter and routes to the next incomplete step.
ADR Quality Readiness Checklist
Purpose: Standardized 8-category, 29-criteria framework for evaluating system testability and NFR compliance during architecture review (Phase 3) and NFR assessment.
When to Use:
- System-level test design (Phase 3): Identify testability gaps in architecture
- NFR assessment workflow: Structured evaluation with evidence
- Gate decisions: Quantifiable criteria (X/29 met = PASS/CONCERNS/FAIL)
How to Use:
1. For each criterion, assess status: ✅ Covered / ⚠️ Gap / ⬜ Not Assessed 2. Document gap description if ⚠️ 3. Describe risk if criterion unmet 4. Map to test scenarios (what tests validate this criterion)
---
1. Testability & Automation
Question: Can we verify this effectively without manual toil?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 1.1 | Isolation: Can the service be tested with all downstream dependencies (DBs, APIs, Queues) mocked or stubbed? | Flaky tests; inability to test in isolation | P1: Service runs with mocked DB, P1: Service runs with mocked API, P2: Integration tests with real deps |
| 1.2 | Headless Interaction: Is 100% of the business logic accessible via API (REST/gRPC) to bypass the UI for testing? | Slow, brittle UI-based automation | P0: All core logic callable via API, P1: No UI dependency for critical paths |
| 1.3 | State Control: Do we have "Seeding APIs" or scripts to inject specific data states (e.g., "User with expired subscription") instantly? | Long setup times; inability to test edge cases | P0: Seed baseline data, P0: Inject edge case data states, P1: Cleanup after tests |
| 1.4 | Sample Requests: Are there valid and invalid cURL/JSON sample requests provided in the design doc for QA to build upon? | Ambiguity on how to consume the service | P1: Valid request succeeds, P1: Invalid request fails with clear error |
Common Gaps:
- No mock endpoints for external services (Athena, Milvus, third-party APIs)
- Business logic tightly coupled to UI (requires E2E tests for everything)
- No seeding APIs (manual database setup required)
- ADR has architecture diagrams but no sample API requests
Mitigation Examples:
- 1.1 (Isolation): Provide mock endpoints, dependency injection, interface abstractions
- 1.2 (Headless): Expose all business logic via REST/GraphQL APIs
- 1.3 (State Control): Implement
/api/test-dataseeding endpoints (dev/staging only) - 1.4 (Sample Requests): Add "Example API Calls" section to ADR with cURL commands
---
2. Test Data Strategy
Question: How do we fuel our tests safely?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 2.1 | Segregation: Does the design support multi-tenancy or specific headers (e.g., x-test-user) to keep test data out of prod metrics? | Skewed business analytics; data pollution | P0: Multi-tenant isolation (customer A ≠ customer B), P1: Test data excluded from prod metrics |
| 2.2 | Generation: Can we use synthetic data, or do we rely on scrubbing production data (GDPR/PII risk)? | Privacy violations; dependency on stale data | P0: Faker-based synthetic data, P1: No production data in tests |
| 2.3 | Teardown: Is there a mechanism to "reset" the environment or clean up data after destructive tests? | Environment rot; subsequent test failures | P0: Automated cleanup after tests, P2: Environment reset script |
Common Gaps:
- No
customer_idscoping in queries (cross-tenant data leakage risk) - Reliance on production data dumps (GDPR/PII violations)
- No cleanup mechanism (tests leave data behind, polluting environment)
Mitigation Examples:
- 2.1 (Segregation): Enforce
customer_idin all queries, add test-specific headers - 2.2 (Generation): Use Faker library, create synthetic data generators, prohibit prod dumps
- 2.3 (Teardown): Auto-cleanup hooks in test framework, isolated test customer IDs
---
3. Scalability & Availability
Question: Can it grow, and will it stay up?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 3.1 | Statelessness: Is the service stateless? If not, how is session state replicated across instances? | Inability to auto-scale horizontally | P1: Service restart mid-request → no data loss, P2: Horizontal scaling under load |
| 3.2 | Bottlenecks: Have we identified the weakest link (e.g., database connections, API rate limits) under load? | System crash during peak traffic | P2: Load test identifies bottleneck, P2: Connection pool exhaustion handled |
| 3.3 | SLA Definitions: What is the target Availability (e.g., 99.9%) and does the architecture support redundancy to meet it? | Breach of contract; customer churn | P1: Availability target defined, P2: Redundancy validated (multi-region/zone) |
| 3.4 | Circuit Breakers: If a dependency fails, does this service fail fast or hang? | Cascading failures taking down the whole platform | P1: Circuit breaker opens on 5 failures, P1: Auto-reset after recovery, P2: Timeout prevents hanging |
Common Gaps:
- Stateful session management (can't scale horizontally)
- No load testing, bottlenecks unknown
- SLA undefined or unrealistic (99.99% without redundancy)
- No circuit breakers (cascading failures)
Mitigation Examples:
- 3.1 (Statelessness): Externalize session to Redis/JWT, design for horizontal scaling
- 3.2 (Bottlenecks): Load test with k6, monitor connection pools, identify weak links
- 3.3 (SLA): Define realistic SLA (99.9% = 43 min/month downtime), add redundancy
- 3.4 (Circuit Breakers): Implement circuit breakers (Hystrix pattern), fail fast on errors
---
4. Disaster Recovery (DR)
Question: What happens when the worst-case scenario occurs?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 4.1 | RTO/RPO: What is the Recovery Time Objective (how long to restore) and Recovery Point Objective (max data loss)? | Extended outages; data loss liability | P2: RTO defined and tested, P2: RPO validated (backup frequency) |
| 4.2 | Failover: Is region/zone failover automated or manual? Has it been practiced? | "Heroics" required during outages; human error | P2: Automated failover works, P2: Manual failover documented and tested |
| 4.3 | Backups: Are backups immutable and tested for restoration integrity? | Ransomware vulnerability; corrupted backups | P2: Backup restore succeeds, P2: Backup immutability validated |
Common Gaps:
- RTO/RPO undefined (no recovery plan)
- Failover never tested (manual process, prone to errors)
- Backups exist but restoration never validated (untested backups = no backups)
Mitigation Examples:
- 4.1 (RTO/RPO): Define RTO (e.g., 4 hours) and RPO (e.g., 1 hour), document recovery procedures
- 4.2 (Failover): Automate multi-region failover, practice failover drills quarterly
- 4.3 (Backups): Implement immutable backups (S3 versioning), test restore monthly
---
5. Security
Question: Is the design safe by default?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 5.1 | AuthN/AuthZ: Does it implement standard protocols (OAuth2/OIDC)? Are permissions granular (Least Privilege)? | Unauthorized access; data leaks | P0: OAuth flow works, P0: Expired token rejected, P0: Insufficient permissions return 403, P1: Scope enforcement |
| 5.2 | Encryption: Is data encrypted at rest (DB) and in transit (TLS)? | Compliance violations; data theft | P1: Milvus data-at-rest encrypted, P1: TLS 1.2+ enforced, P2: Certificate rotation works |
| 5.3 | Secrets: Are API keys/passwords stored in a Vault (not in code or config files)? | Credentials leaked in git history | P1: No hardcoded secrets in code, P1: Secrets loaded from AWS Secrets Manager |
| 5.4 | Input Validation: Are inputs sanitized against Injection attacks (SQLi, XSS)? | System compromise via malicious payloads | P1: SQL injection sanitized, P1: XSS escaped, P2: Command injection prevented |
Common Gaps:
- Weak authentication (no OAuth, hardcoded API keys)
- No encryption at rest (plaintext in database)
- Secrets in git (API keys, passwords in config files)
- No input validation (vulnerable to SQLi, XSS, command injection)
Mitigation Examples:
- 5.1 (AuthN/AuthZ): Implement OAuth 2.1/OIDC, enforce least privilege, validate scopes
- 5.2 (Encryption): Enable TDE (Transparent Data Encryption), enforce TLS 1.2+
- 5.3 (Secrets): Migrate to AWS Secrets Manager/Vault, scan git history for leaks
- 5.4 (Input Validation): Sanitize all inputs, use parameterized queries, escape outputs
---
6. Monitorability, Debuggability & Manageability
Question: Can we operate and fix this in production?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 6.1 | Tracing: Does the service propagate W3C Trace Context / Correlation IDs for distributed tracing? | Impossible to debug errors across microservices | P2: W3C Trace Context propagated (EventBridge → Lambda → Service), P2: Correlation ID in all logs |
| 6.2 | Logs: Can log levels (INFO vs DEBUG) be toggled dynamically without a redeploy? | Inability to diagnose issues in real-time | P2: Log level toggle works without redeploy, P2: Logs structured (JSON format) |
| 6.3 | Metrics: Does it expose RED metrics (Rate, Errors, Duration) for Prometheus/Datadog? | Flying blind regarding system health | P2: /metrics endpoint exposes RED metrics, P2: Prometheus/Datadog scrapes successfully |
| 6.4 | Config: Is configuration externalized? Can we change behavior without a code build? | Rigid system; full deploys needed for minor tweaks | P2: Config change without code build, P2: Feature flags toggle behavior |
Common Gaps:
- No distributed tracing (can't debug across microservices)
- Static log levels (requires redeploy to enable DEBUG)
- No metrics endpoint (blind to system health)
- Configuration hardcoded (requires full deploy for minor changes)
Mitigation Examples:
- 6.1 (Tracing): Implement W3C Trace Context, add correlation IDs to all logs
- 6.2 (Logs): Use dynamic log levels (environment variable), structured logging (JSON)
- 6.3 (Metrics): Expose /metrics endpoint, track RED metrics (Rate, Errors, Duration)
- 6.4 (Config): Externalize config (AWS SSM/AppConfig), use feature flags (LaunchDarkly)
---
7. QoS (Quality of Service) & QoE (Quality of Experience)
Question: How does it perform, and how does it feel?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 7.1 | Latency (QoS): What are the P95 and P99 latency targets? | Slow API responses affecting throughput | P3: P95 latency <Xs (load test), P3: P99 latency <Ys (load test) |
| 7.2 | Throttling (QoS): Is there Rate Limiting to prevent "noisy neighbors" or DDoS? | Service degradation for all users due to one bad actor | P2: Rate limiting enforced, P2: 429 returned when limit exceeded |
| 7.3 | Perceived Performance (QoE): Does the UI show optimistic updates or skeletons while loading? | App feels sluggish to the user | P2: Skeleton/spinner shown while loading (E2E), P2: Optimistic updates (E2E) |
| 7.4 | Degradation (QoE): If the service is slow, does it show a friendly message or a raw stack trace? | Poor user trust; frustration | P2: Friendly error message shown (not stack trace), P1: Error boundary catches exceptions (E2E) |
Common Gaps:
- Latency targets undefined (no SLOs)
- No rate limiting (vulnerable to DDoS, noisy neighbors)
- Poor perceived performance (blank screen while loading)
- Raw error messages (stack traces exposed to users)
Mitigation Examples:
- 7.1 (Latency): Define SLOs (P95 <2s, P99 <5s), load test to validate
- 7.2 (Throttling): Implement rate limiting (per-user, per-IP), return 429 with Retry-After
- 7.3 (Perceived Performance): Add skeleton screens, optimistic updates, progressive loading
- 7.4 (Degradation): Implement error boundaries, show friendly messages, log stack traces server-side
---
8. Deployability
Question: How easily can we ship this?
| # | Criterion | Risk if Unmet | Typical Test Scenarios (P0-P2) |
|---|---|---|---|
| 8.1 | Zero Downtime: Does the design support Blue/Green or Canary deployments? | Maintenance windows required (downtime) | P2: Blue/Green deployment works, P2: Canary deployment gradual rollout |
| 8.2 | Backward Compatibility: Can we deploy the DB changes separately from the Code changes? | "Lock-step" deployments; high risk of breaking changes | P2: DB migration before code deploy, P2: Code handles old and new schema |
| 8.3 | Rollback: Is there an automated rollback trigger if Health Checks fail post-deploy? | Prolonged outages after a bad deploy | P2: Health check fails → automated rollback, P2: Rollback completes within RTO |
Common Gaps:
- No zero-downtime strategy (requires maintenance window)
- Tight coupling between DB and code (lock-step deployments)
- No automated rollback (manual intervention required)
Mitigation Examples:
- 8.1 (Zero Downtime): Implement Blue/Green or Canary deployments, use feature flags
- 8.2 (Backward Compatibility): Separate DB migrations from code deploys, support N-1 schema
- 8.3 (Rollback): Automate rollback on health check failures, test rollback procedures
---
Usage in Test Design Workflow
System-Level Mode (Phase 3):
In test-design-architecture.md:
- Add "NFR Testability Requirements" section after ASRs
- Use 8 categories with checkboxes (29 criteria)
- For each criterion: Status (⬜ Not Assessed, ⚠️ Gap, ✅ Covered), Gap description, Risk if unmet
- Example:
## NFR Testability Requirements
**Based on ADR Quality Readiness Checklist**
### 1. Testability & Automation
Can we verify this effectively without manual toil?
| Criterion | Status | Gap/Requirement | Risk if Unmet |
| ---------------------------------------------------------------- | --------------- | ------------------------------------ | --------------------------------------- |
| ⬜ Isolation: Can service be tested with downstream deps mocked? | ⚠️ Gap | No mock endpoints for Athena queries | Flaky tests; can't test in isolation |
| ⬜ Headless: 100% business logic accessible via API? | ✅ Covered | All MCP tools are REST APIs | N/A |
| ⬜ State Control: Seeding APIs to inject data states? | ⚠️ Gap | Need `/api/test-data` endpoints | Long setup times; can't test edge cases |
| ⬜ Sample Requests: Valid/invalid cURL/JSON samples provided? | ⬜ Not Assessed | Pending ADR Tool schemas finalized | Ambiguity on how to consume service |
**Actions Required:**
- [ ] Backend: Implement mock endpoints for Athena (R-002 blocker)
- [ ] Backend: Implement `/api/test-data` seeding APIs (R-002 blocker)
- [ ] PM: Finalize ADR Tool schemas with sample requests (Q4)In test-design-qa.md:
- Map each criterion to test scenarios
- Add "NFR Test Coverage Plan" section with P0/P1/P2 priority for each category
- Reference Architecture doc gaps
- Example:
## NFR Test Coverage Plan
**Based on ADR Quality Readiness Checklist**
### 1. Testability & Automation (4 criteria)
**Prerequisites from Architecture doc:**
- [ ] R-002: Test data seeding APIs implemented (blocker)
- [ ] Mock endpoints available for Athena queries
| Criterion | Test Scenarios | Priority | Test Count | Owner |
| ------------------------------- | -------------------------------------------------------------------- | -------- | ---------- | ---------------- |
| Isolation: Mock downstream deps | Mock Athena queries, Mock Milvus, Service runs isolated | P1 | 3 | Backend Dev + QA |
| Headless: API-accessible logic | All MCP tools callable via REST, No UI dependency for business logic | P0 | 5 | QA |
| State Control: Seeding APIs | Create test customer, Seed 1000 transactions, Inject edge cases | P0 | 4 | QA |
| Sample Requests: cURL examples | Valid request succeeds, Invalid request fails with clear error | P1 | 2 | QA |
**Detailed Test Scenarios:**
- [ ] Isolation: Service runs with Athena mocked (returns fixture data)
- [ ] Isolation: Service runs with Milvus mocked (returns ANN fixture)
- [ ] State Control: Seed test customer with 1000 baseline transactions
- [ ] State Control: Inject edge case (expired subscription user)---
Usage in NFR Assessment Workflow
Output Structure:
# NFR Assessment: {Feature Name}
**Based on ADR Quality Readiness Checklist (8 categories, 29 criteria)**
## Assessment Summary
| Category | Status | Criteria Met | Evidence | Next Action |
| ----------------------------- | ----------- | ------------ | -------------------------------------- | -------------------- |
| 1. Testability & Automation | ⚠️ CONCERNS | 2/4 | Mock endpoints missing | Implement R-002 |
| 2. Test Data Strategy | ✅ PASS | 3/3 | Faker + auto-cleanup | None |
| 3. Scalability & Availability | ⚠️ CONCERNS | 1/4 | SLA undefined | Define SLA |
| 4. Disaster Recovery | ⚠️ CONCERNS | 0/3 | No RTO/RPO defined | Define recovery plan |
| 5. Security | ✅ PASS | 4/4 | OAuth 2.1 + TLS + Vault + Sanitization | None |
| 6. Monitorability | ⚠️ CONCERNS | 2/4 | No metrics endpoint | Add /metrics |
| 7. QoS & QoE | ⚠️ CONCERNS | 1/4 | Latency targets undefined | Define SLOs |
| 8. Deployability | ✅ PASS | 3/3 | Blue/Green + DB migrations + Rollback | None |
**Overall:** 14/29 criteria met (48%) → ⚠️ CONCERNS
**Gate Decision:** CONCERNS (requires mitigation plan before GA)
---
## Detailed Assessment
### 1. Testability & Automation (2/4 criteria met)
**Question:** Can we verify this effectively without manual toil?
| Criterion | Status | Evidence | Gap/Action |
| ---------------------------- | ------ | ------------------------ | -------------------------- |
| ⬜ Isolation: Mock deps | ⚠️ | No Athena mock | Implement mock endpoints |
| ⬜ Headless: API-accessible | ✅ | All MCP tools are REST | N/A |
| ⬜ State Control: Seeding | ⚠️ | `/api/test-data` pending | Pre-implementation blocker |
| ⬜ Sample Requests: Examples | ⬜ | Pending schemas | Finalize ADR Tools |
**Overall Status:** ⚠️ CONCERNS (2/4 criteria met)
**Next Actions:**
- [ ] Backend: Implement Athena mock endpoints (pre-implementation)
- [ ] Backend: Implement `/api/test-data` (pre-implementation)
- [ ] PM: Finalize sample requests (implementation phase)
{Repeat for all 8 categories}---
Benefits
For test-design workflow:
- ✅ Standard NFR structure (same 8 categories every project)
- ✅ Clear testability requirements for Architecture team
- ✅ Direct mapping: criterion → requirement → test scenario
- ✅ Comprehensive coverage (29 criteria = no blind spots)
For nfr-assess workflow:
- ✅ Structured assessment (not ad-hoc)
- ✅ Quantifiable (X/29 criteria met)
- ✅ Evidence-based (each criterion has evidence field)
- ✅ Actionable (gaps → next actions with owners)
For Architecture teams:
- ✅ Clear checklist (29 yes/no questions)
- ✅ Risk-aware (each criterion has "risk if unmet")
- ✅ Scoped work (only implement what's needed, not everything)
For QA teams:
- ✅ Comprehensive test coverage (29 criteria → test scenarios)
- ✅ Clear priorities (P0 for security/isolation, P1 for monitoring, etc.)
- ✅ No ambiguity (each criterion has specific test scenarios)
API Request Utility
Principle
Use typed HTTP client with built-in schema validation and automatic retry for server errors. The utility handles URL resolution, header management, response parsing, and single-line response validation with proper TypeScript support. Works without a browser - ideal for pure API/service testing.
Rationale
Vanilla Playwright's request API requires boilerplate for common patterns:
- Manual JSON parsing (
await response.json()) - Repetitive status code checking
- No built-in retry logic for transient failures
- No schema validation
- Complex URL construction
The apiRequest utility provides:
- Automatic JSON parsing: Response body pre-parsed
- Built-in retry: 5xx errors retry with exponential backoff
- Schema validation: Single-line validation (JSON Schema, Zod, OpenAPI)
- URL resolution: Four-tier strategy (explicit > config > Playwright > direct)
- TypeScript generics: Type-safe response bodies
- No browser required: Pure API testing without browser overhead
Pattern Examples
Example 1: Basic API Request
Context: Making authenticated API requests with automatic retry and type safety.
Implementation:
import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';
test('should fetch user data', async ({ apiRequest }) => {
const { status, body } = await apiRequest<User>({
method: 'GET',
path: '/api/users/123',
headers: { Authorization: 'Bearer token' },
});
expect(status).toBe(200);
expect(body.name).toBe('John Doe'); // TypeScript knows body is User
});Key Points:
- Generic type
<User>provides TypeScript autocomplete forbody - Status and body destructured from response
- Headers passed as object
- Automatic retry for 5xx errors (configurable)
Example 2: Schema Validation (Single Line)
Context: Validate API responses match expected schema with single-line syntax.
Implementation:
import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';
import { z } from 'zod';
// JSON Schema validation
test('should validate response schema (JSON Schema)', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/users/123',
validateSchema: {
type: 'object',
required: ['id', 'name', 'email'],
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string', format: 'email' },
},
},
});
// Throws if schema validation fails
expect(status).toBe(200);
});
// Zod schema validation
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
test('should validate response schema (Zod)', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/users/123',
validateSchema: UserSchema,
});
// Response body is type-safe AND validated
expect(status).toBe(200);
expect(body.email).toContain('@');
});Key Points:
- Single
validateSchemaparameter - Supports JSON Schema, Zod, YAML files, OpenAPI specs
- Throws on validation failure with detailed errors
- Zero boilerplate validation code
Example 3: POST with Body and Retry Configuration
Context: Creating resources with custom retry behavior for error testing.
Implementation:
test('should create user', async ({ apiRequest }) => {
const newUser = {
name: 'Jane Doe',
email: 'jane@example.com',
};
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/users',
body: newUser, // Automatically sent as JSON
headers: { Authorization: 'Bearer token' },
});
expect(status).toBe(201);
expect(body.id).toBeDefined();
});
// Disable retry for error testing
test('should handle 500 errors', async ({ apiRequest }) => {
await expect(
apiRequest({
method: 'GET',
path: '/api/error',
retryConfig: { maxRetries: 0 }, // Disable retry
}),
).rejects.toThrow('Request failed with status 500');
});Key Points:
bodyparameter auto-serializes to JSON- Default retry: 5xx errors, 3 retries, exponential backoff
- Disable retry with
retryConfig: { maxRetries: 0 } - Only 5xx errors retry (4xx errors fail immediately)
Example 4: URL Resolution Strategy
Context: Flexible URL handling for different environments and test contexts.
Implementation:
// Strategy 1: Explicit baseUrl (highest priority)
await apiRequest({
method: 'GET',
path: '/users',
baseUrl: 'https://api.example.com', // Uses https://api.example.com/users
});
// Strategy 2: Config baseURL (from fixture)
import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';
test.use({ configBaseUrl: 'https://staging-api.example.com' });
test('uses config baseURL', async ({ apiRequest }) => {
await apiRequest({
method: 'GET',
path: '/users', // Uses https://staging-api.example.com/users
});
});
// Strategy 3: Playwright baseURL (from playwright.config.ts)
// playwright.config.ts
export default defineConfig({
use: {
baseURL: 'https://api.example.com',
},
});
test('uses Playwright baseURL', async ({ apiRequest }) => {
await apiRequest({
method: 'GET',
path: '/users', // Uses https://api.example.com/users
});
});
// Strategy 4: Direct path (full URL)
await apiRequest({
method: 'GET',
path: 'https://api.example.com/users', // Full URL works too
});Key Points:
- Four-tier resolution: explicit > config > Playwright > direct
- Trailing slashes normalized automatically
- Environment-specific baseUrl easy to configure
Example 5: Integration with Recurse (Polling)
Context: Waiting for async operations to complete (background jobs, eventual consistency).
Implementation:
import { test } from '@seontechnologies/playwright-utils/fixtures';
test('should poll until job completes', async ({ apiRequest, recurse }) => {
// Create job
const { body } = await apiRequest({
method: 'POST',
path: '/api/jobs',
body: { type: 'export' },
});
const jobId = body.id;
// Poll until ready
const completedJob = await recurse(
() => apiRequest({ method: 'GET', path: `/api/jobs/${jobId}` }),
(response) => response.body.status === 'completed',
{ timeout: 60000, interval: 2000 },
);
expect(completedJob.body.result).toBeDefined();
});Key Points:
apiRequestreturns full response objectrecursepolls until predicate returns true- Composable utilities work together seamlessly
Example 6: Microservice Testing (Multiple Services)
Context: Test interactions between microservices without a browser.
Implementation:
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
const USER_SERVICE = process.env.USER_SERVICE_URL || 'http://localhost:3001';
const ORDER_SERVICE = process.env.ORDER_SERVICE_URL || 'http://localhost:3002';
test.describe('Microservice Integration', () => {
test('should validate cross-service user lookup', async ({ apiRequest }) => {
// Create user in user-service
const { body: user } = await apiRequest({
method: 'POST',
path: '/api/users',
baseUrl: USER_SERVICE,
body: { name: 'Test User', email: 'test@example.com' },
});
// Create order in order-service (validates user via user-service)
const { status, body: order } = await apiRequest({
method: 'POST',
path: '/api/orders',
baseUrl: ORDER_SERVICE,
body: {
userId: user.id,
items: [{ productId: 'prod-1', quantity: 2 }],
},
});
expect(status).toBe(201);
expect(order.userId).toBe(user.id);
});
test('should reject order for invalid user', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/orders',
baseUrl: ORDER_SERVICE,
body: {
userId: 'non-existent-user',
items: [{ productId: 'prod-1', quantity: 1 }],
},
});
expect(status).toBe(400);
expect(body.code).toBe('INVALID_USER');
});
});Key Points:
- Test multiple services without browser
- Use
baseUrlto target different services - Validate cross-service communication
- Pure API testing - fast and reliable
Example 7: GraphQL API Testing
Context: Test GraphQL endpoints with queries and mutations.
Implementation:
test.describe('GraphQL API', () => {
const GRAPHQL_ENDPOINT = '/graphql';
test('should query users via GraphQL', async ({ apiRequest }) => {
const query = `
query GetUsers($limit: Int) {
users(limit: $limit) {
id
name
email
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query,
variables: { limit: 10 },
},
});
expect(status).toBe(200);
expect(body.errors).toBeUndefined();
expect(body.data.users).toHaveLength(10);
});
test('should create user via mutation', async ({ apiRequest }) => {
const mutation = `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query: mutation,
variables: {
input: { name: 'GraphQL User', email: 'gql@example.com' },
},
},
});
expect(status).toBe(200);
expect(body.data.createUser.id).toBeDefined();
});
});Key Points:
- GraphQL via POST request
- Variables in request body
- Check
body.errorsfor GraphQL errors (not status code) - Works for queries and mutations
Example 8: Operation-Based Overload (OpenAPI / Code Generators)
Context: When using a code generator (orval, openapi-generator, custom scripts) that produces typed operation definitions from an OpenAPI spec, pass the operation object directly to apiRequest. This eliminates manual method/path extraction and typeof assertions while preserving full type inference for request body, response, and query parameters. Available since v3.14.0.
Implementation:
// Generated operation definition — structural typing, no import from playwright-utils needed
// type OperationShape = { path: string; method: 'POST'|'GET'|'PUT'|'DELETE'|'PATCH'|'HEAD'; response: unknown; request: unknown; query?: unknown }
import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
// --- Basic usage: operation replaces method + path ---
test('should upsert person via operation overload', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
operation: upsertPersonv2({ customerId }),
headers: getHeaders(customerId),
body: personInput, // compile-time typed as Schemas.PersonInput
});
expect(status).toBe(200);
expect(body.id).toBeDefined(); // body typed as Schemas.Person
});
// --- Typed query parameters (replaces string concatenation) ---
test('should list people with typed query', async ({ apiRequest }) => {
const { body } = await apiRequest({
operation: getPeoplev2({ customerId }),
headers: getHeaders(customerId),
query: { page: 0, page_size: 5 }, // typed from operation's query definition
});
expect(body.items).toHaveLength(5);
});
// --- Params escape hatch (pre-formatted query strings) ---
test('should fetch billing history with raw params', async ({ apiRequest }) => {
const { body } = await apiRequest({
operation: getBillingHistoryv2({ customerId }),
headers: getHeaders(customerId),
params: {
'filters[start_date]': getThisMonthTimestamp(),
'filters[date_type]': 'MONTH',
},
});
expect(body.entries.length).toBeGreaterThan(0);
});
// --- Works with recurse (polling) ---
test('should poll until person is reviewed', async ({ apiRequest, recurse }) => {
await recurse(
async () =>
apiRequest({
operation: getPersonv2({ customerId, hash }),
headers: getHeaders(customerId),
}),
(res) => {
expect(res.status).toBe(200);
expect(res.body.status).toBe('REVIEWED');
},
{ timeout: 30000, interval: 1000 },
);
});
// --- Schema validation chains work identically ---
test('should create movie with schema validation', async ({ apiRequest }) => {
const { body } = await apiRequest({
operation: createMovieOp,
headers: commonHeaders(authToken),
body: movie,
}).validateSchema(CreateMovieResponseSchema, {
shape: { status: 200, data: { name: movie.name } },
});
expect(body.data.id).toBeDefined();
});Key Points:
- Pass
operationinstead ofmethod+path— mutually exclusive at compile time - Response body, request body, and query types inferred from operation definition
- Uses structural typing (duck typing) — works with any code generator producing
{ path, method, response, request, query? } queryfield auto-serializes to bracket notation (filters[type]=pep,ids[0]=10)paramsescape hatch for pre-formatted strings — wins overqueryon conflict- Fully composable with
recurse,validateSchema, and all existing features response/request/queryon the operation are type-level only — runtime never reads their values
Comparison with Vanilla Playwright
| Vanilla Playwright | playwright-utils apiRequest |
|---|---|
const resp = await request.get('/api/users') | const { status, body } = await apiRequest({ method: 'GET', path: '/api/users' }) |
const body = await resp.json() | Response already parsed |
expect(resp.ok()).toBeTruthy() | Status code directly accessible |
| No retry logic | Auto-retry 5xx errors with backoff |
| No schema validation | Built-in multi-format validation |
| Manual error handling | Descriptive error messages |
When to Use
Use apiRequest for:
- ✅ Pure API/service testing (no browser needed)
- ✅ Microservice integration testing
- ✅ GraphQL API testing
- ✅ Schema validation needs
- ✅ Tests requiring retry logic
- ✅ Background API calls in UI tests
- ✅ Contract testing support
- ✅ Type-safe API testing with OpenAPI-generated operations (v3.14.0+)
Stick with vanilla Playwright for:
- Simple one-off requests where utility overhead isn't worth it
- Testing Playwright's native features specifically
- Legacy tests where migration isn't justified
Related Fragments
api-testing-patterns.md- Comprehensive pure API testing patternsoverview.md- Installation and design principlesauth-session.md- Authentication token managementrecurse.md- Polling for async operationsfixtures-composition.md- Combining utilities with mergeTestslog.md- Logging API requestscontract-testing.md- Pact contract testing
Anti-Patterns
❌ Ignoring retry failures:
try {
await apiRequest({ method: 'GET', path: '/api/unstable' });
} catch {
// Silent failure - loses retry information
}✅ Let retries happen, handle final failure:
await expect(apiRequest({ method: 'GET', path: '/api/unstable' })).rejects.toThrow(); // Retries happen automatically, then final error caught❌ Disabling TypeScript benefits:
const response: any = await apiRequest({ method: 'GET', path: '/users' });✅ Use generic types:
const { body } = await apiRequest<User[]>({ method: 'GET', path: '/users' });
// body is typed as User[]❌ Mixing operation overload with explicit generics:
// Don't pass a generic when using operation — types are inferred from the operation
const { body } = await apiRequest<MyType>({
operation: getPersonv2({ customerId }),
headers: getHeaders(customerId),
});✅ Let the operation infer the types:
const { body } = await apiRequest({
operation: getPersonv2({ customerId }),
headers: getHeaders(customerId),
});
// body type inferred from operation.response❌ Mixing operation with method/path:
// Compile error — operation and method/path are mutually exclusive
await apiRequest({
operation: getPersonv2({ customerId }),
method: 'GET', // Error: method?: never
path: '/api/person', // Error: path?: never
});API Testing Patterns
Principle
Test APIs and backend services directly without browser overhead. Use Playwright's request context for HTTP operations, apiRequest utility for enhanced features, and recurse for async operations. Pure API tests run faster, are more stable, and provide better coverage for service-layer logic.
Rationale
Many teams over-rely on E2E/browser tests when API tests would be more appropriate:
- Slower feedback: Browser tests take seconds, API tests take milliseconds
- More brittle: UI changes break tests even when API works correctly
- Wrong abstraction: Testing business logic through UI layers adds noise
- Resource heavy: Browsers consume memory and CPU
API-first testing provides:
- Fast execution: No browser startup, no rendering, no JavaScript execution
- Direct validation: Test exactly what the service returns
- Better isolation: Test service logic independent of UI
- Easier debugging: Clear request/response without DOM noise
- Contract validation: Verify API contracts explicitly
When to Use API Tests vs E2E Tests
| Scenario | API Test | E2E Test |
|---|---|---|
| CRUD operations | ✅ Primary | ❌ Overkill |
| Business logic validation | ✅ Primary | ❌ Overkill |
| Error handling (4xx, 5xx) | ✅ Primary | ⚠️ Supplement |
| Authentication flows | ✅ Primary | ⚠️ Supplement |
| Data transformation | ✅ Primary | ❌ Overkill |
| User journeys | ❌ Can't test | ✅ Primary |
| Visual regression | ❌ Can't test | ✅ Primary |
| Cross-browser issues | ❌ Can't test | ✅ Primary |
Rule of thumb: If you're testing what the server returns (not how it looks), use API tests.
Pattern Examples
Example 1: Pure API Test (No Browser)
Context: Test REST API endpoints directly without any browser context.
Implementation:
// tests/api/users.spec.ts
import { test, expect } from '@playwright/test';
// No page, no browser - just API
test.describe('Users API', () => {
test('should create user', async ({ request }) => {
const response = await request.post('/api/users', {
data: {
name: 'John Doe',
email: 'john@example.com',
role: 'user',
},
});
expect(response.status()).toBe(201);
const user = await response.json();
expect(user.id).toBeDefined();
expect(user.name).toBe('John Doe');
expect(user.email).toBe('john@example.com');
});
test('should get user by ID', async ({ request }) => {
// Create user first
const createResponse = await request.post('/api/users', {
data: { name: 'Jane Doe', email: 'jane@example.com' },
});
const { id } = await createResponse.json();
// Get user
const getResponse = await request.get(`/api/users/${id}`);
expect(getResponse.status()).toBe(200);
const user = await getResponse.json();
expect(user.id).toBe(id);
expect(user.name).toBe('Jane Doe');
});
test('should return 404 for non-existent user', async ({ request }) => {
const response = await request.get('/api/users/non-existent-id');
expect(response.status()).toBe(404);
const error = await response.json();
expect(error.code).toBe('USER_NOT_FOUND');
});
test('should validate required fields', async ({ request }) => {
const response = await request.post('/api/users', {
data: { name: 'Missing Email' }, // email is required
});
expect(response.status()).toBe(400);
const error = await response.json();
expect(error.code).toBe('VALIDATION_ERROR');
expect(error.details).toContainEqual(expect.objectContaining({ field: 'email', message: expect.any(String) }));
});
});Key Points:
- No
pagefixture needed - onlyrequest - Tests run without browser overhead
- Direct HTTP assertions
- Clear error handling tests
Example 2: API Test with apiRequest Utility
Context: Use enhanced apiRequest for schema validation, retry, and type safety.
Implementation:
// tests/api/orders.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
import { z } from 'zod';
// Define schema for type safety and validation
const OrderSchema = z.object({
id: z.string().uuid(),
userId: z.string(),
items: z.array(
z.object({
productId: z.string(),
quantity: z.number().positive(),
price: z.number().positive(),
}),
),
total: z.number().positive(),
status: z.enum(['pending', 'processing', 'shipped', 'delivered']),
createdAt: z.string().datetime(),
});
type Order = z.infer<typeof OrderSchema>;
test.describe('Orders API', () => {
test('should create order with schema validation', async ({ apiRequest }) => {
const { status, body } = await apiRequest<Order>({
method: 'POST',
path: '/api/orders',
body: {
userId: 'user-123',
items: [
{ productId: 'prod-1', quantity: 2, price: 29.99 },
{ productId: 'prod-2', quantity: 1, price: 49.99 },
],
},
validateSchema: OrderSchema, // Validates response matches schema
});
expect(status).toBe(201);
expect(body.id).toBeDefined();
expect(body.status).toBe('pending');
expect(body.total).toBe(109.97); // 2*29.99 + 49.99
});
test('should handle server errors with retry', async ({ apiRequest }) => {
// apiRequest retries 5xx errors by default
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/orders/order-123',
retryConfig: {
maxRetries: 3,
retryDelay: 1000,
},
});
expect(status).toBe(200);
});
test('should list orders with pagination', async ({ apiRequest }) => {
const { status, body } = await apiRequest<{ orders: Order[]; total: number; page: number }>({
method: 'GET',
path: '/api/orders',
params: { page: 1, limit: 10, status: 'pending' },
});
expect(status).toBe(200);
expect(body.orders).toHaveLength(10);
expect(body.total).toBeGreaterThan(10);
expect(body.page).toBe(1);
});
});Key Points:
- Zod schema for runtime validation AND TypeScript types
validateSchemathrows if response doesn't match- Built-in retry for transient failures
- Type-safe
bodyaccess - Note: If your project uses code-generated operations from an OpenAPI spec, see Example 8 for the preferred
operation-based overload (v3.14.0+)
Example 3: Microservice-to-Microservice Testing
Context: Test service interactions without browser - validate API contracts between services.
Implementation:
// tests/api/service-integration.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
test.describe('Service Integration', () => {
const USER_SERVICE_URL = process.env.USER_SERVICE_URL || 'http://localhost:3001';
const ORDER_SERVICE_URL = process.env.ORDER_SERVICE_URL || 'http://localhost:3002';
const INVENTORY_SERVICE_URL = process.env.INVENTORY_SERVICE_URL || 'http://localhost:3003';
test('order service should validate user exists', async ({ apiRequest }) => {
// Create user in user-service
const { body: user } = await apiRequest({
method: 'POST',
path: '/api/users',
baseUrl: USER_SERVICE_URL,
body: { name: 'Test User', email: 'test@example.com' },
});
// Create order in order-service (should validate user via user-service)
const { status, body: order } = await apiRequest({
method: 'POST',
path: '/api/orders',
baseUrl: ORDER_SERVICE_URL,
body: {
userId: user.id,
items: [{ productId: 'prod-1', quantity: 1 }],
},
});
expect(status).toBe(201);
expect(order.userId).toBe(user.id);
});
test('order service should reject invalid user', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/orders',
baseUrl: ORDER_SERVICE_URL,
body: {
userId: 'non-existent-user',
items: [{ productId: 'prod-1', quantity: 1 }],
},
});
expect(status).toBe(400);
expect(body.code).toBe('INVALID_USER');
});
test('order should decrease inventory', async ({ apiRequest, recurse }) => {
// Get initial inventory
const { body: initialInventory } = await apiRequest({
method: 'GET',
path: '/api/inventory/prod-1',
baseUrl: INVENTORY_SERVICE_URL,
});
// Create order
await apiRequest({
method: 'POST',
path: '/api/orders',
baseUrl: ORDER_SERVICE_URL,
body: {
userId: 'user-123',
items: [{ productId: 'prod-1', quantity: 2 }],
},
});
// Poll for inventory update (eventual consistency)
const { body: updatedInventory } = await recurse(
() =>
apiRequest({
method: 'GET',
path: '/api/inventory/prod-1',
baseUrl: INVENTORY_SERVICE_URL,
}),
(response) => response.body.quantity === initialInventory.quantity - 2,
{ timeout: 10000, interval: 500 },
);
expect(updatedInventory.quantity).toBe(initialInventory.quantity - 2);
});
});Key Points:
- Multiple service URLs for microservice testing
- Tests service-to-service communication
- Uses
recursefor eventual consistency - No browser needed for full integration testing
Example 4: GraphQL API Testing
Context: Test GraphQL endpoints with queries and mutations.
Implementation:
// tests/api/graphql.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
const GRAPHQL_ENDPOINT = '/graphql';
test.describe('GraphQL API', () => {
test('should query users', async ({ apiRequest }) => {
const query = `
query GetUsers($limit: Int) {
users(limit: $limit) {
id
name
email
role
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query,
variables: { limit: 10 },
},
});
expect(status).toBe(200);
expect(body.errors).toBeUndefined();
expect(body.data.users).toHaveLength(10);
expect(body.data.users[0]).toHaveProperty('id');
expect(body.data.users[0]).toHaveProperty('name');
});
test('should create user via mutation', async ({ apiRequest }) => {
const mutation = `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query: mutation,
variables: {
input: {
name: 'GraphQL User',
email: 'graphql@example.com',
},
},
},
});
expect(status).toBe(200);
expect(body.errors).toBeUndefined();
expect(body.data.createUser.id).toBeDefined();
expect(body.data.createUser.name).toBe('GraphQL User');
});
test('should handle GraphQL errors', async ({ apiRequest }) => {
const query = `
query GetUser($id: ID!) {
user(id: $id) {
id
name
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query,
variables: { id: 'non-existent' },
},
});
expect(status).toBe(200); // GraphQL returns 200 even for errors
expect(body.errors).toBeDefined();
expect(body.errors[0].message).toContain('not found');
expect(body.data.user).toBeNull();
});
test('should handle validation errors', async ({ apiRequest }) => {
const mutation = `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
}
}
`;
const { status, body } = await apiRequest({
method: 'POST',
path: GRAPHQL_ENDPOINT,
body: {
query: mutation,
variables: {
input: {
name: '', // Invalid: empty name
email: 'invalid-email', // Invalid: bad format
},
},
},
});
expect(status).toBe(200);
expect(body.errors).toBeDefined();
expect(body.errors[0].extensions.code).toBe('BAD_USER_INPUT');
});
});Key Points:
- GraphQL queries and mutations via POST
- Variables passed in request body
- GraphQL returns 200 even for errors (check
body.errors) - Test validation and business logic errors
Example 5: Database Seeding and Cleanup via API
Context: Use API calls to set up and tear down test data without direct database access.
Implementation:
// tests/api/with-data-setup.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
test.describe('Orders with Data Setup', () => {
let testUser: { id: string; email: string };
let testProducts: Array<{ id: string; name: string; price: number }>;
test.beforeAll(async ({ request }) => {
// Seed user via API
const userResponse = await request.post('/api/users', {
data: {
name: 'Test User',
email: `test-${Date.now()}@example.com`,
},
});
testUser = await userResponse.json();
// Seed products via API
testProducts = [];
for (const product of [
{ name: 'Widget A', price: 29.99 },
{ name: 'Widget B', price: 49.99 },
{ name: 'Widget C', price: 99.99 },
]) {
const productResponse = await request.post('/api/products', {
data: product,
});
testProducts.push(await productResponse.json());
}
});
test.afterAll(async ({ request }) => {
// Cleanup via API
if (testUser?.id) {
await request.delete(`/api/users/${testUser.id}`);
}
for (const product of testProducts) {
await request.delete(`/api/products/${product.id}`);
}
});
test('should create order with seeded data', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/orders',
body: {
userId: testUser.id,
items: [
{ productId: testProducts[0].id, quantity: 2 },
{ productId: testProducts[1].id, quantity: 1 },
],
},
});
expect(status).toBe(201);
expect(body.userId).toBe(testUser.id);
expect(body.items).toHaveLength(2);
expect(body.total).toBe(2 * 29.99 + 49.99);
});
test('should list user orders', async ({ apiRequest }) => {
// Create an order first
await apiRequest({
method: 'POST',
path: '/api/orders',
body: {
userId: testUser.id,
items: [{ productId: testProducts[2].id, quantity: 1 }],
},
});
// List orders for user
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/orders',
params: { userId: testUser.id },
});
expect(status).toBe(200);
expect(body.orders.length).toBeGreaterThanOrEqual(1);
expect(body.orders.every((o: any) => o.userId === testUser.id)).toBe(true);
});
});Key Points:
beforeAll/afterAllfor test data setup/cleanup- API-based seeding (no direct DB access needed)
- Unique emails to prevent conflicts in parallel runs
- Cleanup after all tests complete
Example 6: Background Job Testing with Recurse
Context: Test async operations like background jobs, webhooks, and eventual consistency.
Implementation:
// tests/api/background-jobs.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
test.describe('Background Jobs', () => {
test('should process export job', async ({ apiRequest, recurse }) => {
// Trigger export job
const { body: job } = await apiRequest({
method: 'POST',
path: '/api/exports',
body: {
type: 'users',
format: 'csv',
filters: { createdAfter: '2024-01-01' },
},
});
expect(job.id).toBeDefined();
expect(job.status).toBe('pending');
// Poll until job completes
const { body: completedJob } = await recurse(
() => apiRequest({ method: 'GET', path: `/api/exports/${job.id}` }),
(response) => response.body.status === 'completed',
{
timeout: 60000,
interval: 2000,
log: `Waiting for export job ${job.id} to complete`,
},
);
expect(completedJob.status).toBe('completed');
expect(completedJob.downloadUrl).toBeDefined();
expect(completedJob.recordCount).toBeGreaterThan(0);
});
test('should handle job failure gracefully', async ({ apiRequest, recurse }) => {
// Trigger job that will fail
const { body: job } = await apiRequest({
method: 'POST',
path: '/api/exports',
body: {
type: 'invalid-type', // This will cause failure
format: 'csv',
},
});
// Poll until job fails
const { body: failedJob } = await recurse(
() => apiRequest({ method: 'GET', path: `/api/exports/${job.id}` }),
(response) => ['completed', 'failed'].includes(response.body.status),
{ timeout: 30000 },
);
expect(failedJob.status).toBe('failed');
expect(failedJob.error).toBeDefined();
expect(failedJob.error.code).toBe('INVALID_EXPORT_TYPE');
});
test('should process webhook delivery', async ({ apiRequest, recurse }) => {
// Trigger action that sends webhook
const { body: order } = await apiRequest({
method: 'POST',
path: '/api/orders',
body: {
userId: 'user-123',
items: [{ productId: 'prod-1', quantity: 1 }],
webhookUrl: 'https://webhook.site/test-endpoint',
},
});
// Poll for webhook delivery status
const { body: webhookStatus } = await recurse(
() => apiRequest({ method: 'GET', path: `/api/webhooks/order/${order.id}` }),
(response) => response.body.delivered === true,
{ timeout: 30000, interval: 1000 },
);
expect(webhookStatus.delivered).toBe(true);
expect(webhookStatus.deliveredAt).toBeDefined();
expect(webhookStatus.responseStatus).toBe(200);
});
});Key Points:
recursefor polling async operations- Test both success and failure scenarios
- Configurable timeout and interval
- Log messages for debugging
Example 7: Service Authentication (No Browser)
Context: Test authenticated API endpoints using tokens directly - no browser login needed.
Implementation:
// tests/api/authenticated.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
test.describe('Authenticated API Tests', () => {
let authToken: string;
test.beforeAll(async ({ request }) => {
// Get token via API (no browser!)
const response = await request.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD,
},
});
const { token } = await response.json();
authToken = token;
});
test('should access protected endpoint with token', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/me',
headers: {
Authorization: `Bearer ${authToken}`,
},
});
expect(status).toBe(200);
expect(body.email).toBe(process.env.TEST_USER_EMAIL);
});
test('should reject request without token', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/me',
// No Authorization header
});
expect(status).toBe(401);
expect(body.code).toBe('UNAUTHORIZED');
});
test('should reject expired token', async ({ apiRequest }) => {
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'; // Expired token
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/me',
headers: {
Authorization: `Bearer ${expiredToken}`,
},
});
expect(status).toBe(401);
expect(body.code).toBe('TOKEN_EXPIRED');
});
test('should handle role-based access', async ({ apiRequest }) => {
// User token (non-admin)
const { status } = await apiRequest({
method: 'GET',
path: '/api/admin/users',
headers: {
Authorization: `Bearer ${authToken}`,
},
});
expect(status).toBe(403); // Forbidden for non-admin
});
});Key Points:
- Token obtained via API login (no browser)
- Token reused across all tests in describe block
- Test auth, expired tokens, and RBAC
- Pure API testing without UI
Example 8: Operation-Based API Testing (OpenAPI / Code Generators)
Context: When your project uses code-generated operation definitions from an OpenAPI spec, leverage the operation-based overload of apiRequest (v3.14.0+) instead of manual method/path extraction. This eliminates typeof assertions and provides full type inference for request body, response, and query parameters.
Implementation:
// tests/api/operations.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
test.describe('API Tests with Generated Operations', () => {
test('should create entity with full type safety', async ({ apiRequest }) => {
// Operation object from code generator — contains path, method, and type info
const { status, body } = await apiRequest({
operation: createEntityOp({ workspaceId }),
headers: getHeaders(workspaceId),
body: entityInput, // Compile-time typed from operation.request
});
expect(status).toBe(201);
expect(body.id).toBeDefined(); // body typed from operation.response
});
test('should list with typed query parameters', async ({ apiRequest }) => {
// query field replaces manual string concatenation
const { body } = await apiRequest({
operation: listEntitiesOp({ workspaceId }),
headers: getHeaders(workspaceId),
query: { page: 0, page_size: 10, status: 'active' },
});
expect(body.items).toHaveLength(10);
expect(body.total).toBeGreaterThan(10);
});
test('should poll async operation until complete', async ({ apiRequest, recurse }) => {
const { body: job } = await apiRequest({
operation: startJobOp({ workspaceId }),
headers: getHeaders(workspaceId),
body: { type: 'export' },
});
await recurse(
async () =>
apiRequest({
operation: getJobOp({ workspaceId, jobId: job.id }),
headers: getHeaders(workspaceId),
}),
(res) => res.body.status === 'completed',
{ timeout: 60000, interval: 2000 },
);
});
});Key Points:
operationreplacesmethod+path— mutually exclusive at compile time- Types for body, response, and query all inferred from the operation definition
- Works with any code generator using structural typing (no imports from playwright-utils needed in generator)
- Composable with
recurse,validateSchema, and all existingapiRequestfeatures - Preferred approach over
typeof operation.responsefor generated operations
API Test Configuration
Playwright Config for API-Only Tests
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/api',
// No browser needed for API tests
use: {
baseURL: process.env.API_URL || 'http://localhost:3000',
extraHTTPHeaders: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
// Faster without browser overhead
timeout: 30000,
// Run API tests in parallel
workers: 4,
fullyParallel: true,
// No screenshots/traces needed for API tests
reporter: [['html'], ['json', { outputFile: 'api-test-results.json' }]],
});Separate API Test Project
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'api',
testDir: './tests/api',
use: {
baseURL: process.env.API_URL,
},
},
{
name: 'e2e',
testDir: './tests/e2e',
use: {
baseURL: process.env.APP_URL,
...devices['Desktop Chrome'],
},
},
],
});Comparison: API Tests vs E2E Tests
| Aspect | API Test | E2E Test |
|---|---|---|
| Speed | ~50-100ms per test | ~2-10s per test |
| Stability | Very stable | More flaky (UI timing) |
| Setup | Minimal | Browser, context, page |
| Debugging | Clear request/response | DOM, screenshots, traces |
| Coverage | Service logic | User experience |
| Parallelization | Easy (stateless) | Complex (browser resources) |
| CI Cost | Low (no browser) | High (browser containers) |
Related Fragments
api-request.md- apiRequest utility detailsrecurse.md- Polling patterns for async operationsauth-session.md- Token managementcontract-testing.md- Pact contract testingtest-levels-framework.md- When to use which test leveldata-factories.md- Test data setup patterns
Anti-Patterns
DON'T use E2E for API validation:
// Bad: Testing API through UI
test('validate user creation', async ({ page }) => {
await page.goto('/admin/users');
await page.fill('#name', 'John');
await page.click('#submit');
await expect(page.getByText('User created')).toBeVisible();
});DO test APIs directly:
// Good: Direct API test
test('validate user creation', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/users',
body: { name: 'John' },
});
expect(status).toBe(201);
expect(body.id).toBeDefined();
});DON'T ignore API tests because "E2E covers it":
// Bad thinking: "Our E2E tests create users, so API is tested"
// Reality: E2E tests one happy path; API tests cover edge casesDO have dedicated API test coverage:
// Good: Explicit API test suite
test.describe('Users API', () => {
test('creates user', async ({ apiRequest }) => {
/* ... */
});
test('handles duplicate email', async ({ apiRequest }) => {
/* ... */
});
test('validates required fields', async ({ apiRequest }) => {
/* ... */
});
test('handles malformed JSON', async ({ apiRequest }) => {
/* ... */
});
test('rate limits requests', async ({ apiRequest }) => {
/* ... */
});
});Auth Session Utility
Principle
Persist authentication tokens to disk and reuse across test runs. Support multiple user identifiers, ephemeral authentication, and worker-specific accounts for parallel execution. Fetch tokens once, use everywhere. Works for both API-only tests and browser tests.
Rationale
Playwright's built-in authentication works but has limitations:
- Re-authenticates for every test run (slow)
- Single user per project setup
- No token expiration handling
- Manual session management
- Complex setup for multi-user scenarios
The auth-session utility provides:
- Token persistence: Authenticate once, reuse across runs
- Multi-user support: Different user identifiers in same test suite
- Ephemeral auth: On-the-fly user authentication without disk persistence
- Worker-specific accounts: Parallel execution with isolated user accounts
- Automatic token management: Checks validity, renews if expired
- Flexible provider pattern: Adapt to any auth system (OAuth2, JWT, custom)
- API-first design: Get tokens for API tests without browser overhead
Pattern Examples
Example 1: Basic Auth Session Setup
Context: Configure global authentication that persists across test runs.
Implementation:
// Step 1: Configure in global-setup.ts
import { authStorageInit, setAuthProvider, configureAuthSession, authGlobalInit } from '@seontechnologies/playwright-utils/auth-session';
import myCustomProvider from './auth/custom-auth-provider';
async function globalSetup() {
// Ensure storage directories exist
authStorageInit();
// Configure storage path
configureAuthSession({
authStoragePath: process.cwd() + '/playwright/auth-sessions',
debug: true,
});
// Set custom provider (HOW to authenticate)
setAuthProvider(myCustomProvider);
// Optional: pre-fetch token for default user
await authGlobalInit();
}
export default globalSetup;
// Step 2: Create auth fixture
import { test as base } from '@playwright/test';
import { createAuthFixtures, setAuthProvider } from '@seontechnologies/playwright-utils/auth-session';
import myCustomProvider from './custom-auth-provider';
// Register provider early
setAuthProvider(myCustomProvider);
export const test = base.extend(createAuthFixtures());
// Step 3: Use in tests
test('authenticated request', async ({ authToken, request }) => {
const response = await request.get('/api/protected', {
headers: { Authorization: `Bearer ${authToken}` },
});
expect(response.ok()).toBeTruthy();
});Key Points:
- Global setup runs once before all tests
- Token fetched once, reused across all tests
- Custom provider defines your auth mechanism
- Order matters: configure, then setProvider, then init
Example 2: Multi-User Authentication
Context: Testing with different user roles (admin, regular user, guest) in same test suite.
Implementation:
import { test } from '../support/auth/auth-fixture';
// Option 1: Per-test user override
test('admin actions', async ({ authToken, authOptions }) => {
// Override default user
authOptions.userIdentifier = 'admin';
const { authToken: adminToken } = await test.step('Get admin token', async () => {
return { authToken }; // Re-fetches with new identifier
});
// Use admin token
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${adminToken}` },
});
});
// Option 2: Parallel execution with different users
test.describe.parallel('multi-user tests', () => {
test('user 1 actions', async ({ authToken }) => {
// Uses default user (e.g., 'user1')
});
test('user 2 actions', async ({ authToken, authOptions }) => {
authOptions.userIdentifier = 'user2';
// Uses different token for user2
});
});Key Points:
- Override
authOptions.userIdentifierper test - Tokens cached separately per user identifier
- Parallel tests isolated with different users
- Worker-specific accounts possible
Example 3: Ephemeral User Authentication
Context: Create temporary test users that don't persist to disk (e.g., testing user creation flow).
Implementation:
import { applyUserCookiesToBrowserContext } from '@seontechnologies/playwright-utils/auth-session';
import { createTestUser } from '../utils/user-factory';
test('ephemeral user test', async ({ context, page }) => {
// Create temporary user (not persisted)
const ephemeralUser = await createTestUser({
role: 'admin',
permissions: ['delete-users'],
});
// Apply auth directly to browser context
await applyUserCookiesToBrowserContext(context, ephemeralUser);
// Page now authenticated as ephemeral user
await page.goto('/admin/users');
await expect(page.getByTestId('delete-user-btn')).toBeVisible();
// User and token cleaned up after test
});Key Points:
- No disk persistence (ephemeral)
- Apply cookies directly to context
- Useful for testing user lifecycle
- Clean up automatic when test ends
Example 4: Testing Multiple Users in Single Test
Context: Testing interactions between users (messaging, sharing, collaboration features).
Implementation:
test('user interaction', async ({ browser }) => {
// User 1 context
const user1Context = await browser.newContext({
storageState: './auth-sessions/local/user1/storage-state.json',
});
const user1Page = await user1Context.newPage();
// User 2 context
const user2Context = await browser.newContext({
storageState: './auth-sessions/local/user2/storage-state.json',
});
const user2Page = await user2Context.newPage();
// User 1 sends message
await user1Page.goto('/messages');
await user1Page.fill('#message', 'Hello from user 1');
await user1Page.click('#send');
// User 2 receives message
await user2Page.goto('/messages');
await expect(user2Page.getByText('Hello from user 1')).toBeVisible();
// Cleanup
await user1Context.close();
await user2Context.close();
});Key Points:
- Each user has separate browser context
- Reference storage state files directly
- Test real-time interactions
- Clean up contexts after test
Example 5: Worker-Specific Accounts (Parallel Testing)
Context: Running tests in parallel with isolated user accounts per worker to avoid conflicts.
Implementation:
// playwright.config.ts
export default defineConfig({
workers: 4, // 4 parallel workers
use: {
// Each worker uses different user
storageState: async ({}, use, testInfo) => {
const workerIndex = testInfo.workerIndex;
const userIdentifier = `worker-${workerIndex}`;
await use(`./auth-sessions/local/${userIdentifier}/storage-state.json`);
},
},
});
// Tests run in parallel, each worker with its own user
test('parallel test 1', async ({ page }) => {
// Worker 0 uses worker-0 account
await page.goto('/dashboard');
});
test('parallel test 2', async ({ page }) => {
// Worker 1 uses worker-1 account
await page.goto('/dashboard');
});Key Points:
- Each worker has isolated user account
- No conflicts in parallel execution
- Token management automatic per worker
- Scales to any number of workers
Example 6: Pure API Authentication (No Browser)
Context: Get auth tokens for API-only tests using auth-session disk persistence.
Implementation:
// Step 1: Create API-only auth provider (no browser needed)
// playwright/support/api-auth-provider.ts
import { type AuthProvider } from '@seontechnologies/playwright-utils/auth-session';
const apiAuthProvider: AuthProvider = {
getEnvironment: (options) => options.environment || 'local',
getUserIdentifier: (options) => options.userIdentifier || 'api-user',
extractToken: (storageState) => {
// Token stored in localStorage format for disk persistence
const tokenEntry = storageState.origins?.[0]?.localStorage?.find((item) => item.name === 'auth_token');
return tokenEntry?.value;
},
isTokenExpired: (storageState) => {
const expiryEntry = storageState.origins?.[0]?.localStorage?.find((item) => item.name === 'token_expiry');
if (!expiryEntry) return true;
return Date.now() > parseInt(expiryEntry.value, 10);
},
manageAuthToken: async (request, options) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
throw new Error('TEST_USER_EMAIL and TEST_USER_PASSWORD must be set');
}
// Pure API login - no browser!
const response = await request.post('/api/auth/login', {
data: { email, password },
});
if (!response.ok()) {
throw new Error(`Auth failed: ${response.status()}`);
}
const { token, expiresIn } = await response.json();
const expiryTime = Date.now() + expiresIn * 1000;
// Return storage state format for disk persistence
return {
cookies: [],
origins: [
{
origin: process.env.API_BASE_URL || 'http://localhost:3000',
localStorage: [
{ name: 'auth_token', value: token },
{ name: 'token_expiry', value: String(expiryTime) },
],
},
],
};
},
};
export default apiAuthProvider;
// Step 2: Create auth fixture
// playwright/support/fixtures.ts
import { test as base } from '@playwright/test';
import { createAuthFixtures, setAuthProvider } from '@seontechnologies/playwright-utils/auth-session';
import apiAuthProvider from './api-auth-provider';
setAuthProvider(apiAuthProvider);
export const test = base.extend(createAuthFixtures());
// Step 3: Use in tests - token persisted to disk!
// tests/api/authenticated-api.spec.ts
import { test } from '../support/fixtures';
import { expect } from '@playwright/test';
test('should access protected endpoint', async ({ authToken, apiRequest }) => {
// authToken is automatically loaded from disk or fetched if expired
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/me',
headers: { Authorization: `Bearer ${authToken}` },
});
expect(status).toBe(200);
});
test('should create resource with auth', async ({ authToken, apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/orders',
headers: { Authorization: `Bearer ${authToken}` },
body: { items: [{ productId: 'prod-1', quantity: 2 }] },
});
expect(status).toBe(201);
expect(body.id).toBeDefined();
});Key Points:
- Token persisted to disk (not in-memory) - survives test reruns
- Provider fetches token once, reuses until expired
- Pure API authentication - no browser context needed
authTokenfixture handles disk read/write automatically- Environment variables validated with clear error message
Example 7: Service-to-Service Authentication
Context: Test microservice authentication patterns (API keys, service tokens) with proper environment validation.
Implementation:
// tests/api/service-auth.spec.ts
import { test as base, expect } from '@playwright/test';
import { test as apiFixture } from '@seontechnologies/playwright-utils/api-request/fixtures';
import { mergeTests } from '@playwright/test';
// Validate environment variables at module load
const SERVICE_API_KEY = process.env.SERVICE_API_KEY;
const INTERNAL_SERVICE_URL = process.env.INTERNAL_SERVICE_URL;
if (!SERVICE_API_KEY) {
throw new Error('SERVICE_API_KEY environment variable is required');
}
if (!INTERNAL_SERVICE_URL) {
throw new Error('INTERNAL_SERVICE_URL environment variable is required');
}
const test = mergeTests(base, apiFixture);
test.describe('Service-to-Service Auth', () => {
test('should authenticate with API key', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/internal/health',
baseUrl: INTERNAL_SERVICE_URL,
headers: { 'X-API-Key': SERVICE_API_KEY },
});
expect(status).toBe(200);
expect(body.status).toBe('healthy');
});
test('should reject invalid API key', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/internal/health',
baseUrl: INTERNAL_SERVICE_URL,
headers: { 'X-API-Key': 'invalid-key' },
});
expect(status).toBe(401);
expect(body.code).toBe('INVALID_API_KEY');
});
test('should call downstream service with propagated auth', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/internal/aggregate-data',
baseUrl: INTERNAL_SERVICE_URL,
headers: {
'X-API-Key': SERVICE_API_KEY,
'X-Request-ID': `test-${Date.now()}`,
},
body: { sources: ['users', 'orders', 'inventory'] },
});
expect(status).toBe(200);
expect(body.aggregatedFrom).toHaveLength(3);
});
});Key Points:
- Environment variables validated at module load with clear errors
- API key authentication (simpler than OAuth - no disk persistence needed)
- Test internal/service endpoints
- Validate auth rejection scenarios
- Correlation ID for request tracing
Note: API keys are typically static secrets that don't expire, so disk persistence (auth-session) isn't needed. For rotating service tokens, use the auth-session provider pattern from Example 6.
Custom Auth Provider Pattern
Context: Adapt auth-session to your authentication system (OAuth2, JWT, SAML, custom).
Minimal provider structure:
import { type AuthProvider } from '@seontechnologies/playwright-utils/auth-session';
const myCustomProvider: AuthProvider = {
getEnvironment: (options) => options.environment || 'local',
getUserIdentifier: (options) => options.userIdentifier || 'default-user',
extractToken: (storageState) => {
// Extract token from your storage format
return storageState.cookies.find((c) => c.name === 'auth_token')?.value;
},
extractCookies: (tokenData) => {
// Convert token to cookies for browser context
return [
{
name: 'auth_token',
value: tokenData,
domain: 'example.com',
path: '/',
httpOnly: true,
secure: true,
},
];
},
isTokenExpired: (storageState) => {
// Check if token is expired
const expiresAt = storageState.cookies.find((c) => c.name === 'expires_at');
return Date.now() > parseInt(expiresAt?.value || '0');
},
manageAuthToken: async (request, options) => {
// Main token acquisition logic
// Return storage state with cookies/localStorage
},
};
export default myCustomProvider;Integration with API Request
import { test } from '@seontechnologies/playwright-utils/fixtures';
test('authenticated API call', async ({ apiRequest, authToken }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/protected',
headers: { Authorization: `Bearer ${authToken}` },
});
expect(status).toBe(200);
});Related Fragments
api-testing-patterns.md- Pure API testing patterns (no browser)overview.md- Installation and fixture compositionapi-request.md- Authenticated API requestsfixtures-composition.md- Merging auth with other utilities
Anti-Patterns
❌ Calling setAuthProvider after globalSetup:
async function globalSetup() {
configureAuthSession(...)
await authGlobalInit() // Provider not set yet!
setAuthProvider(provider) // Too late
}✅ Register provider before init:
async function globalSetup() {
authStorageInit()
configureAuthSession(...)
setAuthProvider(provider) // First
await authGlobalInit() // Then init
}❌ Hardcoding storage paths:
const storageState = './auth-sessions/local/user1/storage-state.json'; // Brittle✅ Use helper functions:
import { getTokenFilePath } from '@seontechnologies/playwright-utils/auth-session';
const tokenPath = getTokenFilePath({
environment: 'local',
userIdentifier: 'user1',
tokenFileName: 'storage-state.json',
});Burn-in Test Runner
Principle
Use smart test selection with git diff analysis to run only affected tests. Filter out irrelevant changes (configs, types, docs) and control test volume with percentage-based execution. Reduce unnecessary CI runs while maintaining reliability.
Rationale
Playwright's --only-changed triggers all affected tests:
- Config file changes trigger hundreds of tests
- Type definition changes cause full suite runs
- No volume control (all or nothing)
- Slow CI pipelines
The burn-in utility provides:
- Smart filtering: Skip patterns for irrelevant files (configs, types, docs)
- Volume control: Run percentage of affected tests after filtering
- Custom dependency analysis: More accurate than Playwright's built-in
- CI optimization: Faster pipelines without sacrificing confidence
- Process of elimination: Start with all → filter irrelevant → control volume
Pattern Examples
Example 1: Basic Burn-in Setup
Context: Run burn-in on changed files compared to main branch.
Implementation:
// Step 1: Create burn-in script
// playwright/scripts/burn-in-changed.ts
import { runBurnIn } from '@seontechnologies/playwright-utils/burn-in'
async function main() {
await runBurnIn({
configPath: 'playwright/config/.burn-in.config.ts',
baseBranch: 'main'
})
}
main().catch(console.error)
// Step 2: Create config
// playwright/config/.burn-in.config.ts
import type { BurnInConfig } from '@seontechnologies/playwright-utils/burn-in'
const config: BurnInConfig = {
// Files that never trigger tests (first filter)
skipBurnInPatterns: [
'**/config/**',
'**/*constants*',
'**/*types*',
'**/*.md',
'**/README*'
],
// Run 30% of remaining tests after skip filter
burnInTestPercentage: 0.3,
// Burn-in repetition
burnIn: {
repeatEach: 3, // Run each test 3 times
retries: 1 // Allow 1 retry
}
}
export default config
// Step 3: Add package.json script
{
"scripts": {
"test:pw:burn-in-changed": "tsx playwright/scripts/burn-in-changed.ts"
}
}Key Points:
- Two-stage filtering: skip patterns, then volume control
skipBurnInPatternseliminates irrelevant filesburnInTestPercentagecontrols test volume (0.3 = 30%)- Custom dependency analysis finds actually affected tests
Example 2: CI Integration
Context: Use burn-in in GitHub Actions for efficient CI runs.
Implementation:
# .github/workflows/burn-in.yml
name: Burn-in Changed Tests
on:
pull_request:
branches: [main]
jobs:
burn-in:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Need git history
- name: Setup Node
uses: actions/setup-node@v4
- name: Install dependencies
run: npm ci
- name: Run burn-in on changed tests
run: npm run test:pw:burn-in-changed -- --base-branch=origin/main
- name: Upload artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: burn-in-failures
path: test-results/Key Points:
fetch-depth: 0for full git history- Pass
--base-branch=origin/mainfor PR comparison - Upload artifacts only on failure
- Significantly faster than full suite
Example 3: How It Works (Process of Elimination)
Context: Understanding the filtering pipeline.
Scenario:
Git diff finds: 21 changed files
├─ Step 1: Skip patterns filter
│ Removed: 6 files (*.md, config/*, *types*)
│ Remaining: 15 files
│
├─ Step 2: Dependency analysis
│ Tests that import these 15 files: 45 tests
│
└─ Step 3: Volume control (30%)
Final tests to run: 14 tests (30% of 45)
Result: Run 14 targeted tests instead of 147 with --only-changed!Key Points:
- Three-stage pipeline: skip → analyze → control
- Custom dependency analysis (not just imports)
- Percentage applies AFTER filtering
- Dramatically reduces CI time
Example 4: Environment-Specific Configuration
Context: Different settings for local vs CI environments.
Implementation:
import type { BurnInConfig } from '@seontechnologies/playwright-utils/burn-in';
const config: BurnInConfig = {
skipBurnInPatterns: ['**/config/**', '**/*types*', '**/*.md'],
// CI runs fewer iterations, local runs more
burnInTestPercentage: process.env.CI ? 0.2 : 0.3,
burnIn: {
repeatEach: process.env.CI ? 2 : 3,
retries: process.env.CI ? 0 : 1, // No retries in CI
},
};
export default config;Key Points:
process.env.CIfor environment detection- Lower percentage in CI (20% vs 30%)
- Fewer iterations in CI (2 vs 3)
- No retries in CI (fail fast)
Example 5: Sharding Support
Context: Distribute burn-in tests across multiple CI workers.
Implementation:
// burn-in-changed.ts with sharding
import { runBurnIn } from '@seontechnologies/playwright-utils/burn-in';
async function main() {
const shardArg = process.argv.find((arg) => arg.startsWith('--shard='));
if (shardArg) {
process.env.PW_SHARD = shardArg.split('=')[1];
}
await runBurnIn({
configPath: 'playwright/config/.burn-in.config.ts',
});
}# GitHub Actions with sharding
jobs:
burn-in:
strategy:
matrix:
shard: [1/3, 2/3, 3/3]
steps:
- run: npm run test:pw:burn-in-changed -- --shard=${{ matrix.shard }}Key Points:
- Pass
--shard=1/3for parallel execution - Burn-in respects Playwright sharding
- Distribute across multiple workers
- Reduces total CI time further
Integration with CI Workflow
When setting up CI with *ci workflow, recommend burn-in for:
- Pull request validation
- Pre-merge checks
- Nightly builds (subset runs)
Related Fragments
ci-burn-in.md- Traditional burn-in patterns (10-iteration loops)selective-testing.md- Test selection strategiesoverview.md- Installation
Anti-Patterns
❌ Over-aggressive skip patterns:
skipBurnInPatterns: [
'**/*', // Skips everything!
];✅ Targeted skip patterns:
skipBurnInPatterns: ['**/config/**', '**/*types*', '**/*.md', '**/*constants*'];❌ Too low percentage (false confidence):
burnInTestPercentage: 0.05; // Only 5% - might miss issues✅ Balanced percentage:
burnInTestPercentage: 0.2; // 20% in CI, provides good coveragePact.js Utils Request Filter
Principle
Use createRequestFilter and noOpRequestFilter from @seontechnologies/pactjs-utils to inject authentication headers during provider verification. The pluggable token generator pattern prevents double-Bearer bugs and separates auth concerns from verification logic.
Rationale
Problems with manual request filters
- Express type gymnastics: Pact's
requestFilterexpects(req, res, next) => voidwith Express-compatible types — but Pact doesn't re-export these types - Double-Bearer bug: Easy to write
Authorization: Bearer Bearer ${token}when the token generator already includes the prefix - Inline complexity: Auth logic mixed with verifier config makes tests harder to read
- No-op boilerplate: Providers without auth still need a pass-through function or
undefined
Solutions
- `createRequestFilter`: Accepts
{ tokenGenerator: () => string }— generator returns raw token value synchronously, filter addsBearerprefix - `noOpRequestFilter`: Pre-built pass-through for providers without auth requirements
- Bearer prefix contract:
tokenGeneratorreturns raw value (e.g.,"abc123"), filter always adds"Bearer "— impossible to double-prefix
Pattern Examples
Example 1: Basic Auth Injection
import { buildVerifierOptions, createRequestFilter } from '@seontechnologies/pactjs-utils';
const opts = buildVerifierOptions({
provider: 'SampleMoviesAPI',
port: '3001',
includeMainAndDeployed: true,
stateHandlers: {
/* ... */
},
requestFilter: createRequestFilter({
// tokenGenerator returns raw token — filter adds "Bearer " prefix
tokenGenerator: () => 'test-auth-token-123',
}),
});
// Every request during verification will have:
// Authorization: Bearer test-auth-token-123Key Points:
tokenGeneratoris synchronous (() => string) — if you need async token fetching, resolve the token before creating the filter- Return the raw token value, NOT
"Bearer ..."— the filter adds the prefix - Filter sets
Authorizationheader on every request during verification
Example 2: Dynamic Token (Pre-resolved)
import { createRequestFilter } from '@seontechnologies/pactjs-utils';
// Since tokenGenerator is synchronous, fetch the token before creating the filter
let cachedToken: string;
async function setupRequestFilter() {
const response = await fetch('http://localhost:8080/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId: process.env.TEST_CLIENT_ID,
clientSecret: process.env.TEST_CLIENT_SECRET,
}),
});
const { access_token } = await response.json();
cachedToken = access_token;
}
const requestFilter = createRequestFilter({
tokenGenerator: () => cachedToken, // Synchronous — returns pre-fetched token
});
const opts = buildVerifierOptions({
provider: 'SecureAPI',
port: '3001',
includeMainAndDeployed: true,
stateHandlers: {
/* ... */
},
requestFilter,
});Example 3: No-Auth Provider
import { buildVerifierOptions, noOpRequestFilter } from '@seontechnologies/pactjs-utils';
// For providers that don't require authentication
const opts = buildVerifierOptions({
provider: 'PublicAPI',
port: '3001',
includeMainAndDeployed: true,
stateHandlers: {
/* ... */
},
requestFilter: noOpRequestFilter,
});
// noOpRequestFilter is equivalent to: (req, res, next) => next()Example 4: Integration with buildVerifierOptions
import { buildVerifierOptions, createRequestFilter } from '@seontechnologies/pactjs-utils';
import type { StateHandlers } from '@seontechnologies/pactjs-utils';
// Complete provider verification setup
const stateHandlers: StateHandlers = {
'user is authenticated': async () => {
// Auth state is handled by the request filter, not state handler
},
'movie exists': {
setup: async (params) => {
await db.seed({ movies: [{ id: params?.id }] });
},
teardown: async () => {
await db.clean('movies');
},
},
};
const requestFilter = createRequestFilter({
tokenGenerator: () => process.env.TEST_AUTH_TOKEN ?? 'fallback-token',
});
const opts = buildVerifierOptions({
provider: 'SampleMoviesAPI',
port: process.env.PORT ?? '3001',
includeMainAndDeployed: process.env.PACT_BREAKING_CHANGE !== 'true',
stateHandlers,
requestFilter,
});
// Run verification
await new Verifier(opts).verifyProvider();Key Points
- Bearer prefix contract:
tokenGeneratorreturns raw value → filter adds"Bearer "→ impossible to double-prefix - Synchronous only:
tokenGeneratormust returnstring(notPromise<string>) — pre-resolve async tokens before creating the filter - Separation of concerns: Auth logic in
createRequestFilter, verification logic inbuildVerifierOptions - noOpRequestFilter: Use for providers without auth — cleaner than
undefinedor inline no-op - Express compatible: The returned filter matches Pact's expected
(req, res, next) => voidsignature
Related Fragments
pactjs-utils-overview.md— installation, utility table, decision treepactjs-utils-provider-verifier.md— buildVerifierOptions integrationcontract-testing.md— foundational patterns with raw Pact.js
Anti-Patterns
Wrong: Manual Bearer prefix with double-prefix risk
// ❌ Risk of double-prefix: "Bearer Bearer token"
requestFilter: (req, res, next) => {
const token = getToken(); // What if getToken() returns "Bearer abc123"?
req.headers['authorization'] = `Bearer ${token}`;
next();
};Right: Use createRequestFilter with raw token
// ✅ tokenGenerator returns raw value — filter handles prefix
requestFilter: createRequestFilter({
tokenGenerator: () => getToken(), // Returns "abc123", not "Bearer abc123"
});Wrong: Inline auth logic in verifier config
// ❌ Auth logic mixed with verifier config
const opts: VerifierOptions = {
provider: 'my-api',
providerBaseUrl: 'http://localhost:3001',
requestFilter: (req, res, next) => {
const clientId = process.env.CLIENT_ID;
const clientSecret = process.env.CLIENT_SECRET;
// 10 lines of token fetching logic...
req.headers['authorization'] = `Bearer ${token}`;
next();
},
// ... rest of config
};Right: Separate auth into createRequestFilter
// ✅ Clean separation — async setup wraps token fetch (CommonJS-safe)
async function setupVerifierOptions() {
const token = await fetchAuthToken(); // Resolve async token BEFORE creating filter
const requestFilter = createRequestFilter({
tokenGenerator: () => token, // Synchronous — returns pre-fetched value
});
return buildVerifierOptions({
provider: 'my-api',
port: '3001',
includeMainAndDeployed: true,
requestFilter,
stateHandlers: {
/* ... */
},
});
}
// In tests/hooks, callers can await setupVerifierOptions():
// const opts = await setupVerifierOptions();_Source: @seontechnologies/pactjs-utils request-filter module, pact-js-example-provider verification tests_
Step 1: Assess Edit Target
STEP GOAL:
Identify which output should be edited and load it.
MANDATORY EXECUTION RULES (READ FIRST):
Universal Rules:
- 📖 Read the complete step file before taking any action
- ✅ Speak in
{communication_language}
Role Reinforcement:
- ✅ You are the Master Test Architect
Step-Specific Rules:
- 🎯 Ask the user which output file to edit
- 🚫 Do not edit until target is confirmed
EXECUTION PROTOCOLS:
- 🎯 Follow the MANDATORY SEQUENCE exactly
CONTEXT BOUNDARIES:
- Available context: existing outputs
- Focus: select edit target
- Limits: no edits yet
MANDATORY SEQUENCE
CRITICAL: Follow this sequence exactly.
1. Identify Target
Ask the user to provide the output file path or select from known outputs.
2. Load Target
Read the provided output file in full.
3. Confirm
Confirm the target and proceed to edit.
Load next step: {nextStepFile}
🚨 SYSTEM SUCCESS/FAILURE METRICS:
✅ SUCCESS:
- Target identified and loaded
❌ SYSTEM FAILURE:
- Proceeding without a confirmed target
Related skills
FAQ
What does bmad-testarch-trace produce?
A requirements-or-journeys-to-tests traceability matrix, a coverage analysis, and a quality gate decision of PASS, CONCERNS, FAIL, or WAIVED.
How does it find requirements to trace?
It resolves a coverage oracle from formal requirements, contract/spec artifacts, external pointers, or synthetic journeys inferred from source.