executable-stories-pytest — Story API
Setup
from executable_stories import story
def test_applies_discount_code():
story.init("Applies discount code", tags=["checkout"], ticket="CART-42", covers=["src/checkout.py"])
story.given("a cart with items totaling $100")
cart = create_cart([{"name": "Shirt", "price": 100}])
story.when("a 20% discount code is applied")
apply_discount(cart, "SAVE20")
story.then("the total is $80")
assert cart.total == 80
story.and_("the discount is shown in the summary")
assert len(cart.discounts) == 1
File naming: test_*_story.py. Output is automatic via the pytest plugin — .executable-stories/raw-run.json is written after all tests. Override with EXECUTABLE_STORIES_OUTPUT env var.
Note: and_ has a trailing underscore because and is a Python keyword.
Core Patterns
Step markers with Auto-And conversion
First call to given(), when(), or then() renders the keyword as-is. Subsequent calls to the same keyword auto-convert to "And". Explicit and_() always renders "And". Explicit but() always renders "But" and never auto-converts.
def test_blocks_suspended_user_login():
story.init("Blocks suspended user login")
story.given("the user account exists") # renders "Given"
story.given("the account is suspended") # renders "And" (auto-converted)
story.when("the user submits valid credentials")
story.then("the user sees an error message")
story.but("the user is not logged in") # renders "But" (always)
story.but("no session is created") # renders "But" (always)
Step aliases
AAA pattern: story.arrange() (Given), story.act() (When), story.assert_() (Then).
Additional: story.setup(), story.context() (Given), story.execute(), story.action() (When), story.verify() (Then).
Note: The Then alias is assert_() because assert is a Python keyword.
Doc entries attached to steps
def test_processes_payment():
story.init("Processes payment")
story.given("a valid payment request")
story.json("Request payload", {"amount": 50, "currency": "USD"})
story.kv("Gateway", "stripe")
story.when("the payment is submitted")
story.code("Response", '{ "status": "ok" }', lang="json")
story.then("the order is confirmed")
story.table("Order summary",
columns=["Item", "Qty", "Price"],
rows=[["Widget", "2", "$25"]],
)
story.link("API docs", "https://docs.example.com/payments")
story.note("Payment processed in sandbox mode")
Embedded HTML
Embed generated HTML (charts, single-file reports, skill/agent output) in an
always-sandboxed iframe in the report. Exactly one of path / url / content
is required; optional title and height (number → px, string passed through; default 400px).
story.html(content=chart_html, title="Latency chart", height=600)
story.html(url="https://dash.example.com/run/42", height=600)
story.html(path="./reports/summary.html", title="Summary")
Source guidance: generated/ephemeral HTML (a skill writing to a temp dir) → pass content
(captured now, survives temp-dir cleanup). Stable on-disk artifact → pass path (inlined at format time).
The embedded HTML must be self-contained (a single file). Local files are inlined as the
iframe's srcdoc; relative references to sibling CSS/JS/images are not rewritten, so a multi-file
report renders broken. Use a single-file/inline mode or pass markup via content. Directory bundling is planned.
Sandbox-safe contract: renders inside <iframe sandbox="allow-scripts"> (opaque origin, no
allow-same-origin). CDN scripts (Tailwind, Mermaid) and inline DOM scripts work; localStorage/
sessionStorage/cookies throw SecurityError (and an unguarded access aborts the rest of that
script block) — guard with try/catch or avoid. No window.top/parent access; no popups.
Inline docs via docs parameter
story.given("valid credentials", docs=[
{"kind": "kv", "label": "username", "value": "alice", "phase": "runtime"},
{"kind": "note", "text": "Password masked for security", "phase": "runtime"},
])
Step wrappers with timing
def test_fetches_user_profile():
story.init("Fetches user profile")
story.given("a registered user")
profile = story.fn("When", "the profile is fetched", lambda: fetch_profile("user-123"))
story.expect("the profile contains the correct name", lambda: (
assert profile.name == "Alice"
))
fn and expect wrap a callable with automatic timing. Exceptions propagate after duration is recorded. Both return the callable's result.
Init options
story.init("My scenario",
tags=["smoke", "auth"],
ticket="AUTH-42", # or ticket=["AUTH-42", "AUTH-43"]
meta={"priority": "high"},
trace_url_template="https://jaeger.example.com/trace/{traceId}",
)
Manual step timing
story.given("a step to time")
token = story.start_timer()
# ... work ...
story.end_timer(token)
Attachments
story.attach("debug.log", "text/plain", path="/tmp/debug.log")
story.attach("config", "application/json", body='{"key":"val"}', encoding="IDENTITY")
Common Mistakes
CRITICAL Missing story.init() before steps
Wrong:
def test_my_scenario():
story.given("something") # No context — steps are lost
Correct:
def test_my_scenario():
story.init("My scenario")
story.given("something")
story.init() creates the thread-local context. Without it, step calls have no effect.
Source: packages/executable-stories-pytest/src/executable_stories/_story_api.py
HIGH Using and or assert instead of and_ or assert_
Wrong:
story.and("also this") # SyntaxError: 'and' is a Python keyword
story.assert("result") # SyntaxError: 'assert' is a Python keyword
Correct:
story.and_("also this") # Trailing underscore
story.assert_("result") # Trailing underscore
Python keywords and and assert cannot be used as method names. The trailing underscore follows PEP 8 naming conventions for keyword conflicts.
Source: packages/executable-stories-pytest/src/executable_stories/_story_api.py
MEDIUM Parameterized scenarios with pytest.mark.parametrize
import pytest
@pytest.mark.parametrize("a,b,expected", [(1, 2, 3), (2, 3, 5)])
def test_addition(a, b, expected):
story.init(f"Adding {a} and {b}")
story.given(f"two numbers {a} and {b}")
story.when("the numbers are added")
result = a + b
story.then(f"the result is {expected}")
assert result == expected
Each parametrize iteration produces a separate scenario. Use f-strings in the scenario title so each has a distinct name.
Source: packages/executable-stories-pytest/src/executable_stories/_plugin.py
Source: jagreehal/executable-stories — distributed by TomeVault.