← Back to list

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

We’ll explore TDD in the context of a Go project, using examples from a real-world implementation.

Eduardus Tjitrahardja · 2024-09-29 11:33 · 0 claps · 6.2 min read
#software-engineering #test-driven-development #programming #go #gomega
Open on Medium ↗
Wiki topics: 💻 · Programming

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?

Source: What is Test-Driven Development?

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:

[embed]Gomega Matcher : A guide to developing your custom tests in Golang Lets be honest when testing on golang comes to our mind we think of gomega and gingko.diptochakrabarty.medium.com

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 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


메타데이터
post_id
c917ccc3fa92
slug
test-driven-development-tdd-in-go-a-practical-guide-c917ccc3fa92
url
https://medium.com/@edutjie/test-driven-development-tdd-in-go-a-practical-guide-c917ccc3fa92
canonical_url
https://medium.com/@edutjie/test-driven-development-tdd-in-go-a-practical-guide-c917ccc3fa92
author_url
https://medium.com/@edutjie
status
ok
fetched_at
2026-08-21 16:18:07