How to Write Unit Tests in Go

December 09, 2024
Written by
Temitope Taiwo Oyedele
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

How to write unit test in go

Unit testing is a crucial aspect of software development that helps ensure the quality and reliability of your code. In the Go programming language, unit testing is particularly straightforward due to the built-in testing package provided by the standard library.

In this article, you'll learn the basics of writing unit tests in Go, including table-driven tests, coverage tests, and benchmarks. You'll also see examples and learn tips for effective testing practices.

Prerequisites

Before diving into the details of unit testing in Go, the following prerequisites should be met:

  • Understanding of the Go Runtime
  • Go version 1.22 or higher
  • Familiarity with programming in Go. You should have basic knowledge of Go syntax, data types, control structures, and object-oriented programming concepts
  • Your preferred text editor or IDE for writing Go

Set up your Go project

Open your terminal and create a directory for your project.

mkdir unit_test_go
cd unit_test_go

Then, run the following command to initialize a Go module. This step is crucial for managing dependencies in your project.

go mod init unit_test_go

What we'll test: how to calculate the total amount of an order

Let's say we have an Order struct that contains information about an order placed by a customer. The struct has three fields: ID, CurrencyAlphaCode, and Items. The Items field is a slice of Item structs, which contain information about each product in the order. This structure will be used to perform various operations, such as iterating over the Items to calculate the order total, applying currency conversion based on the CurrencyAlphaCode, and generating a summary of the order for the customer.

Install the required packages

You need to install the GoMoney package. This library is designed to work with monetary values using a currency's smallest unit. For our tests, we’ll use the assert package, a part of the Testify library, which provides testing tools for the Go testing system.

Install them by running the following command:

go get github.com/Rhymond/go-money github.com/stretchr/testify/assert

Write the core code

Now, let's create the code that we're going to test. Create a file called order.go. Then, add the following to it:

package order

import (
	"fmt"
	"github.com/Rhymond/go-money"
)

type Order struct {
	ID                string
	CurrencyAlphaCode string
	Items             []Item
}

type Item struct {
	ID        string
	Quantity  uint
	UnitPrice *money.Money
}

func (o Order) ComputeTotal() (*money.Money, error) {
	amount := money.New(0, o.CurrencyAlphaCode)
	for _, item := range o.Items {
		var err error
		amount, err = amount.Add(item.UnitPrice.Multiply(int64(item.Quantity)))
		if err != nil {
			return nil, fmt.Errorf("impossible to add item elements: %w", err)
		}
	}
	return amount, nil
}

The code above depicts a simple order management system that allows you to create orders with multiple items, and provides a method to calculate the total cost of an order.

Write the first unit test

To write a unit test for the ComputeTotal() method, create a new file named order_test.go in the same directory as the order.go file. Then, add the following code to the file:

package order

import (
	"testing"
	"github.com/Rhymond/go-money"
	"github.com/stretchr/testify/assert"
)

func TestComputeTotal(t *testing.T) {
	o := Order{
		ID:                "45",
		CurrencyAlphaCode: "EUR",
		Items: []Item{
			{
				ID:        "458",
				Quantity:  2,
				UnitPrice: money.New(100, "EUR"),
			},
		},
	}
	total, err := o.ComputeTotal()
	assert.NoError(t, err)
	assert.Equal(t, int64(200), total.Amount())
	assert.Equal(t, "EUR", total.Currency().Code)
}

In this example, we define a single test case, TestComputeTotal(), which creates an instance of the Order struct and calls the ComputeTotal() method. We then assert that the result matches our expected values using the assert.NoError(), assert.Equal(), and assert.Equal() functions from the testify/assert package.

To check the result in the terminal, run the following command:

go test

You should see output similar to the screenshot below.

go test
PASS
ok  	unit_test_go	0.853s

The "PASS" indicates that the code works as expected under the tested conditions. If a test fails, the output will show "FAIL" instead.

