GitHub

Tutorial: Writing Acceptance Tests That Guide Delivery

Time: ~15 minutes (6 steps) Platform: macOS, Linux, or Windows Prerequisites: Python 3.10+, Claude Code with nWave installed, Tutorial 1 completed Dependencies: None beyond pytest. Pure Python.


Setup

Run from a directory where you want the tutorial project created (e.g. ~/projects):

curl -fsSL https://raw.githubusercontent.com/nWave-ai/nWave/main/docs/guides/tutorial-writing-tests/setup.py | python3

Prefer to read first? See manual-setup.md.

After setup you should have

  • A md-converter/ directory with src/md_converter/__init__.py, tests/__init__.py, pyproject.toml, and .gitignore
  • A .venv/ virtual environment with pytest installed
  • A clean git repository with one initial commit

What You'll Build

A Markdown-to-HTML converter — built entirely from tests you write yourself.

Before — raw Markdown:

# Hello World

This is **bold** and this is *italic*.

- Item one
- Item two

After — your markdown_to_html() function produces:

<h1>Hello World</h1>
<p>This is <strong>bold</strong> and this is <em>italic</em>.</p>
<ul>
<li>Item one</li>
<li>Item two</li>
</ul>

In Tutorial 1, the tests were written for you. This time, you write them. This is the core skill: define "done" precisely, then let nWave build it. Good tests are the difference between code that works and code that merely compiles.


Step 1 of 6: Open the project (~1 minute)

After running setup, cd md-converter and activate the virtualenv:

cd md-converter
source .venv/bin/activate

Windows users: Replace source .venv/bin/activate with .venv\Scripts\activate.

This time there's no starter repo — you'll write the tests from scratch in the next step.

Next: you'll write your first acceptance test — the simplest possible behavior.


Step 2 of 6: Write Your First Test — The Simplest Behavior (~2 minutes)

Here's the key principle: start with the simplest test, then add complexity. This is the density ramp — the same idea as learning to walk before you run.

Your first test should define the most basic thing the converter does: turn a heading into HTML.

Create tests/test_markdown.py:

# tests/test_markdown.py
from md_converter import markdown_to_html


def test_converts_heading_to_html():
    """A line starting with # becomes an <h1> tag."""
    result = markdown_to_html("# Hello World")
    assert result.strip() == "<h1>Hello World</h1>"

Notice three things about this test:

  1. The name reads as a specification: test_converts_heading_to_html — anyone can tell what the feature does
  2. It tests behavior, not implementation: we don't say "use regex" or "split the string" — just what goes in and what comes out
  3. It's tiny: one input, one output, one assertion

Run it to confirm it fails:

pytest tests/ -v
FAILED tests/test_markdown.py::test_converts_heading_to_html - ImportError

1 failed

Red. Good. That failing test is your specification — it defines one behavior you want.

If you see ModuleNotFoundError instead of ImportError: Both mean the same thing — the function doesn't exist yet. Your test is working correctly.

Next: you'll add tests for bold/italic — defining how inline formatting works.


Step 3 of 6: Add Inline Formatting Tests (~3 minutes)

Now add a second behavior: bold and italic text. This is slightly more complex because it handles inline formatting rather than whole lines.

Add this test to tests/test_markdown.py (below the existing test):

def test_converts_bold_and_italic_text():
    """Double asterisks become <strong>, single become <em>."""
    result = markdown_to_html("This is **bold** and *italic* text.")
    assert "<strong>bold</strong>" in result
    assert "<em>italic</em>" in result

Why in instead of ==? Because we're testing the bold/italic behavior specifically. We don't care whether the surrounding text is wrapped in <p> tags yet — that would test two things at once. Keep tests focused on one behavior.

Now add an edge case. Edge cases define boundaries — they're where bugs hide:

def test_plain_text_returns_as_paragraph():
    """Text with no Markdown syntax wraps in a <p> tag."""
    result = markdown_to_html("Just plain text.")
    assert result.strip() == "<p>Just plain text.</p>"

This test answers: "What happens when there's no Markdown?" That boundary matters — it prevents the converter from mangling regular text.

Run the tests:

pytest tests/ -v
FAILED tests/test_markdown.py::test_converts_heading_to_html
FAILED tests/test_markdown.py::test_converts_bold_and_italic_text
FAILED tests/test_markdown.py::test_plain_text_returns_as_paragraph

3 failed

Three red tests, three behaviors specified. Each test name reads like a requirement:

  1. Converts headings to HTML
  2. Converts bold and italic text
  3. Wraps plain text in paragraphs

Next: you'll add one more test for lists, completing your specification.


Step 4 of 6: Add a List Conversion Test (~2 minutes)

The final behavior: unordered lists. Lines starting with - become list items.

Add this to tests/test_markdown.py:

def test_converts_unordered_list():
    """Lines starting with '- ' become <ul> with <li> items."""
    markdown = "- First\n- Second\n- Third"
    result = markdown_to_html(markdown)
    assert "<ul>" in result
    assert "<li>First</li>" in result
    assert "<li>Second</li>" in result
    assert "<li>Third</li>" in result
    assert "</ul>" in result

Run all four tests:

pytest tests/ -v
FAILED tests/test_markdown.py::test_converts_heading_to_html
FAILED tests/test_markdown.py::test_converts_bold_and_italic_text
FAILED tests/test_markdown.py::test_plain_text_returns_as_paragraph
FAILED tests/test_markdown.py::test_converts_unordered_list

4 failed

Four red tests. Let's look at what you've built — your complete test file should look like this:

# tests/test_markdown.py
from md_converter import markdown_to_html


def test_converts_heading_to_html():
    """A line starting with # becomes an <h1> tag."""
    result = markdown_to_html("# Hello World")
    assert result.strip() == "<h1>Hello World</h1>"


def test_converts_bold_and_italic_text():
    """Double asterisks become <strong>, single become <em>."""
    result = markdown_to_html("This is **bold** and *italic* text.")
    assert "<strong>bold</strong>" in result
    assert "<em>italic</em>" in result


def test_plain_text_returns_as_paragraph():
    """Text with no Markdown syntax wraps in a <p> tag."""
    result = markdown_to_html("Just plain text.")
    assert result.strip() == "<p>Just plain text.</p>"


def test_converts_unordered_list():
    """Lines starting with '- ' become <ul> with <li> items."""
    markdown = "- First\n- Second\n- Third"
    result = markdown_to_html(markdown)
    assert "<ul>" in result
    assert "<li>First</li>" in result
    assert "<li>Second</li>" in result
    assert "<li>Third</li>" in result
    assert "</ul>" in result

Read these test names aloud. They're a specification:

  • Converts heading to HTML
  • Converts bold and italic text
  • Wraps plain text as paragraphs
  • Converts unordered lists

That's what "done" looks like. You didn't describe how to parse Markdown. You described what correct output looks like for each case.

Commit your tests before handing off to nWave:

git add -A && git commit -m "test: acceptance tests for markdown-to-html converter"

Next: you'll hand these tests to nWave and watch it build the implementation.


Step 5 of 6: Let nWave Deliver (~5 minutes)

AI output varies between runs. Your code will differ from the examples below. That is normal. We define success by what the code does (tests pass), not what the agent says.

Open Claude Code and start the delivery:

claude

Then type this Claude Code command (not a terminal command):

/nw-deliver "Markdown-to-HTML converter"

This takes 3-5 minutes. You'll see phases scroll by:

● nw-solution-architect(Fill roadmap skeleton)    ← Planning (~30s)
● nw-software-crafter(Execute step 01-01)         ← Writing code (~2-3 min)
● nw-software-crafter-reviewer(Adversarial review) ← Quality check (~1 min)

The agent reads your four tests, plans an implementation, and builds code that makes each test pass — one at a time, following the TDD cycle.

Messages you can safely ignore

Lines like these are normal internal coordination, not errors:

  • PreToolUse:Task hook error — DES validation checkpoint
  • DES_MARKERS_MISSING — Internal formatting check, auto-retries

Verify success

When delivery completes, run:

pytest tests/ -v

Expected:

tests/test_markdown.py::test_converts_heading_to_html PASSED
tests/test_markdown.py::test_converts_bold_and_italic_text PASSED
tests/test_markdown.py::test_plain_text_returns_as_paragraph PASSED
tests/test_markdown.py::test_converts_unordered_list PASSED

4 passed

All green. Try the converter:

PYTHONPATH=src python3 -c "
from md_converter import markdown_to_html
print(markdown_to_html('# Hello\n\nThis is **bold** and *italic*.\n\n- One\n- Two'))
"

You should see something like:

<h1>Hello</h1>
<p>This is <strong>bold</strong> and <em>italic</em>.</p>
<ul>
<li>One</li>
<li>Two</li>
</ul>

Your exact output may differ slightly — what matters is that all four Markdown features are converted.

If tests fail after delivery: Run /nw-deliver again — it resumes from where it left off and fixes remaining failures.

If you see no output for 2+ minutes: Check the Claude Code status bar at the bottom. A pulsing indicator means it's still working.

Next: a recap of the test-writing principles you just used.


Step 6 of 6: What Just Happened (~1 minute)

You started with an empty project and wrote four acceptance tests. nWave turned them into working code. But the real skill you practiced was writing tests that guide delivery.

The Five Principles You Applied

  1. Test the WHAT, not the HOW — Your tests said "heading becomes <h1>", never "use regex to parse". The agent chose its own implementation.

  2. Descriptive names are specificationstest_converts_heading_to_html reads like a requirement. Anyone (human or AI) can understand what "done" means.

  3. Start simple, add complexity — Heading first, then inline formatting, then lists. Each test built on the previous concept. This is the density ramp.

  4. Edge cases define boundariestest_plain_text_returns_as_paragraph asks "what happens with no Markdown?" That boundary prevents bugs.

  5. Keep tests independent — Each test runs alone. No test depends on another test's state. This lets nWave tackle them one at a time.

What You Built

  • A complete project from scratch (no starter repo)
  • 4 acceptance tests that serve as a living specification
  • A working Markdown-to-HTML converter you never had to implement
  • Atomic git commits tracing every TDD step

What You Didn't Have to Do

  • Write the implementation
  • Figure out how to parse Markdown syntax
  • Decide on architecture (functions, classes, modules)
  • Write additional unit tests (nWave wrote them from your acceptance tests)
  • Review the code (the reviewer agent did it)

Next Steps


Troubleshooting

Symptom Fix
ImportError when running tests before delivery Expected — the function doesn't exist yet. That's the point of red tests.
ModuleNotFoundError after delivery Make sure your venv is active and pyproject.toml has pythonpath = ["src"]
Tests still failing after delivery Run /nw-deliver again — it resumes and fixes remaining failures
Agent creates files in wrong location Check that src/md_converter/__init__.py exists before delivery
Want to start over git stash && git checkout main then recreate the test file
Delivery takes more than 10 minutes Normal for slower machines. Check the Claude Code status bar for activity.

Last Updated: 2026-02-17