Test-Driven Development (TDD) in Go: A Practical Guide

Eduardus Tjitrahardja
7 min readSep 29, 2024

--

Introduction

Test-Driven Development (TDD) is a software development process where tests are written before the actual code. This approach ensures that the codebase is thoroughly tested and helps in maintaining high code quality. In this blog post, we’ll explore TDD in the context of a Go project, using examples from a real-world implementation.

What is TDD?

Source: What is Test-Driven Development?

TDD is a development technique where you write a test before you write just enough production code to fulfill that test and then refactor the code to pass the test. The cycle is often summarized as follows:

1. Red: Write a failing test.
2. Green: Write the minimum amount of code to make the test pass.
3. Refactor: Improve the code while keeping the tests green.

This approach helps in building confidence in the code and ensures that the code is always working as expected.

Benefits of TDD

  1. Improved Code Quality: Writing tests first force you to think about the requirements and design before writing the code.
  2. Bug Prevention: Defining the expected results upfront helps prevent unexpected bugs from appearing.
  3. Refactoring with Confidence: With a comprehensive suite of tests, you can refactor your code without fear of breaking existing functionality.
  4. Documentation: Tests act as documentation for the code, clarifying its intended behavior and improving overall team collaboration.

TDD in Action: A Go Project Example

Let’s dive into a Go project I implemented in a Software Engineering course to see how TDD is implemented. We’ll use a simple example of a VendorService that retrieves vendor information from a database.

Step 1: Write a Failing Test (Red)

First, we write a test for the GetByLocation method, which retrieves vendors based on a product description. Here I use GoMega as the testing framework, feel free to use another. You can learn about it more in this article post:

Let’s start writing the unit test code!

  • The Accessor Unit Test (accessor_test.go)
func TestVendorAccessor_GetByLocation(t *testing.T) {
t.Parallel()

var (
accessor *postgresVendorAccessor
mock sqlmock.Sqlmock
)

setup := func(t *testing.T) (*gomega.GomegaWithT, *sql.DB) {
g := gomega.NewWithT(t)
db, sqlMock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
log.Fatal("error initializing mock:", err)
}

accessor = newPostgresVendorAccessor(db)
mock = sqlMock

return g, db
}

sampleData := []string{
"id",
"name",
"description",
"bp_id",
"bp_name",
"rating",
"area_group_id",
"area_group_name",
"sap_code",
"modified_date",
"modified_by",
"dt",
}

query := `SELECT
"id",
"name",
"description",
"bp_id",
"bp_name",
"rating",
"area_group_id",
"area_group_name",
"sap_code",
"modified_date",
"modified_by",
"dt"
FROM vendor
WHERE area_group_name = $1`

fixedTime := time.Date(2024, time.September, 27, 12, 30, 0, 0, time.UTC)

location := "Indonesia"

t.Run("success", func(t *testing.T) {
g, db := setup(t)
defer db.Close()

rows := sqlmock.NewRows(sampleData).
AddRow(
"1",
"name",
"description",
"1",
"bp_name",
1,
"1",
location,
"sap_code",
fixedTime,
1,
fixedTime,
)

mock.ExpectQuery(query).
WithArgs(location).
WillReturnRows(rows)

ctx := context.Background()
res, err := accessor.GetByLocation(ctx, location)

expectation := []Vendor{{
ID: "1",
Name: "name",
Description: "description",
BpID: "1",
BpName: "bp_name",
Rating: 1,
AreaGroupID: "1",
AreaGroupName: location,
SapCode: "sap_code",
ModifiedDate: fixedTime,
ModifiedBy: 1,
Date: fixedTime,
}}

g.Expect(err).To(gomega.BeNil())
g.Expect(res).To(gomega.Equal(expectation))
})

t.Run("success on empty result", func(t *testing.T) {
g, db := setup(t)
defer db.Close()

rows := sqlmock.NewRows(sampleData)

mock.ExpectQuery(query).
WithArgs(location).
WillReturnRows(rows)

ctx := context.Background()
res, err := accessor.GetByLocation(ctx, location)
g.Expect(err).To(gomega.BeNil())
g.Expect(res).To(gomega.Equal([]Vendor{}))
})

