Behavior-Driven Development with Go

BDD (Behavior-Driven Development) is a way for software teams to work that closes the gap between business people and technical people by:

Essentially, day-to-day BDD activity is a three-step, iterative process:

  1. First, take a small upcoming change to the system, a User Story, and talk about concrete examples of the new functionality to explore, discover and agree on the details of what's expected to be done.
  2. Next, document those examples in a way that can be automated, and check for agreement.
  3. Finally, implement the behaviour described by each documented example, starting with an automated test to guide the development of the code.

A User Story is a small piece of valuable functionality used for planning and prioritising work on an agile team.

A good User Story should:

That is the definition of BDD and User Stories from Cucumber. We can write User Stories by using Gherkin syntax. Here is an example.

Feature: User administration
  Scenario: Register a new account
    Given I register a new account with username admin and password admin
    Then The registration succeeded
    When I log in to the app using username admin and password admin
    Then The logging is succeeded
    When I log in to the app using username admin and password wrongpassword
    Then The logging is failed
    When I register a new account with username admin and password admin
    Then The registration failed

That scenario is common in app development. That user story is written in high-level language so we can understand the context. But the machine also understands that user story because of the syntaxes of Gherkin such as Feature, Scenario, Given, Then, and When. Now, let's run that scenario to ensure our app has that feature. We're going to write our app in Go.

We use Godog to test our GoLang app with that story. In VS Code, it is better to install Cucumber (Gherkin) Full Support extension so we have syntax highlighter and autocomplete for Gherkin.

Let's write that story above in a file named user.feature maybe and put it in a folder, let's say features. Create a file named main_test.go with the code below.

func TestFeatures(t *testing.T) {
    godog.TestSuite{
        Options: &godog.Options{
            Format:   "pretty",
            Paths:    []string{"features"},
            TestingT: t,
        },
    }.Run()
}

Let's run go test ./... in the CLI then it will show this message.

Feature: User administration

  Scenario: Register a new account                                           # features/user.feature:2
    Given I register a new account with username admin and password admin
    Then The registration succeeded
    When I log in to the app using username admin and password admin
    Then The logging is succeeded
    When I log in to the app using username admin and password wrongpassword
    Then The logging is failed
    When I register a new account with username admin and password admin
    Then The registration failed

1 scenarios (1 undefined)
8 steps (8 undefined)
561.74µs

You can implement step definitions for undefined steps with these snippets:

func iLogInToTheAppUsingUsernameAdminAndPasswordAdmin() error {
    return godog.ErrPending
}

func iLogInToTheAppUsingUsernameAdminAndPasswordWrongpassword() error {
    return godog.ErrPending
}

func iRegisterANewAccountWithUsernameAdminAndPasswordAdmin() error {
    return godog.ErrPending
}

func theLoggingIsFailed() error {
    return godog.ErrPending
}

func theLoggingIsSucceeded() error {
    return godog.ErrPending
}

func theRegistrationFailed() error {
    return godog.ErrPending
}

func theRegistrationSucceeded() error {
    return godog.ErrPending
}

func InitializeScenario(ctx *godog.ScenarioContext) {
    ctx.Step(`^I log in to the app using username admin and password admin$`, iLogInToTheAppUsingUsernameAdminAndPasswordAdmin)
    ctx.Step(`^I log in to the app using username admin and password wrongpassword$`, iLogInToTheAppUsingUsernameAdminAndPasswordWrongpassword)
    ctx.Step(`^I register a new account with username admin and password admin$`, iRegisterANewAccountWithUsernameAdminAndPasswordAdmin)
    ctx.Step(`^The logging is failed$`, theLoggingIsFailed)
    ctx.Step(`^The logging is succeeded$`, theLoggingIsSucceeded)
    ctx.Step(`^The registration failed$`, theRegistrationFailed)
    ctx.Step(`^The registration succeeded$`, theRegistrationSucceeded)
}

--- FAIL: TestFeatures (0.00s)
    --- FAIL: TestFeatures/Register_a_new_account (0.00s)
        suite.go:451: step is undefined
FAIL
FAIL    github.com/aristorinjuang/go-bdd-user   0.669s
FAIL

Godog can let us know what we need to write next for steps. You can copy and paste the suggestions. Then put InitializeScenario to the godog.TestSuite{} like this.

func TestFeatures(t *testing.T) {
    godog.TestSuite{
        ScenarioInitializer:  InitializeScenario,
        Options: &godog.Options{
            Format:   "pretty",
            Paths:    []string{"features"},
            TestingT: t,
        },
    }.Run()
}

Run it again and we will get a message like this TODO: write pending definition. That means we need to write the tests.

Hmm, let's jump back to the steps. Let's refactor our steps so we can use reusable functions.

