Behavior-Driven Development in Go
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.Contextand 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.