Testing is often seen as a technical safeguard — a way to ensure code correctness. But what if tests could do more than that? What if they could express intent, document behavior, and communicate meaning across developers, testers, and product owners alike?

Introduction#

That’s the promise of Behavior-Driven Development (BDD) — a practice that elevates testing from verifying logic to describing how the system behaves.

In Go, the tool that brings this philosophy to life is GoDog, the Cucumber-inspired framework for writing executable specifications using natural-language syntax.

Yet before diving into GoDog, here is my thoughton a long-standing misconception I have come across multiple times: BDD is not about Gherkin syntax — it’s a mindset.

BDD Is Not Gherkin — It’s a Mindset#

BDD was introduced by Dan North in 2003 as an evolution of Test-Driven Development (TDD). He noticed that teams often struggled with TDD because the term “test” implied verification, not behavioral design.

BDD reframed the question:

Instead of How do I test this function? ask What behavior am I expecting from the system?

If I was to summarize it in one sentence:

BDD is TDD done right.

Nothing stops you from writing non-BDD tests with Gherkin.The syntax doesn’t make your tests behavioral — your mindset does.

If your Gherkin scenarios simply mirror technical operations (click button, expect 200 OK), you’re missing the spirit of BDD. But when you use it to express intent (user should see confirmation when signup succeeds), you’re speaking the language of behavior.

So let’s explore how to do that right.

Understanding the Gherkin Syntax#

Gherkin is a structured, readable way to describe system behavior using a few key keywords:

Keyword Purpose Example
Feature Describes a system capability Feature: User authentication
Scenario Describes one example of behavior Scenario: Successful login
Given Precondition/setup Given a user exists
When Action performed When the user logs in
Then Expected outcome Then they should see their dashboard
And / But Logical connectors And they should receive a welcome message

A Simple Example#

Here’s a simple example:

Feature: Shopping Basket
  Scenario: Adding a product
    Given I have an empty basket
    When I add "2" "Apples"
    Then the basket total should be "2"
  • This is a story behaviour that anyone can understand

Introducing GoDog#

GoDog is the Cucumber for Go — it executes your .feature files by mapping each step to Go functions called step definitions.

A typical GoDog project has this structure:

project/
├── features/
   ├── signin.feature
   └── signup.feature
├── steps/
   └── auth_steps_test.go
└── go.mod

Below is a realworld BDD feature , referenced from one of my repo

signin.feature#

Feature: Signin Feature
  I want to Signin to Fedha API

  Background:
    Given user detail:
      |name         |email             |password|
      |Jonathan Doe |johndoe@mail.com  |abcdefg |
    Given I have load fedha application
    And the fields "email" and "password" are empty

  Scenario: Error on empty fields
    When I click on "Sign In"
    And both fields "email" and "password" have errors
    Then failed to login

  Scenario: Login Successful
    When I type "email" and "password"
    And I click on "Sign In"
    Then the user name should be "Jonathan Doe"

Implementing Step Definitions#

  • Each line in a .feature file maps to a Go function.For instance:
func (a *App) iClickOn(button string) error {
    if button == "Sign In" {
        a.response = a.client.SignIn(a.email, a.password)
    }
    return nil
}

func (a *App) userNameShouldBe(expected string) error {
    if a.user.Name != expected {
        return fmt.Errorf("expected user name %s, got %s", expected, a.user.Name)
    }
    return nil
}

And these are registered in the FeatureContext:

func FeatureContext(s *godog.Suite) {
    app := NewApp()
    s.Step(`^I click on "([^"]*)"$`, app.iClickOn)
    s.Step(`^the user name should be "([^"]*)"$`, app.userNameShouldBe)
}

When you run godog run, GoDog reads your .feature files and executes the mapped step definitions.

Managing Shared State Between Steps#

One of the trickiest challenges in BDD testing is sharing state between steps.

For example, your Given step might set up a user, and your When step needs to act on that same user. If you use global variables, you’ll quickly run into concurrency issues.

  • The recommended pattern is to use context.Context and a custom struct.
type SharedState struct {
    Email    string
    Password string
    Response string
}

var sharedKey = struct{}{}

func getSharedState(ctx context.Context) (context.Context, *SharedState) {
    v := ctx.Value(sharedKey)
    if v == nil {
        v = &SharedState{}
        ctx = context.WithValue(ctx, sharedKey, v)
    }
    return ctx, v.(*SharedState)
}

Then, use this inside your steps:

func givenUserDetail(ctx context.Context, name, email, password string) (context.Context, error) {
    ctx, state := getSharedState(ctx)
    state.Email = email
    state.Password = password
    return ctx, nil
}

func whenUserClicksSignIn(ctx context.Context) (context.Context, error) {
    ctx, state := getSharedState(ctx)
    state.Response = simulateLogin(state.Email, state.Password)
    return ctx, nil
}

func thenLoginShouldFail(ctx context.Context, expected string) (context.Context, error) {
    _, state := getSharedState(ctx)
    if state.Response != expected {
        return ctx, fmt.Errorf("expected %s, got %s", expected, state.Response)
    }
    return ctx, nil
}
  • This ensures each scenario runs in isolation, safely and predictably.

Why BDD Matters#

BDD brings meaning back to testing. It’s not about fancy syntax or cucumber-green outputs — it’s about ensuring your system behaves as intended, in terms that humans and machines can both understand.

In Go, GoDog makes this possible without losing the language’s pragmatic simplicity.

Write tests that tell stories and let your behaviors speak louder than your assertions.