t.Run("error on scanning row", func(t *testing.T) {
g, db := setup(t)
defer db.Close()

rows := sqlmock.NewRows(sampleData).AddRow(
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
)

mock.ExpectQuery(query).
WithArgs(location).
WillReturnRows(rows)

ctx := context.Background()
res, err := accessor.GetByLocation(ctx, location)

g.Expect(err).ToNot(gomega.BeNil())
g.Expect(res).To(gomega.BeNil())
})

t.Run("error on executing query", func(t *testing.T) {
g, db := setup(t)
defer db.Close()

mock.ExpectQuery(query).
WithArgs(location).
WillReturnError(errors.New("some error"))

ctx := context.Background()
res, err := accessor.GetByLocation(ctx, location)

g.Expect(err).ToNot(gomega.BeNil())
g.Expect(res).To(gomega.BeNil())
})

t.Run("error while iterating rows", func(t *testing.T) {
g, db := setup(t)
defer db.Close()

rows := sqlmock.NewRows(sampleData).
AddRow(
"1",
"name",
"description",
"1",
"bp_name",
1,
"1",
location,
"sap_code",
fixedTime,
1,
fixedTime,
).RowError(0, fmt.Errorf("row error"))

mock.ExpectQuery(query).
WithArgs(location).
WillReturnRows(rows)

ctx := context.Background()
res, err := accessor.GetByLocation(ctx, location)

g.Expect(err).ToNot(gomega.BeNil())
g.Expect(res).To(gomega.BeNil())
})
}
  • The Service Unit Test (service_test.go)
func TestVendorService_GetByLocation(t *testing.T) {
location := "Indonesia"
sampleData := []Vendor{
{
ID: "1",
Name: "name",
Description: "description",
BpID: "1",
BpName: "bp_name",
Rating: 1,
AreaGroupID: "1",
AreaGroupName: location,
SapCode: "sap_code",
ModifiedDate: time.Now(),
ModifiedBy: 1,
Date: time.Now(),
},
}
ctrl := gomock.NewController(t)
defer ctrl.Finish()
type fields struct {
mockVendorDBAccessor *MockvendorDBAccessor
}
type args struct {
ctx context.Context
}
tests := []struct {
name string
fields fields
args args
want []Vendor
wantErr bool
}{
{
name: "success",
fields: fields{
mockVendorDBAccessor: NewMockvendorDBAccessor(ctrl),
},
args: args{ctx: context.Background()},
want: sampleData,
wantErr: false,
},
{
name: "failure",
fields: fields{
mockVendorDBAccessor: NewMockvendorDBAccessor(ctrl),
},
args: args{ctx: context.Background()},
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := gomega.NewWithT(t)
v := &VendorService{
vendorDBAccessor: tt.fields.mockVendorDBAccessor,
}

if tt.wantErr {
tt.fields.mockVendorDBAccessor.EXPECT().
GetByLocation(tt.args.ctx, location).
Return(nil, fmt.Errorf("some error"))
} else {
tt.fields.mockVendorDBAccessor.EXPECT().
GetByLocation(tt.args.ctx, location).
Return(tt.want, nil)
}

res, err := v.GetByLocation(tt.args.ctx, location)

if tt.wantErr {
g.Expect(err).ToNot(gomega.BeNil())
g.Expect(res).To(gomega.BeNil())
} else {
g.Expect(err).To(gomega.BeNil())
g.Expect(res).To(gomega.Equal(tt.want))
}
})
}
}

Step 2: Write the Minimum Code to Pass the Test (Green)

Next, we implement the GetByLocations accessor and service method in the VendorService.

  • The Accessor Implementation (accessor.go)
func (p *postgresVendorAccessor) GetByLocation(ctx context.Context, location string) ([]Vendor, error) {
query := `SELECT
"id",
"name",
"description",
"bp_id",
"bp_name",
"rating",
"area_group_id",
"area_group_name",
"sap_code",
"modified_date",
"modified_by",
"dt"
FROM vendor
WHERE area_group_name = $1`

rows, err := p.db.Query(query, location)
if err != nil {
return nil, err
}

defer rows.Close()

vendors := []Vendor{}

for rows.Next() {
var vendor Vendor
err := rows.Scan(
&vendor.ID,
&vendor.Name,
&vendor.Description,
&vendor.BpID,
&vendor.BpName,
&vendor.Rating,
&vendor.AreaGroupID,
&vendor.AreaGroupName,
&vendor.SapCode,
&vendor.ModifiedDate,
&vendor.ModifiedBy,
&vendor.Date,
)
if err != nil {
return nil, fmt.Errorf("Failed while scanning row: %w", err)
}
vendors = append(vendors, vendor)
}

if err := rows.Err(); err != nil {
return nil, err
}

return vendors, nil
}
  • The Service Implementation (service.go)
