Testland
Browse all skills & agents

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-author
View source

model-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

  • A complex stateful flow exists (multi-step wizard, checkout funnel, onboarding flow, payment state machine).
  • Ad-hoc test authoring keeps missing edge cases.
  • The team wants exhaustive transition coverage that manual tests can't provide.

How to use

  1. Identify the state space - list every state and every transition (event + guard + action) of one flow. One model per flow, never one giant model for the whole app.
  2. Author the model in a portable YAML format - states carry initial / final flags; transitions carry from / to / event / guard / action.
  3. Validate the model - every transition references a real state, every state is reachable from the initial state, and every state can reach a final state. Validator + coverage-criteria detail in references/model-validation-and-coverage.md.
  4. Pick a coverage criterion (transition / state / all-pairs) and generate the test paths that satisfy it.
  5. Convert each path to a test, or feed the model + paths to ai-test-generator for code generation.

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_browser

Validate

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): 9

Generate 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 -> abandoned

Each 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-patternWhy it failsFix
Modeling the entire app as one giant state machineUnmanageable; combinatorial explosion.One model per major flow (checkout, onboarding, etc.).
Skipping model validationUnreachable states / dead-end states; tests waste time.Validate the model (see references + the worked example).
All-paths coverage on a complex modelPer mbt-wiki (opens in new window): "impractical."Pick a tractable criterion (transition, all-pairs).
Generated tests that don't honor guardsTests fail because preconditions weren't met.The path includes guard checks; tests honor them.
One-shot model authoring; no maintenanceModel drifts from app behavior; tests test the wrong thing.Update model when the app's state machine changes; treat as code.

Limitations

  • Author skill required. Modeling state machines is a craft; initial authoring takes practice.
  • Black-box only. Per mbt-wiki (opens in new window): MBT is black-box; doesn't catch implementation bugs that don't surface in state behavior.
  • Tooling fragmentation. MBT tools (GraphWalker, ModelJUnit, Spec Explorer) have varying capabilities; this skill's YAML format is portable but tool-agnostic.
  • Coverage criteria != correctness. Transition coverage means every transition fires once; doesn't guarantee correctness in all parameter combinations.

References

  • mbt (opens in new window) - Model-based testing definition; finite-state automata; abstract vs executable test suites; coverage criteria; black-box framing.
  • references/model-validation-and-coverage.md - the model validator, the path generator per criterion, and the coverage-criteria caveats.
  • ai-test-generator - downstream consumer of the generated paths.

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:

CriterionWhat it exercises
transitionevery transition at least once
stateevery state visited at least once
all_pairsevery 2-step transition pair
all_pathsevery 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.