Add table-driven tests

Table-driven tests are a great way to simplify test cases and make them more readable. Instead of having multiple test cases for different inputs, we can define a table of input parameters and expected outputs, and run the test for all combinations.

Let's see how we can modify the previous example to use table-driven tests. Update your order_test.go file to match the following:

package order

import (
   "testing"
   "github.com/Rhymond/go-money"
   "github.com/stretchr/testify/assert"
)

func TestComputeTotal(t *testing.T) {
   testCases := []struct {
       name     string
       order    Order
       expected int64
   }{
       {
           name: "Single item order",
           order: Order{
               ID:                "45",
               CurrencyAlphaCode: "EUR",
               Items: []Item{
                   {
                       ID:        "458",
                       Quantity:  2,
                       UnitPrice: money.New(100, money.EUR),
                   },
               },
           },
           expected: 200,
       },
       {
           name: "Order with no items",
           order: Order{
               ID:                "47",
               CurrencyAlphaCode: "EUR",
               Items:             []Item{},
           },
           expected: 0,
       },
   }

   for _, tc := range testCases {
       t.Run(tc.name, func(t *testing.T) {
           total, err := tc.order.ComputeTotal()
           assert.NoError(t, err)
           assert.Equal(t, tc.expected, total.Amount())
       })
   }
}

In the code above, we define a slice of structs called testCases that contains two elements, each representing a test case. Each struct contains three fields: name, order, and expected. The name field is a string that describes the name of the test case. The order field is an instance of the Order struct that represents the input data for the test case. The expected field is an integer representing the expected output for the test case.

We then loop through each test case using a for loop and run the test using the t.Run() function. This function takes two arguments: the name of the test case and a function that implements the test logic. In this case, the function implementation is anonymous and defines a single assertion using the assert.Equal() function.

To run the test, use the following command

go test -v

Adding the -v flag to the go test command increases verbosity, providing more detailed output about which tests are running and how long each test takes. This can be particularly useful for understanding the performance of your tests and identifying any that may be taking longer than expected.

The output from running go test -v includes information about each test, such as the name of the test, whether it passed or failed, and the time it took to run.

You should see output like this:

=== RUN   TestComputeTotal
=== RUN   TestComputeTotal/Single_item_order
=== RUN   TestComputeTotal/Order_with_no_items
--- PASS: TestComputeTotal (0.00s)
	--- PASS: TestComputeTotal/Single_item_order (0.00s)
	--- PASS: TestComputeTotal/Order_with_no_items (0.00s)
PASS
ok  	unit_test_go	1.003s

Add coverage tests

Coverage tests help ensure that your code covers all possible execution paths. They are essential for ensuring code quality and reliability because they help identify untested code paths, prevent potential bugs, and improve overall software robustness.

In Go, you can use the -cover flag when running tests to generate coverage reports. A coverage report is an in-depth analysis of which parts of your code are run during tests and which are not. This information enables you to find untested code and enhance test suites.

To generate a basic coverage report, run the following code:

go test -cover

This command runs your tests and displays a simple coverage percentage in the terminal.

This should be the output in the terminal:

Usage of the -cover flag in Go tests to generate coverage reports.

For a more detailed analysis, we can generate a coverage profile and view it in a browser.

View test results in the browser

Visualizing your test coverage is critical for understanding the quality of your code and identifying areas that could benefit from more testing. The go tool cover command is an effective technique for accomplishing this.

Visualizing your test coverage is critical for ensuring your code is well-tested and robust. It allows you to easily discover untested areas of your codebase, resulting in greater code quality and fewer defects.

First, you need to run your tests with the -coverprofile flag to generate a coverage profile, which contains information about the coverage of your code.

go test -coverprofile=c.out

This command runs your tests and generates a coverage profile file named c.out. After this, you can then view the code in the browser using this command:

go tool cover -html=c.out
Covergae test results displayed on the browser

