Articles

BDD as a Single Source of Truth for Requirements and Tests

ALittleMoronSoftware Testing9 views
A business stakeholder, QA analyst, and developer discuss a BDD shopping-cart scenario connected to two test engines.

I like the idea of BDD for one fairly simple reason: requirements can be written as clear scenarios right away, and those same scenarios can later be run as tests. There is no need to keep one task description for the business, a retelling of that description for the developer, and another retelling for the tester. Everyone can share one language and one scenario describing the expected behavior of the system.

You can write such a scenario at almost any stage of a task. It can be put together during a shared session with the business, an analyst, a tester, and a developer. The analyst can write it while the developer implements the steps. You can do the opposite: the developer writes the scenarios, and the analyst and tester review them separately. Even if BDD scenarios appear only after the code is finished, they can still become executable documentation of the existing behavior.

I get the sense that far fewer people know about BDD than you might expect. Those who do often see it as some separate kind of automated testing for testers. In practice, almost the entire process of working on a requirement can be built around BDD: from the first discussion to running the code in CI.

BDD and Gherkin Are Not Quite the Same Thing

BDD stands for Behavior-Driven Development. The idea is broader than a particular syntax or testing framework. The people involved in development first agree on how the system should behave in a particular situation and then capture that agreement through examples.

Gherkin is a language that makes such examples convenient to write. It has a familiar structure:

  • Given describes the initial state;
  • When describes an action or event;
  • Then describes an observable result.

The complete syntax is covered in the official Gherkin reference. Gherkin itself knows nothing about Python, Go, databases, HTTP, or the architecture of a particular service. It describes behavior in the language of the business domain. The connection to code appears later, in the step definitions.

From this point on, I will occasionally use BDD and the scenarios themselves almost interchangeably. Technically, this is a simplification: BDD is an approach, while Gherkin is one way to write scenarios. For this article, however, we are interested in the practical combination of a readable scenario and executable steps.

Let's Start with a Simple Cart-Clearing Scenario

Suppose a user has a shopping cart containing some items and wants to clear it completely. The first version of the requirement could be written like this:

Feature: Clearing a shopping cart

  Scenario: User clears their cart
    Given the user's cart contains items
    When the user clears the cart
    Then the user's cart is empty

This text can already be shown to almost anyone involved in development. The business sees the expected behavior. The analyst can check whether the requirement was understood correctly. The tester immediately sees the main scenario. The developer understands which initial state needs to be prepared, what must be called, and which result must be checked.

There are no technical details in the scenario. It does not say which HTTP method should be called, which table stores the cart, or which use case is responsible for clearing it. Those are implementation details. Today the step may call an API; tomorrow it may invoke the business logic directly. The expected behavior itself does not change.

This is one of the main reasons to use Gherkin. It pushes you to describe the result in the language of the business domain. If a scenario looks like a sequence of POST /api/v1/cart/clear, an SQL query, and a check against a specific table, it will mostly be readable to developers. Formally, it is still Gherkin, but the shared language has gone missing somewhere along the way.

How a Process Grows Around the Scenario

Let's start with one specific process. It is not the only correct one; it simply makes the full path of a requirement easier to demonstrate.

  1. The business formulates the need for users to clear their carts manually.
  2. An analyst, a tester, and a developer clarify the behavior together and write the scenario in Gherkin.
  3. The scenario is added to the project but temporarily tagged with @skip, because the required behavior does not exist in the code yet.
  4. The developer implements the functionality and connects the textual steps to code.
  5. The tag is removed, and the scenario starts running as part of the main test suite.
  6. The analyst and tester verify that the wording still matches the original requirement, while the developer verifies that every step really performs the required action or check.

The draft scenario could look like this:

Feature: Clearing a shopping cart

  @skip
  Scenario: User clears their cart
    Given the user's cart contains items
    When the user clears the cart
    Then the user's cart is empty

There is one important technical detail here: @skip is not a magic Gherkin command. It is an ordinary tag and a team convention. The test runner must be configured separately to exclude such scenarios. In Behave, for example, this can be done through behave.ini:

