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:
- 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
Essentially, day-to-day BDD activity is a three-step, iterative process:
- 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.
- Next, document those examples in a way that can be automated, and check for agreement.
- 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:
- Deliver a demonstrable piece of functionality
- Have testable acceptance criteria
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:
- https://github.com/aristorinjuang/go-bdd-calculator. I made a basic calculator app while demonstrating TDD in TypeScript. Here is the GoLang version with BDD.
- https://github.com/aristorinjuang/go-bdd-user. The completed code for this example.
Related Articles
- RabbitMQ with Go
- Implementing Domain-Driven Design with Go
- Design Patterns with Go and TypeScript
- Build, Test, and Run Go Microservices with Bazel
- Monorepo vs Polyrepo, Microservices with Monorepo in Go