BDD test suite in Go
First part in the series of using BDD in Go to setup a pizza ordering application.
Today we're going over how to set up a Go project with BDD (Behavior driven development).
What is BDD?
BDD is a way for software teams to work that closes the gap between business people and technical people by: Encouraging collaboration across roles to build shared understanding of the problem to be solved Working in rapid, small iterations to increase feedback and the flow of value Producing system documentation that is automatically checked against the system's behaviour.
This approach is great even for solo developers because it provides a framework to deliver value. By describing functionality in simple English terms, it allows you to focus on the outcome instead of the technical implementation. This frees bandwidth to explore options on how to implement a solution. You even get the security of trying different solutions with the safety net of the BDD tests.
First, we're going to start off by making a hello world program in Go. You don't really need this for this part, but it will be good to have in the next sections.
This first part of the series will follow along the example found in Godog's Repo. It will integrate two feature files and demonstrate the bdd test suite in action.
go.mod
module go-bdd
go 1.24
require github.com/cucumber/godog v0.15.1
require (
github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
github.com/gofrs/uuid v4.3.1+incompatible // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-memdb v1.3.4 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/spf13/pflag v1.0.7 // indirect
)
main.go just contains boilerplate functionality that we won't test. We just have it as a formality.
package main
import (
"fmt"
)
func main() {
s := "gopher"
fmt.Printf("Hello and welcome, %s!\n", s)
for i := 1; i <= 5; i++ {
fmt.Println("i =", 100/i)
}
}
Create a tests folder that contains a features folder. This will contain all of our primary requirements for the test suite.
mkdir -p test/features
Now, import the respective feature files into the features folder.
Here is godogs.feature
Feature: eat godogs
In order to be happy
As a hungry gopher
I need to be able to eat godogs
Scenario: Eat 5 out of 12
Given there are 12 godogs
When I eat 5
Then there should be 7 remaining
Scenario: Eat 12 out of 12
Given there are 12 godogs
When I eat 12
Then there should be none remaining
Then, we also bring in nodogs.feature
Feature: do not eat godogs
In order to be fit
As a well-fed gopher
I need to be able to avoid godogs
Scenario: Eat 0 out of 12
Given there are 12 godogs
When I eat 0
Then there should be 12 remaining
Scenario: Eat 0 out of 0
Given there are 0 godogs
When I eat 0
Then there should be 0 remaining
From there, create a file under test.
cd test
touch godogs_test.go
godogs_test.go
package test
import (
"context"
"errors"
"fmt"
"strconv"
"testing"
"github.com/cucumber/godog"
)
// godogsCtxKey is the key used to store the available godogs in the context.Context.
type godogsCtxKey struct{}
func thereAreGodogs(ctx context.Context, available int) (context.Context, error) {
return context.WithValue(ctx, godogsCtxKey{}, available), nil
}
func iEat(ctx context.Context, num int) (context.Context, error) {
available, ok := ctx.Value(godogsCtxKey{}).(int)
if !ok {
return ctx, errors.New("there are no godogs available")
}
if available < num {
return ctx, fmt.Errorf("you cannot eat %d godogs, there are %d available", num, available)
}
available -= num
return context.WithValue(ctx, godogsCtxKey{}, available), nil
}
func thereShouldBeRemaining(ctx context.Context, remainingStr string) error {
remaining, ok := ctx.Value(godogsCtxKey{}).(int)
if !ok {
return errors.New("there are no godogs available")
}
var expectedRemaining, err = strconv.Atoi(remainingStr)
if remainingStr != "none" {
if err != nil {
return fmt.Errorf("invalid remaining value: %s", remainingStr)
}
} else {
expectedRemaining = 0
}
if remaining != expectedRemaining {
return fmt.Errorf("expected %d remaining, got %d", expectedRemaining, remaining)
}
return nil
}
func TestGodogExampleFeature(t *testing.T) {
suite := godog.TestSuite{
ScenarioInitializer: InitializeScenario,
Options: &godog.Options{
Format: "pretty",
Paths: []string{"features"},
TestingT: t, // Testing instance that will run subtests.
},
}
if suite.Run() != 0 {
t.Fatal("non-zero status returned, failed to run feature tests")
}
}
func InitializeScenario(sc *godog.ScenarioContext) {
sc.Step(`^there are (\d+) godogs$`, thereAreGodogs)
sc.Step(`^I eat (\d+)$`, iEat)
sc.Step(`^there should be (\d+|none) remaining$`, thereShouldBeRemaining)
}
You should now have this as your file structure:
test/
├── features/
│ ├── godogs.feature
│ ├── nodogs.feature
├── godogs_test.go
├── go.mod
├── main.go
Go back to the root directory of your project. From there, you are going to run go mod tidy.
Then, run go test -v ./test.
You will get the following output, and your test suite should be passed.
➜ go-bdd git:(main) ✗ go test -v ./test
=== RUN TestGodogExampleFeature
Feature: eat godogs
In order to be happy
As a hungry gopher
I need to be able to eat godogs
=== RUN TestGodogExampleFeature/Eat_5_out_of_12
Scenario: Eat 5 out of 12 # features/godogs.feature:6
Given there are 12 godogs # godogs_test.go:72 -> go-bdd/test.thereAreGodogs
When I eat 5 # godogs_test.go:73 -> go-bdd/test.iEat
Then there should be 7 remaining # godogs_test.go:74 -> go-bdd/test.thereShouldBeRemaining
=== RUN TestGodogExampleFeature/Eat_12_out_of_12
Scenario: Eat 12 out of 12 # features/godogs.feature:11
Given there are 12 godogs # godogs_test.go:72 -> go-bdd/test.thereAreGodogs
When I eat 12 # godogs_test.go:73 -> go-bdd/test.iEat
Then there should be none remaining # godogs_test.go:74 -> go-bdd/test.thereShouldBeRemaining
Feature: do not eat godogs
In order to be fit
As a well-fed gopher
I need to be able to avoid godogs
=== RUN TestGodogExampleFeature/Eat_0_out_of_12
Scenario: Eat 0 out of 12 # features/nodogs.feature:6
Given there are 12 godogs # godogs_test.go:72 -> go-bdd/test.thereAreGodogs
When I eat 0 # godogs_test.go:73 -> go-bdd/test.iEat
Then there should be 12 remaining # godogs_test.go:74 -> go-bdd/test.thereShouldBeRemaining
=== RUN TestGodogExampleFeature/Eat_0_out_of_0
Scenario: Eat 0 out of 0 # features/nodogs.feature:11
Given there are 0 godogs # godogs_test.go:72 -> go-bdd/test.thereAreGodogs
When I eat 0 # godogs_test.go:73 -> go-bdd/test.iEat
Then there should be 0 remaining # godogs_test.go:74 -> go-bdd/test.thereShouldBeRemaining
4 scenarios (4 passed)
12 steps (12 passed)
401.875µs
--- PASS: TestGodogExampleFeature (0.00s)
--- PASS: TestGodogExampleFeature/Eat_5_out_of_12 (0.00s)
--- PASS: TestGodogExampleFeature/Eat_12_out_of_12 (0.00s)
--- PASS: TestGodogExampleFeature/Eat_0_out_of_12 (0.00s)
--- PASS: TestGodogExampleFeature/Eat_0_out_of_0 (0.00s)
PASS
ok go-bdd/test 0.190s
Conclusion
Congrats, you've set up a test suite with two features that are driven from the spec file. This is beneficial in many ways. The code is now reliant on implementing step definitions as described in the feature files. The feature files have become the source of truth for expected behaviors of the system in different contexts. The step definitions must align with the requirements in the feature files for this to work, there is no free lunch on there.
There are tools you can integrate in your CI/CD pipeline to generate documentation for the layman to browse and understand business requirements exactly how the code has implemented it. Moreover, the feature files can be added as to your RAG strategy to get summarized questions about the domain.
Of course, this will only be helpful once we add in our own set of features and begin to implement that. We will build a pizza shop ordering system application to do this in the upcoming post.