[behave]
default_tags = not @skip

Instead of @skip, you can use @not_implemented, @wip, or any other convention. What matters is that a new scenario does not break the main pipeline before development begins and does not get lost somewhere in the task description.

This process gives the developer a fairly deterministic piece of work. There is an initial state, an action, and an expected result. The developer does not have to turn an abstract task description into a set of testable conditions while already writing the code; that set is right there in front of them.

The Process Can Be Almost Anything

I see no reason to turn the previous sequence into a mandatory ritual. Much of BDD's value comes from the fact that responsibilities can be distributed in different ways.

Scenarios can be written during a shared session. The business explains the rule, the analyst helps turn it into precise wording, the tester searches for corner cases, and the developer immediately points out where the requirement is ambiguous or too expensive to implement. Such a meeting can be an ordinary brainstorming session around a single .feature file.

The scenarios can be left to the analyst. They add new BDD scenarios under @skip, after which the developer receives formalized examples of the expected behavior. As the implementation progresses, the developer connects the steps and removes the tag.

Everything can be left to the developer. They write both the scenarios and the implementation, while the analyst and tester review the BDD separately. That review is still useful: they may not read the code, but they can absolutely read a scenario written in the language of the business domain.

The task can be implemented first and the existing behavior described afterward. In this setup, BDD does not drive development from the beginning, but the scenarios still serve as tests and executable documentation. Sometimes this is the only realistic path for a legacy service where the behavior has existed for years but has never been described properly.

None of these approaches automatically makes the scenarios better. A team can hold a shared meeting and collectively miss an obvious corner case. A single strong developer can own the whole thing and produce an excellent description. The specific process depends on the team. What matters more is that the resulting scenario is unambiguous, testable, and truly describes the agreed behavior.

Scenarios Gradually Grow

The first cart-clearing scenario is too abstract. What does “contains items” mean: one item, ten, a hundred? What should happen if the cart is already empty? Does the user need an active session? None of these questions must necessarily be answered in the very first line. The scenario can become more precise together with the requirement.

For example, authentication can be moved into Background, while the number of items can be moved into Examples:

Feature: Clearing a shopping cart

  Background:
    Given the user is authenticated

  Scenario Outline: User clears their cart
    Given the user's cart contains <items_count> items
    When the user clears the cart
    Then there are 0 items left in the user's cart

    Examples:
      | items_count |
      | 0           |
      | 1           |
      | 3           |

Background runs before every scenario in the feature. While there is only one scenario, moving a single step there is not especially useful. I am showing it here as the next stage in the file's growth: when other cart operations appear alongside this one, authentication will not have to be repeated in every scenario. There is no reason to create a Background merely for the sake of having one.

Scenario Outline turns one template into several concrete examples. In this case, the scenario will run for an empty cart, a cart with one item, and a cart with three items. This adds an important input to the requirement: the operation must bring the cart to the same state regardless of the initial number of items.

If it later turns out that an empty cart cannot be cleared or requires a different response, that is a change in behavior. It should be reflected in the BDD scenario rather than hidden inside a step definition. Otherwise, the text will promise one thing while the step's code silently implements another.

Over time, new Given steps, separate scenarios, example tables, and business rules may appear. That is normal evolution. The important thing is to keep a scenario from turning into a wall of twenty technical steps. If understanding a single behavior requires keeping half of the service's internals in your head, the BDD scenarios are no longer doing their main job.

What's Underneath Gherkin in Python

Now let's open the facade and see how text turns into an ordinary test. For Python, we will use Behave. A minimal structure looks roughly like this:

features/
├── clear_cart.feature
├── environment.py
└── steps/
    └── cart_steps.py

The scenario lives in clear_cart.feature. The test infrastructure can be prepared in environment.py. For example:

# features/environment.py
from tests.bdd.cart_driver import build_cart_test_driver


def before_scenario(context, scenario):
    context.cart = build_cart_test_driver()

