Test-Driven Development (TDD) in Go: A Practical Guide
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?
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
- Improved Code Quality: Writing tests first force you to think about the requirements and design before writing the code.
- Bug Prevention: Defining the expected results upfront helps prevent unexpected bugs from appearing.
- Refactoring with Confidence: With a comprehensive suite of tests, you can refactor your code without fear of breaking existing functionality.
- 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 GetByLocation
’s 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 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!