BDD test suite in Go

First part in the series of using BDD in Go to setup a pizza ordering application.

BDD test suite in Go

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.