Skip to main content

Narrative-Driven Development: BDD + TDD + Living Documentation in One Workflow

· 7 min read
William "dethstrobe" Johnson
Ex-Googler, Founder Null Sweat, Deadly Ninja Cyborg from the Future

The best documentation is always up to date. The best way to keep docs up to date is to make them executable.

I've spent six months building Test2Doc—turning Playwright tests into documentation—and writing a tutorial that puts it into practice. Here are the patterns I discovered for writing tests that read like user guides.

If you're new to Test2Doc, here's the quick pitch on why this matters:

  • Documentation automatically stays in sync with the code.
    • Tests validate functionality
    • Tests document functionality
    • Tests generate documentation, explaining functionality to the layperson
  • Documentation is never out of date
  • Software is more reliable when tests validate functionality
  • Software is easier to refactor without needing to worry about breaking functionality
  • Using accessible selectors validates the site’s accessibility

But the question remains: how do you write effective docs using Test2Doc?

Enter my buzzword acronym to try and solve this problem:

Narrative-Driven Development

Narrative-Driven Development (aka NDD) is the idea of writing tests more like a tutorial. Describing how to use the software.

It is similar to Gherkin.

To understand NDD, let's first understand what came before with Gherkin.

What is Gherkin?

Gherkin is a popular way to write tests. It's associated with the Cucumber framework and Behavior-Driven Development (aka BDD).

It follows a similar pattern to this:

  • Given: The setup of your test
  • When: The input into the test case, like a user action.
  • Then: The expected outcome of the input, usually associated with an assertion

Champions of Gherkin and BDD cite this framework as being human readable. But I personally never entirely bought into it. Some of the jargon ends up having a slightly different contextual meaning than what a layperson would expect, leading to sentences that sound like English but feeling disconnected from their actual meaning.

Gherkin ends up creating documentation artifacts that are not very understandable to the layperson.

If you're already familiar with it, and it works for you and your team, keep using it. Pragmatism and productivity over reinventing the wheel.

But if you're like me and think there can be a better way, let's talk about Narrative-Driven Development.

How to Narratively Drive Development?

This is more similar to TDD and BDD than it is different.

  • Like Test-Driven Development, you write the tests first.
  • Like Behavior-Driven Development, you focus on behavior.
  • Unlike both, you optimize for documentation readers.

It follows the same red-green-refactor cycles. It follows the same focus on user scenarios. But the output is comprehensive documentation outlining how to use the software with screenshots to offer context on exactly what is happening.

Guided Testing (How-To Workflows)

When writing a Guided Test you write the test as a How-to guide. This explains user workflows on how to accomplish a task that requires multiple steps to complete.

Not to be overly simplistic about this, but here are 3 guidelines to follow:

  1. Write Steps Like Instructions
  2. Use Context Markers
  3. Add Screenshots for Extra Context

Let's go over these guidelines with some simplistic examples.

Rule 1: Write Steps Like Instructions

Don't write:

test("delete functionality", async ({ page }) => {
await page.goto("/app/123")
await page.click("#delete-btn")
await page.click("#confirm")
expect(page.url()).toBe("/app")
})

Do write:

test("Delete an application", async ({ page }, testInfo) => {
await test.step("On the Application Detail page, ", async () => {
await page.goto("/applications/123")
})

await test.step("click the Delete button to open a confirmation dialog.", async () => {
const deleteBtn = page.getByRole("button", { name: "Delete" })
await screenshot(testInfo, deleteBtn)
await deleteBtn.click()
})

await test.step("Clicking 'Yes, Delete It' removes the application.", async () => {
await page.getByRole("button", { name: "Yes, Delete It" }).click()
await expect(page).toHaveURL("/applications")
await expect(page.getByRole("row", { name: "123" })).not.toBeVisible()
})
})
Rule 2: Use Context Markers

Similar to Gherkin, we can write tests following a similar pattern.

Start test steps with consistent phrases that set the scene:

  • "On the [Page] page, " - Similar to Given. We're giving the user the context to know where to start.
  • "When [condition], " - This is adding extra context to Given, not to be confused with the When step in Gherkin.
  • "[action] on [element]" - This is similar to When in that we're asserting user input on the site.
  • "[side-effect]" - This is similar to Then. This marks the expected outcome, confirming the user's action was successful.

Don't follow these to the letter. It won't make for good docs. Just use it as a guideline to structure the test. Something similar to this:

await test.step("On the Settings page, ", async () => {
await page.goto("/settings")
})

await test.step("When you want to change your email, ", async () => {
// Don't actually change it yet, just set context
})

await test.step("click the Edit button next to your email address.", async () => {
// Now do the action
})

await test.step("Type your new email into the text input.", async () => {
// More actions to update the email.
})

await test.step("Click the submit button to update your email.", async () => {
// Last action with assertion to confirm change was made.
})
Rule 3: Add Screenshots for Extra Context

To help remove ambiguity in the documentation and clarify what we're testing/documenting you should take screenshots.

Rule 3.1: Screenshot Before Actions

Show users what to look for before they need to click it.

await test.step("click the Save button.", async () => {
const saveButton = page.getByRole("button", { name: "Save" })

// Screenshot FIRST - show them what to find
await screenshot(testInfo, saveButton, {
annotation: { text: "Click here" }
})

// Then click
await saveButton.click()
})
Rule 3.2: Screenshot After Actions That Need Extra Context

Most actions should hopefully be self-explanatory, but of course when it's not you should add a screenshot to highlight the side effect so that the user knows they performed the action correctly.

await test.step("Click the date button to open the date picker.", async () => {
await page.getByRole("button", { name: "Select Date" }).click()
const datePicker = page.getByRole("dialog")
await expect(datePicker).toBeVisible()
await screenshot(testInfo, datePicker)
})

Showcase Testing (Feature Documentation)

Sometimes you don't need to guide users through a workflow—you just need to document what's available on a page. I'm calling these Showcase Tests.

These tests simply verify and document elements that appear:

await test.step("The Application Detail page displays:", async () => {
await page.goto("/applications/123")
})

await test.step("- Button linking to the original job posting", async () => {
const jobLink = page.getByRole("link", { name: "View Application" })
await expect(jobLink).toHaveAttribute("href", "https://example.com/jobs/123")
})

await test.step("- Salary range for the position", async () => {
await expect(page.getByText("$80k - $120k")).toBeVisible()
})

This is where we can also take advantage of Test2Doc turning step titles into markdown. So we can give each step a - or * to make them an item in an unordered list. Or 1. to make an ordered list.

Next Step in Driving Development

From a purely technical and conceptual standpoint, this isn't drastically different from Test-Driven Development or Behavior-Driven Development. The whole point of giving this a different name is to help differentiate this approach from what came before.

While we can always argue the semantics, use case, or any number of other arbitrary reasons why this is or is not different from what came before, I do think this will help frame the mindset to help understand how to write better tests that will output better documentation that can be used by non-technical users and stakeholders to help actually use your software.

If you want something more in-depth and more real-world examples, give my RedwoodSDK tutorial a shot.


TL;DR: Write your test steps as if you're explaining the app to a friend. That's it.