GoLang – 23 – Unit Testing Best Practices

Unit Testing Best Practices in Go: Mocking Dependencies, Test Organization, and Structure

Unit testing is a crucial part of software development, ensuring that individual components of your code work as expected. In this section, we’ll explore best practices for unit testing in Go, including techniques for mocking dependencies and organizing tests effectively.

Mocking Dependencies in Go Tests

When unit testing, you often want to isolate the code under test from external dependencies such as databases, APIs, or other services. Mocking is a technique that allows you to simulate the behavior of these dependencies. In Go, libraries like “testify/mock” and “gomock” can help with mocking.

Using testify/mock

The “testify/mock” library is widely used in the Go community for mocking. It provides a convenient way to create mock objects for interfaces and functions. Here’s an example:


package main

import (
    "fmt"
    "github.com/stretchr/testify/mock"
)

type MyMockedObject struct {
    mock.Mock
}

func (m *MyMockedObject) DoSomething() error {
    args := m.Called()
    return args.Error(0)
}

func main() {
    myMock := new(MyMockedObject)
    myMock.On("DoSomething").Return(nil)

    err := myMock.DoSomething()
    if err == nil {
        fmt.Println("Test Passed")
    } else {
        fmt.Println("Test Failed")
    }
}

In this example, the “testify/mock” library is used to create a mock object for the “MyMockedObject” type. We set expectations on the “DoSomething” method and then call it. If the method behaves as expected, the test will pass.

Using gomock

“gomock” is another powerful library for creating mock objects in Go. It generates code based on your interface definitions, making it suitable for complex mocking scenarios. Here’s an example:


package main

import (
    "fmt"
    "testing"
    "github.com/golang/mock/gomock"
)

type MyMockedObject interface {
    DoSomething() error
}

func main() {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    myMock := NewMockMyMockedObject(ctrl)
    myMock.EXPECT().DoSomething().Return(nil)

    err := myMock.DoSomething()
    if err == nil {
        fmt.Println("Test Passed")
    } else {
        fmt.Println("Test Failed")
    }
}

In this example, “gomock” is used to create a mock object based on the “MyMockedObject” interface. Expectations are set for the “DoSomething” method, and the test checks whether the method behaves as expected.

Test Organization and Structure

Organizing and structuring your tests is crucial for maintaining a robust and maintainable test suite. Proper test organization helps with readability, maintainability, and ease of debugging.

Test Naming Conventions

Follow a consistent naming convention for your test functions. By convention, test functions should be named with the prefix “Test” followed by the name of the function being tested. For example:


func TestMyFunction(t *testing.T) {
    // Test logic
}
Table-Driven Tests

Table-driven tests are a powerful technique for testing various inputs and expected outputs of a function. This approach keeps tests organized and easy to expand. Here’s an example:


func TestAdd(t *testing.T) {
    tests := []struct {
        a, b, expected int
    }{
        {1, 2, 3},
        {0, 0, 0},
        {-1, -1, -2},
        {5, -3, 2},
    }

    for _, test := range tests {
        result := Add(test.a, test.b)
        if result != test.expected {
            t.Errorf("Add(%d, %d) expected %d, but got %d", test.a, test.b, test.expected, result)
        }
    }
}

In this example, a table-driven test for the “Add” function is created, allowing you to test various input combinations and expected outcomes efficiently.

Test Helper Functions

Consider using helper functions to avoid code duplication in your tests. Helper functions can set up common test scenarios or assertions. They improve test readability and maintainability.


func TestDivision(t *testing.T) {
    assertDivisionResult(t, 6, 3, 2)
    assertDivisionResult(t, 9, 0, -1) // Test division by zero
}

func assertDivisionResult(t *testing.T, dividend, divisor, expected int) {
    result, err := Divide(dividend, divisor)
    if divisor == 0 {
        if err == nil {
            t.Errorf("Division by zero should return an error.")
        }
    } else {
        if result != expected {
            t.Errorf("Division result for %d/%d should be %d, but got %d", dividend, divisor, expected, result)
        }
    }
}

In this example, the “assertDivisionResult” helper function is used to simplify testing division operations. It checks both the result and error conditions.

Example of Well-Structured Tests

Let’s look at an example of well-structured tests for a simple math library:


package math

import (
    "testing"
)

func TestAdd(t *testing.T) {
    tests := []struct {
        a, b, expected int
    }{
        {1, 2, 3},
        {0, 0, 0},
        {-1, -1, -2},
        {5, -3, 2},
    }

    for _, test := range tests {
        result := Add(test.a, test.b)
        if result != test.expected {
            t.Errorf("Add(%d, %d) expected %d, but got %d", test.a, test.b, test.expected, result)
        }
    }
}

func TestSubtract(t *testing.T) {
    tests := []struct {
        a, b, expected int
    }{
        {5, 2, 3},
        {0, 0, 0},
        {-1, -1, 0},
        {3, 5, -2},
    }

    for _, test := range tests {
        result := Subtract(test.a, test.b)
        if result != test.expected {
            t.Errorf("Subtract(%d, %d) expected %d, but got %d", test.a, test.b, test.expected, result)
        }
    }
}

In this example, test functions are well-organized, and a table-driven approach is used to test both the “Add” and “Subtract” functions.

Unit testing in Go is vital for code quality, and following best practices like mocking dependencies and structuring tests effectively will make your testing process more efficient and reliable.