Enable benchmarks

Benchmarks measure the performance of your code. In Go, benchmarks are defined similarly to tests, but use the testing.B type instead of testing.T. Benchmarks are useful for identifying performance bottlenecks in your code.

For our example, we can use benchmarks to measure how long it takes to compute the total for an order with many items.

To do this, update order_test.go, with the following code:

package order

import (
   "testing"
   "github.com/Rhymond/go-money"
   "github.com/stretchr/testify/assert"
)

func TestComputeTotal(t *testing.T) {
   o := Order{
       ID:                "45",
       CurrencyAlphaCode: "EUR",
       Items: []Item{
           {
               ID:        "458",
               Quantity:  2,
               UnitPrice: money.New(100, money.EUR),
           },
       },
   }
   total, err := o.ComputeTotal()
   assert.NoError(t, err)
   assert.Equal(t, int64(200), total.Amount())
   assert.Equal(t, "EUR", total.Currency().Code)
}

func BenchmarkComputeTotal(b *testing.B) {
   order := Order{
       ID:                "45",
       CurrencyAlphaCode: "EUR",
       Items: []Item{
           {
               ID:        "458",
               Quantity:  1000,
               UnitPrice: money.New(100, money.EUR),
           },
       },
   }
   b.ResetTimer()
   for i := 0; i < b.N; i++ {
       _, _ = order.ComputeTotal()
   }
}

Then, run the benchmark using this command:

go test -bench=.

You should see output like this:

output showing the implementation of a benchmark for measuring the performance of computing the total amount for an order with many items.

The output means the loop ran 17,410,407 times at a speed of 80.09 nanoseconds per loop.

Document Go code with an example

You can document Go code using the "Example" approach. This method is part of Go's documentation practices, where examples are included directly within the codebase to serve as both documentation and executable code. They are intended to demonstrate how to use the language and its packages effectively.

To document the ComputeTotal() function with an example, you need to first import the fmt package inside the order_test.go, and then add the example function at the end of the file, by updating the file to match the code below:

package order

import (
   "fmt"
   "testing"
   "github.com/Rhymond/go-money"
   "github.com/stretchr/testify/assert"
)

func TestComputeTotal(t *testing.T) {
   o := Order{
       ID:                "45",
       CurrencyAlphaCode: "EUR",
       Items: []Item{
           {
               ID:        "458",
               Quantity:  2,
               UnitPrice: money.New(100, money.EUR),
           },
       },
   }

   total, err := o.ComputeTotal()
   assert.NoError(t, err)
   assert.Equal(t, int64(200), total.Amount())
   assert.Equal(t, "EUR", total.Currency().Code)
}

func ExampleComputeTotal() {
   o := Order{
       ID:                "45",
       CurrencyAlphaCode: "EUR",
       Items: []Item{
           {
               ID:        "458",
               Quantity:  2,
               UnitPrice: money.New(100, money.EUR),
           },
       },
   }

   total, err := o.ComputeTotal()
   if err != nil {
       panic(err)
   }
   fmt.Printf("%d %s\n", total.Amount(), total.Currency().Code)
}

Run the go command once more:

go test -v

You should see output like the following.

Output showing how the example approach works.  The example function ExampleComputeTotal showcases how to compute the total of an order, including instantiation, calculation, and output formatting.

This feature enhances your documentation while simultaneously making your unit tests more robust.

That’s how to write a unit test in go

Unit testing in Go is straightforward, thanks to the testing package in the standard library. By following these steps, you can write effective unit tests for your Go applications, ensuring that your code behaves as expected. This makes it easier to maintain and refactor your codebase.

Remember, unit testing aims not only to find bugs but also to build confidence in your code's correctness and stability.

Temitope Taiwo Oyedele is a software engineer and technical writer. He likes to write about things he’s learned and experienced.

The test icon in the tutorial's main image was created by Freepik on Flaticon.