func (v *VendorService) GetByLocation(ctx context.Context, location string) ([]Vendor, error) {
return v.vendorDBAccessor.GetByLocation(ctx, location)
}

Step 3: Refactor the code if needed (Refactor)

Finally, we refactor the code to ensure it is clean and maintainable. In this case, the code is already simple, so minimal refactoring is needed.

Using GoMock for Unit Testing

In addition to the steps above, we also use GoMock to generate the services’ mocks to be able to create the unit test. GoMock is a tool that automatically generates mock implementations of interfaces, which can be used in tests to simulate the behavior of real objects.

To generate mocks for our vendorDBAccessor interface, we use the following command:

go:generate mockgen -typed -source=service.go -destination=service_mock.go -package=vendors

This command generates a mock implementation of the service interface in the service_mock.go file, which we can then use in our tests.

Integrating TDD with CI/CD

In our project, we use GitHub Actions to automate the testing and code quality checks. Here’s an example from our CI/CD files created by Valerian Salim:

  • build.yml
name: Go Build CI

on:
workflow_call:

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23.1'

- name: Install Dependencies
run: go mod download

- name: Build Application
run: make build-ci

- name: Upload Binary Artifact
uses: actions/upload-artifact@v4
with:
name: kg-procurement-binary
path: ./bin/kg-procurement
  • unit-tests.yml
name: Unit Tests with Coverage

on:
workflow_call:

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23.1'

- name: Install dependencies
run: go mod download

- name: Run Tests with Coverage
run: go test -v -race -coverprofile=coverage.out ./...

- name: Upload Coverage Report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.out
  • sonarcloud.yml
name: SonarCloud Analysis

on:
workflow_call:

jobs:
sonarcloud:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23.1'

- name: Install Dependencies
run: go mod download

- name: Run Tests with Coverage
run: |
go test -v -race -coverprofile=coverage.out -covermode=atomic ./...

- name: Display Coverage Summary
run: go tool cover -func=coverage.out

- name: SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@v3.0.0
with:
args: >
-Dsonar.projectKey=${{ secrets.SONAR_PROJECT_KEY }}
-Dsonar.organization=${{ secrets.SONAR_ORGANIZATION }}
-Dsonar.go.coverage.reportPaths=coverage.out
-Dsonar.sources=.
-Dsonar.tests=.
-Dsonar.test.inclusions=**/*_test.go
-Dsonar.exclusions=**/vendor/**,**/*_mock.go,internal/common/database/**
-Dsonar.coverage.exclusions=**/router/**,**/accessor.go,**/vendor/**,**/*_mock.go,**/*_test.go,**/cmd/**/*.*
-Dsonar.cpd.exclusions=**/accessor.go,**/*.sql
-Dsonar.language=go
-Dsonar.sourceEncoding=UTF-8
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
  • ci-workflow.yml (combining all pipelines together)
name: CI Workflow

on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]

jobs:
build:
uses: ./.github/workflows/build.yml
secrets: inherit

test:
needs: build
uses: ./.github/workflows/unit-tests.yml
secrets: inherit

sonarcloud:
needs: test
uses: ./.github/workflows/sonarcloud.yml
secrets: inherit

This configuration ensures that our tests are run and code coverage is reported every time we push changes to main or create a pull request.

Conclusion

In the end, your commits will look like something like this:

TDD style Github commits

TDD is a powerful technique that can significantly improve the quality and maintainability of your code. By writing tests first, you ensure that your code meets the requirements and is thoroughly tested. Integrating TDD with CI/CD pipelines, as shown in our example, further enhances the development process by automating tests and code quality checks.

Happy coding!

Reference

What is Test-Driven Development? | TestDriven.io

--

--