Behavior Driven Development in Go With the Godog Package
Write Gherkin scenarios and run them as Go tests with godog: a feature file, step definitions in a test, scenario outlines, and when BDD is worth its cost.

Behavior driven development, BDD, is a way of writing tests as examples in structured plain language, so people who do not write code can read them, and sometimes write them. The usual notation is Gherkin: Given sets up a state, When takes an action, Then checks the result. In Go, the maintained tool is godog, the Cucumber implementation. This guide runs it on Go 1.25.6 with godog v0.16.0, shows the pieces, and is straightforward about when it is worth the layer it adds.
Tested with Go 1.25.6, godog v0.16.0, macOS on Apple Silicon (arm64)
The feature file
Feature: Shopping cart
Totals follow the items in the cart
Scenario: Adding items updates the total
Given an empty cart
When I add 2 items priced 150 cents
And I add 1 item priced 1299 cents
Then the cart total is 1599 cents
Scenario Outline: Discount by size
Given an empty cart
When I add <qty> items priced 1000 cents
Then the cart total is <total> cents
Examples:
| qty | total |
| 1 | 1000 |
| 5 | 4500 |
| 10 | 8000 |
Scenario: Removing an unknown item fails
Given an empty cart
When I remove an item that is not there
Then I see the error "item not in cart"A feature file is a text document. Feature names the area, each Scenario is one example, and the steps below it read as sentences. A Scenario Outline with an Examples table runs the same steps for each row, substituting the <qty> and <total> placeholders. This file says three things about a cart: adding items totals them, the total gets cheaper in bulk (1 item costs 1000, 5 cost 4500, 10 cost 8000), and removing something that is not in the cart is an error. A product owner can read that and say whether the numbers are right, which is the whole point.
The code under test
package main
import "errors"
type Cart struct{ qty, cents int }
func (c *Cart) Add(qty, cents int) { c.qty += qty; c.cents += qty * cents }
// Total gives 10% off from 5 items and 20% off from 10.
func (c *Cart) Total() int {
switch {
case c.qty >= 10:
return c.cents * 80 / 100
case c.qty >= 5:
return c.cents * 90 / 100
}
return c.cents
}
func (c *Cart) Remove(sku string) error { return errors.New("item not in cart") }
func main() {}Cart keeps a quantity and a cent total. Total takes 10% off from five items and 20% off from ten. Remove always returns an error here, enough for the scenario.
Step definitions
package main
import (
"context"
"fmt"
"testing"
"github.com/cucumber/godog"
)
type world struct {
cart Cart
err error
}
func InitializeScenario(sc *godog.ScenarioContext) {
w := &world{}
sc.Before(func(ctx context.Context, _ *godog.Scenario) (context.Context, error) { *w = world{}; return ctx, nil })
sc.Step(`^an empty cart$`, func() { w.cart = Cart{} })
sc.Step(`^I add (\d+) items? priced (\d+) cents$`, func(qty, cents int) { w.cart.Add(qty, cents) })
sc.Step(`^I remove an item that is not there$`, func() { w.err = w.cart.Remove("nope") })
sc.Step(`^the cart total is (\d+) cents$`, func(want int) error {
if got := w.cart.Total(); got != want {
return fmt.Errorf("total is %d, want %d", got, want)
}
return nil
})
sc.Step(`^I see the error "([^"]*)"$`, func(want string) error {
if w.err == nil || w.err.Error() != want {
return fmt.Errorf("error is %v, want %q", w.err, want)
}
return nil
})
}
func TestFeatures(t *testing.T) {
suite := godog.TestSuite{
ScenarioInitializer: InitializeScenario,
Options: &godog.Options{Format: "pretty", Paths: []string{"features"}, TestingT: t, NoColors: true},
}
if suite.Run() != 0 {
t.Fatal("non-zero status returned, failed to run feature tests")
}
}The glue lives in a normal test file. A world struct holds the state shared by the steps of one scenario: the cart and the last error. InitializeScenario registers each step with sc.Step(regexp, function). When godog meets a line in a feature file, it finds the first regular expression that matches and calls the function with the captured groups converted to the function's parameter types. A capture group of digits becomes an int argument. A step function returns an error to fail, or returns nothing to pass.
The pattern for the add step shows the usual care. An optional plural makes one definition serve 1 item and 2 items, because feature files are prose and people write the singular. A Before hook resets the world, so one scenario cannot leave the cart full for the next.
TestFeatures is the entry point. It builds a godog.TestSuite with the initializer and options and runs it. Paths points at the features directory, Format: "pretty" prints each step as it runs, and TestingT: t makes each scenario a subtest, so go test, -run, -v and test caching all work as usual. A non-zero status fails the test.
The run
go test -count=1 -v . 2>&1 | sed -E 's/ \([0-9.]+s\)//; s/ -> example.test\/lab[^ ]*//; /^[0-9.]+(ms|µs|s)$/d; s/^ok.*/ok/' | grep -vE '^(=== RUN|=== PAUSE|=== CONT)' | grep -vE '^\s*$' | head -60Feature: Shopping cart
Totals follow the items in the cart
Scenario: Adding items updates the total # features/cart.feature:4
Given an empty cart # cart_test.go:19
When I add 2 items priced 150 cents # cart_test.go:20
And I add 1 item priced 1299 cents # cart_test.go:20
Then the cart total is 1599 cents # cart_test.go:22
Scenario Outline: Discount by size # features/cart.feature:10
Given an empty cart # cart_test.go:19
When I add <qty> items priced 1000 cents # cart_test.go:20
Then the cart total is <total> cents # cart_test.go:22
Examples:
| qty | total |
| 1 | 1000 |
| 5 | 4500 |
| 10 | 8000 |
Scenario: Removing an unknown item fails # features/cart.feature:21
Given an empty cart # cart_test.go:19
When I remove an item that is not there # cart_test.go:21
Then I see the error "item not in cart" # cart_test.go:28
5 scenarios (5 passed)
16 steps (16 passed)
--- PASS: TestFeatures
--- PASS: TestFeatures/Adding_items_updates_the_total
--- PASS: TestFeatures/Discount_by_size
--- PASS: TestFeatures/Discount_by_size#01
--- PASS: TestFeatures/Discount_by_size#02
--- PASS: TestFeatures/Removing_an_unknown_item_fails
PASS
okThe output prints the feature file back, with each step annotated by the Go file and line that implemented it. The summary says 5 scenarios (5 passed) and 16 steps (16 passed). Five scenarios, because the outline counts three, one per example row. Under go test -v they are subtests: TestFeatures/Adding_items_updates_the_total, then Discount_by_size, Discount_by_size#01 and Discount_by_size#02, then the error scenario. Select one with -run 'TestFeatures/Removing_an_unknown_item_fails'.
Failures read the way you would hope. A step that returns an error is reported with the line in the feature file, the step text and your message, such as total is 4500, want 5000. Steps with no matching definition are reported as undefined, and godog prints a ready-to-paste snippet for the missing function, which is a convenient way to start a new feature: write the scenario, run it, copy the snippets.
Where BDD earns its cost
BDD adds a layer: a notation to maintain, a set of step definitions to keep coherent, and a translation between sentences and code. That layer pays when it is read.
It pays when non-programmers take part. If a product owner, a domain expert or a tester reads and edits the scenarios, the feature file is a shared specification, and it fails when the code stops matching it. That is a use developers cannot replicate with table tests.
It pays for rules with many examples. Pricing, eligibility, tax, workflow transitions. An Examples table is a clear way to show forty rows, and domain people can check them against policy.
It pays as living documentation, if the scenarios are kept to the behaviours that matter and written in the language of the domain, not the interface.
It costs when only developers read it. Then the same checks as a Go table test are shorter, typed and checked by the compiler. A scenario that says When I click the button with id "submit" is a script in disguise, with the cost of a notation and none of the benefit.
Writing scenarios that last
- Describe behaviour, not mechanism.
When I add 2 items priced 150 centssurvives a change from a form to an API;When I POST to /cart/items with JSONdoes not. - One reason to fail per scenario. A scenario that checks eight outcomes tells you little when it breaks.
- Keep scenarios independent. Reset state in
Before, never rely on the order of scenarios. - Reuse steps, but keep them readable. A generic step like
Then the response status is 200is fine; one that takes six parameters is a function call in costume. - Put numbers in examples, and the explanation in the scenario name.
- Test below the user interface when you can. Binding steps to the domain layer or the HTTP handlers (with
httptest) is fast and stable. Binding them to a browser inherits all the cost of end to end tests.
Practical details
Godog looks for feature files from the paths in Options, and tags such as @slow select subsets with Tags: "~@slow". Hooks exist at suite, scenario and step level for setup, cleanup and logging. Run the scenarios in parallel with Concurrency in the options; each scenario needs its own world, which the Before hook above already creates, but any shared external resource needs isolation as in parallel tests. The pretty formatter suits a terminal, while junit and cucumber formats suit CI. For code that runs against a real database or service, follow the tier advice in the testing strategy and put those suites behind a build tag.
Running scenarios against an HTTP API
A common use is to bind steps to a service's HTTP handlers. The world holds an httptest.Server or a handler, a client and the last response. Given steps prepare data through the same handlers, or directly in a fake store. When steps send requests: When I request the order "o-1". Then steps decode the last response and compare status and fields. Steps stay readable if the Go code owns the HTTP details and the feature file speaks about orders and customers, not about headers and JSON paths.
This style tests the real routing, middleware and encoding in process, like the recorder article, with scenarios as the spec. It runs in milliseconds, so the suite can sit in the unit or integration tier. Binding the same scenarios to a deployed stack is possible by swapping the client's base URL, and that is how teams reuse a feature file as a post-deploy smoke check, with only a tagged subset.
Tags, hooks and organisation
Tags such as @wip or @slow on a scenario or a feature let you include or exclude groups: Tags: "@smoke" runs the smoke set, "~@slow" skips slow ones. Use tags for tiers, for features in progress and for scenarios that need a particular environment.
Hooks run around scenarios and steps. Before and After are the place for state reset, cleanup and capturing diagnostics when a scenario fails, such as dumping the last response. Suite-level hooks start shared infrastructure once. Keep hooks short, since a hook that does a lot hides what a scenario depends on.
Organise features by domain concept, not by screen or endpoint: cart.feature, checkout.feature, refunds.feature. Keep one step file per feature area, with shared steps in a common file, so a reader can find the definition behind a line.
Writing the first scenarios with the team
The value of BDD comes from the conversation, not the tool. A practical format is a short session with a developer, a tester and a person who owns the rules. Take one story and write three to five examples on cards in plain language, with concrete numbers: "five items at 10.00 cost 45.00". Argue about the awkward ones: what about exactly five, what about a return. Each resolved argument becomes a row in the examples table, and each surprising answer is a requirement found before the code was written.
Then write the feature file from the cards and run it before implementing. The scenarios fail with undefined steps, godog prints the skeletons, and a developer fills them in. That order, examples first, is where the method does its work. Teams that write the feature files after the code, alone, tend to end up with scripts nobody else reads.
Keeping the step library healthy
Over time a step library grows, and the habits that keep it useful are plain.
- Name steps in domain language, and review the wording as you would an API.
- Delete unused steps. Godog can report unused definitions when asked; remove them.
- Avoid step-to-step state outside the world. Package variables break parallel runs and hide dependencies.
- Prefer a few parameterised steps to many near-duplicates, but stop before a step takes more than two or three captures.
- Fail with messages that name the example. A step that fails with
false != truehelps nobody; say what was expected and what was found, as the total step above does. - Keep assertions in
Thensteps. AGiventhat asserts is a test of the setup, and it misleads readers about what the scenario checks.
These are the same disciplines as in any test code, applied to a layer that has a weaker compiler.
A worked example of the conversation
Take the discount rule. The product owner says "bulk orders are cheaper". That is not yet testable. The first question is what counts as bulk, and the answer is five items. The second is whether the discount applies to the whole order or to the items above five, and the answer, after a pause, is the whole order. The third is what happens at exactly ten, and the answer is that twenty percent replaces ten rather than adding to it. Each answer is now a row in the table: 1 item costs 1000, 5 cost 4500, 10 cost 8000.
Two of those answers would have been guessed by a developer working alone, and one of the guesses would have been wrong. The feature file is the record of the decision, written in words everyone agreed on. When the rule changes next quarter, the person who owns it edits the examples first, and the build fails until the code agrees. That loop, a rule that lives in a document that also runs, is the reason to pay for the tool.
Cost in practice
Count the cost honestly before adopting it. A feature file and its step definitions take longer to write than a table test for the same cases, often twice as long for the first scenarios and a little longer than a table once the vocabulary exists. There is a learning cost for people new to Gherkin, and a review cost, because two files must be kept in agreement. If scenarios are read by three people a year, the cost does not repay. If they are the place where the business rules are agreed, as in pricing or compliance, it does.
A reasonable approach is to start with one feature that has a real rule and a real stakeholder, run it for a quarter, and ask the stakeholder whether they opened the file. If they did, extend the practice. If they did not, convert the scenarios to table tests and keep the code. Either way you have learned something real, and the tool is the part of that you can undo.
Alternatives
If the people reading the tests are developers, plain Go does the job. Table tests are the lightest option; the table-driven article covers them. For example-style documentation that runs, see example tests. For a readable assertion DSL inside Go, the standard library plus testify is enough. For specification by example with a business audience, other ecosystems have Cucumber, SpecFlow and Behave, and godog is Go's member of the family, sharing the Gherkin syntax so that features can be exchanged between languages.
Limits
Gherkin is a restricted language, and some behaviour does not fit it: concurrency, timing, performance, and anything with a lot of state. Step regular expressions are matched at run time, so a typo in a step is found when the test runs, not when it compiles. A large step library can become a hidden programming language that nobody dares change. And a suite where nobody outside engineering opens the files is paying for a benefit it does not receive. The documentation for the syntax is in the Gherkin reference, and godog itself in its package documentation.


