model-based-test-graph-author
Build-an-X workflow for model-based testing (MBT) per the canonical definition - authors a state-machine model of the SUT (states + transitions + guards + actions), validates the model is connected and complete, and feeds the model to a test generator (manual / AI / dedicated MBT tool) that produces test paths covering each transition. Per Wikipedia (en.wikipedia.org/wiki/Model-based_testing): MBT "leverages model-based design for designing and possibly executing tests." Use when a complex stateful flow (checkout, onboarding, multi-step wizard) needs systematic coverage that ad-hoc tests miss.
Install with skills.sh (any agent)
npx skills add testland/qa --skill model-based-test-graph-authormodel-based-test-graph-author
Overview
Per mbt-wiki (opens in new window):
"Model-based testing is an approach to testing that leverages model-based design for designing and possibly executing tests. A model typically represents either the desired behavior of a system under test or testing strategies themselves."
"Often the model is translated to or interpreted as a finite-state automaton or a state transition system." (mbt-wiki (opens in new window))
The state machine is the artifact; test paths are derived from it. This skill produces the state-machine model (input to MBT tools or AI test generators).
When to use
How to use
Worked example - checkout flow
State space
List the states and the transitions between them. Each transition names the event that fires it plus any guard and action:
States:
- empty_cart
- cart_with_items
- shipping_entered
- payment_entered
- confirmed
- failed_payment
- abandoned
Transitions:
empty_cart -> cart_with_items [add_item]
cart_with_items -> empty_cart [remove_all_items]
cart_with_items -> shipping_entered [enter_shipping]
shipping_entered -> payment_entered [enter_payment]
payment_entered -> confirmed [submit; payment_succeeds]
payment_entered -> failed_payment [submit; payment_fails]
failed_payment -> payment_entered [retry_payment]
failed_payment -> abandoned [give_up]
cart_with_items -> abandoned [close_browser]Per mbt-wiki (opens in new window): "The automaton represents possible system configurations, and a possible execution path can serve as a test case."
Portable model
Author the same machine in tool-agnostic YAML - this is the artifact MBT tools and AI generators consume:
# models/checkout.yaml
states:
- id: empty_cart
initial: true
- id: cart_with_items
- id: shipping_entered
- id: payment_entered
- id: confirmed
final: true
- id: failed_payment
- id: abandoned
final: true
transitions:
- from: empty_cart
to: cart_with_items
event: add_item
guard: "item.in_stock"
action: "cart.add(item)"
- from: cart_with_items
to: shipping_entered
event: enter_shipping
guard: "valid_address"
action: "session.shipping = address"
- from: shipping_entered
to: payment_entered
event: enter_payment
guard: "valid_card"
- from: payment_entered
to: confirmed
event: submit
guard: "payment_succeeds"
- from: payment_entered
to: failed_payment
event: submit
guard: "payment_fails"
- from: failed_payment
to: payment_entered
event: retry_payment
- from: failed_payment
to: abandoned
event: give_up
- from: cart_with_items
to: abandoned
event: close_browserValidate
Run the validator (in references/model-validation-and-coverage.md). For the checkout model it confirms no unreachable or deadlock states and prints the counts that bound path generation:
States: 7; Transitions: 9
Possible test paths (transition coverage): 9Generate paths
Coverage criterion: every transition is exercised at least once (transition coverage). A greedy walk that prefers untraversed edges covers all nine transitions in 3 paths:
Path 1: empty_cart -> add_item -> cart_with_items -> enter_shipping -> ... -> confirmed
Path 2: empty_cart -> add_item -> cart_with_items -> ... -> submit -> payment_fails -> failed_payment -> retry_payment -> ... -> confirmed
Path 3: empty_cart -> add_item -> cart_with_items -> close_browser -> abandonedEach path is one test scenario.
Convert a path to a test
Each path becomes a test; guards on the path become the fixtures and assertions of that test:
// e2e/checkout/path-1.spec.ts (auto-generated from model)
test('Path 1 - happy path', async ({ page }) => {
// empty_cart -> cart_with_items
await page.goto('/products/BOOK-001');
await page.getByRole('button', { name: /add to cart/i }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
// cart_with_items -> shipping_entered
await page.goto('/checkout');
await page.getByLabel(/address/i).fill('123 Main St');
await page.getByRole('button', { name: /continue/i }).click();
// shipping_entered -> payment_entered
await page.getByLabel(/card/i).fill('4242 4242 4242 4242');
await page.getByRole('button', { name: /continue/i }).click();
// payment_entered -> confirmed
await page.getByRole('button', { name: /place order/i }).click();
await expect(page.getByRole('heading', { name: /order confirmed/i })).toBeVisible();
});Feed an AI generator (optional)
The model + paths can drive ai-test-generator instead of hand-authoring each test:
input:
model: models/checkout.yaml
paths: generated/paths.json
framework: playwright
page_objects: src/page-objects/The LLM generates test code per path; review each generated test before merge. The model constrains the LLM - better than free-form generation.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Modeling the entire app as one giant state machine | Unmanageable; combinatorial explosion. | One model per major flow (checkout, onboarding, etc.). |
| Skipping model validation | Unreachable states / dead-end states; tests waste time. | Validate the model (see references + the worked example). |
| All-paths coverage on a complex model | Per mbt-wiki (opens in new window): "impractical." | Pick a tractable criterion (transition, all-pairs). |
| Generated tests that don't honor guards | Tests fail because preconditions weren't met. | The path includes guard checks; tests honor them. |
| One-shot model authoring; no maintenance | Model drifts from app behavior; tests test the wrong thing. | Update model when the app's state machine changes; treat as code. |
Limitations
References
Model validation and coverage-criteria selection
View source (opens in new window)Model validation and coverage-criteria selection
Deep reference for the model-based-test-graph-author SKILL.md. Consult when validating a state-machine model or choosing which coverage criterion drives path generation.
Validate the model
A model with unreachable or dead-end states wastes generation effort. Validate it before generating any paths:
# scripts/validate-model.py
import yaml
model = yaml.safe_load(open('models/checkout.yaml'))
states = {s['id'] for s in model['states']}
initial = next(s['id'] for s in model['states'] if s.get('initial'))
finals = {s['id'] for s in model['states'] if s.get('final')}
# Check 1: every transition references valid states
for t in model['transitions']:
assert t['from'] in states, f"Unknown from-state: {t['from']}"
assert t['to'] in states, f"Unknown to-state: {t['to']}"
# Check 2: every state is reachable from initial
reachable = {initial}
changed = True
while changed:
changed = False
for t in model['transitions']:
if t['from'] in reachable and t['to'] not in reachable:
reachable.add(t['to'])
changed = True
unreachable = states - reachable
assert not unreachable, f"Unreachable states: {unreachable}"
# Check 3: every state can reach a final state
for s in states - finals:
if not can_reach_final(s, model, finals):
print(f"Warning: state {s} cannot reach a final state (deadlock)")
# Check 4: report the counts that bound path generation
print(f"States: {len(states)}; Transitions: {len(model['transitions'])}")
print(f"Possible test paths (transition coverage): {len(model['transitions'])}")The four checks are: (1) every transition references valid states, (2) every state is reachable from the initial state, (3) every non-final state can reach a final state (deadlock guard), and (4) print the state / transition counts.
Generate test paths per criterion
The coverage criterion decides how many paths get generated:
def generate_paths(model, criterion='transition'):
"""Returns list of paths (each a list of transitions)."""
if criterion == 'transition':
# Greedy: walk the graph, prefer untraversed edges
return greedy_transition_cover(model)
elif criterion == 'state':
return paths_visiting_each_state(model)
elif criterion == 'all_pairs':
return all_2_step_pairs(model)
# ...Criteria, from cheapest to most exhaustive:
| Criterion | What it exercises |
|---|---|
transition | every transition at least once |
state | every state visited at least once |
all_pairs | every 2-step transition pair |
all_paths | every path up to length N (rarely viable) |
Why not all-paths
"Because systems can have enormous numbers of possible configurations, finding all paths is impractical. Instead, test criteria are needed to guide the selection of a finite, appropriate number of test cases." (mbt-wiki (opens in new window))
"Model-based testing qualifies as black-box testing since test suites are derived from models and not from source code." (mbt-wiki (opens in new window))
The team picks the coverage criterion (transition, state, all 2-step pairs, all paths up to length N); MBT generates the matching paths.
Related skills
ai-spec-coverage-mapper
Build-an-X workflow that uses an LLM to map existing tests to spec sections - given a spec doc + the test suite, the LLM identifies which tests cover which sections, surfaces uncovered sections (gap), and recommends specific tests to add. Output is a coverage matrix per spec ID. Scope is mapping tests that already exist and naming the gaps, not authoring tests for new acceptance criteria. Use when a spec doc and a test suite both exist but nobody can say which requirements are actually covered - before a release sign-off, an audit, or a decision about where to spend the next round of test effort.
ai-test-generator
Generates tests from natural-language specs (acceptance criteria, user stories, requirements) using an LLM, with confidence scoring per test case (LLM self-assessment plus heuristics: assertion quality, naming, completeness), batching uncertain cases for human review, and integration with the team's existing test framework. Use when the user asks to generate unit tests from acceptance criteria, convert user stories to test cases, automate test creation from requirements, or augment a spec-driven test suite with AI-generated stubs that are then curated before merge.
input-domain-coverage-audit
Audits a test file's input-domain coverage per entry point across three axes: equivalence partitioning (clustering the literal values the tests actually pass, to infer which partitions are exercised), boundary value analysis (recorded n/a when the entry point declares no bound), and error/negative-path coverage (classifying every matcher as positive or negative and computing the negative-assertion ratio). Emits a PASS / SHALLOW / N/A verdict per axis per entry point, with the evidence that produced it. Owns whether the test data spans the input space, not whether an individual assertion is specific enough: matcher specificity belongs to `test-code-conventions`. Use when a test file's cases all look alike - every argument the same shape, every response a success, no thrown-error case - and the suite needs a defensible answer on whether it exercises more than one equivalence class before it is approved.