func initializeScenario(sCtx *godog.ScenarioContext) {
    sCtx.Step(`^I register a new account with username ([\da-zA-Z0-9]+) and password ([\da-zA-Z0-9]+)$`, register)
    sCtx.Step(`^The registration (succeeded|failed)$`, check)
    sCtx.Step(`^I log in to the app using username ([\da-zA-Z0-9]+) and password ([\da-zA-Z0-9]+)$`, login)
    sCtx.Step(`^The logging is (succeeded|failed)$`, check)
}

From 7 to 4 only steps with 3 functions. We use regular expressions to convert those strings to parameters in our functions. I also used private functions instead of public functions. So, we know if there are some unused functions.

type (
    appContext struct{}
    errContext struct{}
)

func register(ctx context.Context, username, password string) (context.Context, error) {
    userService := ctx.Value(appContext{}).(*user.UserService)
    ctx = context.WithValue(ctx, errContext{}, userService.Register(username, password))

    return ctx, nil
}

func login(ctx context.Context, username, password string) (context.Context, error) {
    userService := ctx.Value(appContext{}).(*user.UserService)
    ctx = context.WithValue(ctx, errContext{}, userService.Login(username, password))

    return ctx, nil
}

func check(ctx context.Context, status string) error {
    if status == "failed" && ctx.Value(errContext{}) != nil {
        return nil
    }
    if ctx.Value(errContext{}) == nil {
        return nil
    }
    return ctx.Value(errContext{}).(error)
}

I used struct{} as context keys there. You also can create a new type like the type ContextKey string then write as many context keys as you want just using 1 type.

We will get an error if we run it because UserService is not defined yet. Let's write it and then run the code again. I will show you the completed code later.

We will get an error again because there is no a *user.UserService in the context yet.

The better place to put the user service or app context is on TestSuiteInitializer. We are going to put it before the suite. Here is an example.

func initializeSuite(sCtx *godog.TestSuiteContext) {
    sCtx.BeforeSuite(func() {
        userRepository := user.NewMapUserRepository()
        passwordHashingService := passwordhashing.NewBcryptPasswordHashingService()

        ctx = context.WithValue(context.Background(), appContext{}, user.NewUserService(userRepository, passwordHashingService))
    })
}

Here is the completed main_test.go file.

package main_test

import (
    "context"
    "testing"

    "github.com/aristorinjuang/go-bdd-user/internal/user"
    "github.com/aristorinjuang/go-bdd-user/pkg/passwordhashing"
    "github.com/cucumber/godog"
)

type (
    appContext struct{}
    errContext struct{}
)

var ctx context.Context

func register(username, password string) (context.Context, error) {
    userService := ctx.Value(appContext{}).(*user.UserService)
    ctx = context.WithValue(ctx, errContext{}, userService.Register(username, password))

    return ctx, nil
}

func login(username, password string) (context.Context, error) {
    userService := ctx.Value(appContext{}).(*user.UserService)
    ctx = context.WithValue(ctx, errContext{}, userService.Login(username, password))

    return ctx, nil
}

func check(status string) error {
    if status == "failed" && ctx.Value(errContext{}) != nil {
        return nil
    }
    if ctx.Value(errContext{}) == nil {
        return nil
    }
    return ctx.Value(errContext{}).(error)
}

func initializeScenario(sCtx *godog.ScenarioContext) {
    sCtx.Step(`^I register a new account with username ([\da-zA-Z0-9]+) and password ([\da-zA-Z0-9]+)$`, register)
    sCtx.Step(`^The registration (succeeded|failed)$`, check)
    sCtx.Step(`^I log in to the app using username ([\da-zA-Z0-9]+) and password ([\da-zA-Z0-9]+)$`, login)
    sCtx.Step(`^The logging is (succeeded|failed)$`, check)
}

func initializeSuite(sCtx *godog.TestSuiteContext) {
    sCtx.BeforeSuite(func() {
        userRepository := user.NewMapUserRepository()
        passwordHashingService := passwordhashing.NewBcryptPasswordHashingService()

        ctx = context.WithValue(context.Background(), appContext{}, user.NewUserService(userRepository, passwordHashingService))
    })
}

func TestFeatures(t *testing.T) {
    godog.TestSuite{
        TestSuiteInitializer: initializeSuite,
        ScenarioInitializer:  initializeScenario,
        Options: &godog.Options{
            Format:   "pretty",
            Paths:    []string{"../../test/features/app"},
            TestingT: t,
        },
    }.Run()
}

We don't need to put context.Context as parameters again because we already put it as a global variable. The BeforeSuite() needs a function that has no parameter and returns. It is to define what we need before running the tests/steps/scenarios such as a database connection, etc. I used it to put the app context.

What we did is similar to TDD. We wrote the tests first. But we need the scenarios or user stories ready before we can write the tests. While doing DDD, we can get them from domain experts and QA (Quality Assurance) or testers.

I made two examples for this:

Related Articles