I am intentionally leaving out the implementation of build_cart_test_driver(). In one project, the driver may talk to a live HTTP API. In another, it may invoke a use case directly. In a third, it may start the application with a test database. To Behave, it is simply an object that lets the steps prepare data, perform an action, and read the result.

The steps themselves could look like this:

# features/steps/cart_steps.py
from behave import given, then, when


@given("the user is authenticated")
def authorize_user(context):
    context.user_id = context.cart.create_user()


@given("the user's cart contains {items_count:d} items")
def fill_cart(context, items_count):
    context.cart.set_items(
        user_id=context.user_id,
        items_count=items_count,
    )


@when("the user clears the cart")
def clear_cart(context):
    context.cart.clear(user_id=context.user_id)


@then("there are {expected_count:d} items left in the user's cart")
def assert_items_count(context, expected_count):
    actual_count = context.cart.get_items_count(user_id=context.user_id)

    assert actual_count == expected_count, (
        f"expected {expected_count} items, got {actual_count}"
    )

There is no special magic here.

In the first Given, we create a user and save their identifier in context. In the second Given, we take the value from Examples and prepare the cart. In When, we pass the saved identifier into a real application call. In Then, we read the actual state and perform an ordinary assert.

context is used to pass state between the steps of one scenario. It can hold the user, an API response, created entities, and any other data for the current test. Behave manages the lifetime of the context, while setup and cleanup can be connected through hooks and fixtures. This is described in detail in the Behave tutorial.

Essentially, these are the same tests a developer already writes. They are simply split into named steps:

prepare data -> execute code -> verify the result
Given        -> When         -> Then

Anything can be hidden behind context.cart.clear():

# API-based version
response = api_client.delete(f"/users/{user_id}/cart")

# Business-logic version
clear_cart_use_case.execute(user_id=user_id)

The specific level should be chosen based on what the team wants to test. The same textual scenario does not have to be permanently tied to HTTP, a database, or a particular class. Behave even allows different sets of step implementations to be run for different test stages, but that is already beyond the main point of this article.

The Same Scenario in Go

Now let's take Go and Godog. There is no need to rewrite the .feature file. Only the code connecting its steps to the application changes.

As in the Python example, I am leaving out the imports, the CartTestDriver interface, and its concrete implementation. This is the same integration seam: behind it may be an API client, a use case, or some other test infrastructure.

The scenario state can be kept in a small structure:

type cartScenario struct {
	driver CartTestDriver
	userID string
}

func (s *cartScenario) authorizeUser() error {
	s.driver = buildCartTestDriver()

	userID, err := s.driver.CreateUser()
	if err != nil {
		return err
	}

	s.userID = userID
	return nil
}

func (s *cartScenario) fillCart(itemsCount int) error {
	return s.driver.SetItems(s.userID, itemsCount)
}

func (s *cartScenario) clearCart() error {
	return s.driver.Clear(s.userID)
}

func (s *cartScenario) assertItemsCount(expectedCount int) error {
	actualCount, err := s.driver.GetItemsCount(s.userID)
	if err != nil {
		return err
	}

	if actualCount != expectedCount {
		return fmt.Errorf(
			"expected %d items, got %d",
			expectedCount,
			actualCount,
		)
	}

	return nil
}

The methods are then registered as step definitions:

func InitializeScenario(ctx *godog.ScenarioContext) {
	scenario := &cartScenario{}

	ctx.Given(
		`^the user is authenticated$`,
		scenario.authorizeUser,
	)
	ctx.Given(
		`^the user's cart contains (\d+) items$`,
		scenario.fillCart,
	)
	ctx.When(
		`^the user clears the cart$`,
		scenario.clearCart,
	)
	ctx.Then(
		`^there are (\d+) items left in the user's cart$`,
		scenario.assertItemsCount,
	)
}

The feature files can then be connected to an ordinary go test run:

func TestFeatures(t *testing.T) {
	suite := godog.TestSuite{
		ScenarioInitializer: InitializeScenario,
		Options: &godog.Options{
			Format:   "pretty",
			Paths:    []string{"features"},
			TestingT: t,
		},
	}

	if suite.Run() != 0 {
		t.Fatal("BDD scenarios failed")
	}
}

The Go code looks different, the checks return error values instead of using Python assertions, and the infrastructure is assembled differently. The scenario, however, remains the same. The business, analysts, and testers do not need to know which language implements the steps.

I am not suggesting that the same check should be written in both Python and Go. The second example is here only to illustrate that the language of the requirements does not depend on the language of the application. One service may use Behave, another Godog, and a third Cucumber for Java or JavaScript. The principle stays the same.

Why BDD Scenarios Can Be a Single Source of Truth

The source of truth here is the scenarios themselves, not the particular place where they are stored. They may live alongside the code, in a separate repository, or in a test management system. They may run locally, in CI, or through a separate service. The storage setup affects the convenience of the process, but it does not change the meaning of BDD.

A scenario serves several purposes at once:

  • it captures a business requirement through a concrete example;
  • it explains the expected behavior to the analyst, tester, and developer;
  • it is connected to executable code;
  • it verifies that the implementation still matches the agreement;
  • it remains documentation that is harder to forget to update because it is actually executed.

If the service behavior differs from the BDD scenario, the implementation is the problem in most cases. A well-written scenario should describe the expected result precisely and without room for two interpretations. Still, it should not be declared infallible. The requirement may have changed, the participants may have missed a condition, or the scenario itself may simply be wrong.

When such a mismatch occurs, the first step is to understand what exactly broke. It may be enough to fix the code. The scenario may need to be updated after a new business decision. If the same kind of issue keeps recurring, the surrounding processes come into play: BDD reviews, checks of the testing infrastructure, and feedback from incidents.

BDD Can Also Be Written So That It Tests Nothing

The mere presence of a .feature file guarantees nothing. There is still code between a nice-looking scenario and the actual behavior of the service, and that code provides plenty of failure points.

A developer can leave a stub behind:

@then("the user's cart is empty")
def assert_cart_is_empty(context):
    pass

Behave will see an implemented step, run the function without an error, and consider it successful. The same thing can happen in Godog if a step definition always returns nil. The scenario will stay green even though it contains no actual check.

The wrong application layer can be called, the test infrastructure can be assembled incorrectly, or an intermediate state can be checked instead of an observable result. The step definitions can be correct while an important scenario is still missing. Analysts and testers can also overlook a corner case or phrase a condition in a way that two developers interpret differently.

BDD therefore requires ordinary engineering discipline:

  • scenarios should be reviewed as requirements;
  • step definitions should be reviewed as test code;
  • Then steps must contain real checks of the result;
  • stubbed and excluded scenarios should not spend years sitting under @skip;
  • after an incident, both the scenarios and the infrastructure used to run them should be checked alongside the code.

There is no need to describe every test through Gherkin. A unit test for a small function, a check of an SQL query, or a load profile is usually easier to leave as ordinary code. BDD works especially well where there is observable business behavior that several participants in development need to understand in the same way.

In the End

I like BDD for more than the Given / When / Then syntax itself. What I like is the ability to describe expected behavior once and then use that description throughout the entire path of a task.

At first, a scenario can be the subject of a discussion. Then it becomes a precise requirement for the developer. After the implementation, it becomes a test and continues to live as documentation. The team does not have to copy someone else's entire process. Scenarios can be written together or separately, before the code or after it. They can run through Python, Go, or another language.

Underneath Gherkin, there is still ordinary data preparation, an application call, and a check of the result. That is why BDD does not require any special magic. It gives those actions a form that can be understood by more people than just the authors of the test code.

If the scenarios are precise and the steps actually test the system, development becomes clearer and more deterministic. Instead of several retellings of the same requirement, the team has one executable example of the expected behavior. For me, that is already a good enough reason to try building at least part of the process around BDD.