avatarIsrael Josué Parra Rosales

Summary

The web content provides an in-depth guide on implementing unit testing in a microservice architecture, specifically focusing on the shopping cart service, using Go's standard testing library and the gomock mocking framework.

Abstract

The provided web content is part of a comprehensive series on building a microservice with Go, with this particular chapter dedicated to unit testing. It begins by defining unit testing and its importance in software development, emphasizing characteristics such as isolation, automation, fast execution, and repeatability. The article then delves into Go's built-in support for unit testing through its standard library, discussing best practices and naming conventions. It introduces the concept of test tables for organizing multiple test cases efficiently and proceeds to demonstrate their implementation. The latter part of the content shifts to practical application, showcasing how to use gomock to create mock objects for testing the shopping cart microservice. The code examples illustrate unit tests for the creation and retrieval of shopping carts, highlighting the use of test tables and mock objects to simulate various scenarios and validate the service's functionality.

Opinions

  • The author advocates for the use of unit testing as a fundamental practice in modern software development, particularly within agile and CI/CD environments.
  • There is a strong emphasis on the benefits of using test tables in Go for structuring test cases, which enhances readability and ensures comprehensive test coverage.
  • The author suggests that the gomock library is a valuable tool for developers to mock interfaces, facilitating dependency injection and enabling more complex testing scenarios.
  • The content reflects a preference for following common conventions and practices in Go unit testing to create effective and reliable tests.
  • The author implies that familiarity with third-party testing tools and libraries, in addition to Go's standard testing library, is beneficial for tackling complex testing needs.

Chapter 15 Coding our Microservice (Part 5)

Implementing Unit Testing

The following list is the previous chapters of this series:

I recommend you take a look at the previous chapters if you have not read them yet. That will help you to get more knowledge in this wonderful world of “Microservices architecture”.

Introduction

Before starting to describe and analyze which code will be needed to create a test for our little project, let’s start defining what unit testing is.

Is a software testing technique that focuses on verifying a software application’s individual units or components in isolation. These units or components typically correspond to small, self-contained parts of the code, such as functions, methods, or structures.

The primary goal of unit testing is to ensure that each unit of code functions correctly according to its design and specifications. It involves writing test cases that evaluate the unit’s behavior under various conditions and checking whether the actual outcomes match the expected results.

Unit testing is a fundamental practice in modern software development, especially in agile and continuous integration/continuous deployment (CI/CD) environments. It provides confidence that individual code units work as expected and can help catch bugs early in the development process, reducing the cost and effort required for debugging and maintenance.

Key characteristics of unit testing include:

  • Isolation Unit tests are designed to be independent and isolated from other parts of the application. This means that when testing a specific unit, all external dependencies should be mocked or stubbed to ensure that the test focuses solely on that unit.
  • Automation Unit tests are automated, meaning they can be run automatically by testing frameworks or tools without manual intervention. This automation makes it possible to execute tests frequently and consistently.
  • Fast Execution Unit tests are typically quick to execute since they target small units of code. This allows developers to run tests frequently during development without significant delays.
  • Repeatable Unit tests should produce the same results every time they are run. This predictability is essential for identifying regressions or unexpected behavior.

Golang and Unit Test

In Go, just like in many other programming languages, unit testing plays a vital role in the development process. The Go programming language offers a built-in standard library known as “testing,” which serves as a robust framework for crafting and executing unit tests with efficiency and effectiveness.

Here are some of the common features and best practices associated with unit tests in Go:

Standard Library

Go offers a standard testing library, which includes essential functions and types for crafting tests. The testing.T type is utilized to represent a test case and record its outcomes.

Naming Convention

In Go, adhering to naming conventions is crucial. Test file names should end with the “_test” suffix, such as “my_package_test.go”. Test functions should commence with “Test” followed by a descriptive name indicating what is being tested. They must accept a parameter of type testing.T. For example, a test function might look like TestSome(t *testing.T).

Additional Testing Tools

Beyond the standard Go library, a wide array of third-party testing tools and libraries are at your disposal. These tools can help you tackle more complex testing scenarios, including mocking, assertions, and measuring code coverage.

Testing execution

To run the tests, the “go test” command is used in the directory containing the test files. Go will automatically compile and run all test functions in that directory.

In Go, unit tests are easy to write and run thanks to the standard “test” library already built into the language natively. It is recommended to follow common conventions and practices, which will help us create effective tests that reliably and efficiently verify the behavior of your functions and methods.

Test Tables

The “Test Tables” are often used to test a function with multiple expected input and output cases. Test tables are a highly effective and structured testing technique frequently employed in Go to systematically evaluate the behavior of a function under a multitude of input and output scenarios.

With the Test Tables we have a way to organize test cases into a concise and readable format, instead of writing separate test case functions for each individual input scenario, With the implementation of Test Tables is possible to define a table that includes various sets of inputs and their corresponding expected outputs, for example, each row in the table represents a specific test case, with the input values provided in one or more columns and the expected output in another.

One significant advantage of using test tables is their ability to ensure comprehensive test coverage. By simply extending the table with additional test cases, you can explore various combinations of input parameters and expected outcomes. This makes it easier to catch edge cases and uncover potential bugs that might otherwise go unnoticed.

Another benefit is the improved readability of tests. Test tables make it clear at a glance what scenarios are being tested, as the test cases are presented in a structured format. This can be especially valuable when collaborating with other developers or revisiting tests after some time has passed.

To create a test table in Go, you typically define an array of structures, with each structure representing a test case. Each field in the structure corresponds to an input parameter or an expected output. You then iterate over the table, running the function being tested with each set of inputs and checking if the actual output matches the expected output.

Let’s illustrate this concept with a simple example. Consider a function that adds two numbers and returns the result. In the code example below, you’ll find a test function that defines multiple test cases to evaluate this addition function. Each test case is represented by a struct called testCase, which includes two numbers, “a” and “b”, and the expected result, “expected”.

In the example, are defined test cases with different input values and their corresponding expected results are defined. The TestSum function iterates through these test cases, calculates the actual result using the Sum function, and then compares it to the expected result. If they don’t match, it reports an error, making it easy to identify any discrepancies in the function’s behavior.

package mypackage

import "testing"

func TestSum(t *testing.T) {
    testCases := []struct {
        a, b, expected int
    }{
        {1, 2, 3},   // Test Case 1
        {0, 0, 0},   // Test Case 2
        {-1, 1, 0},  // Test Case 3
        {5, -3, 2},  // Test Case 4
    }

    for _, tc := range testCases {
        result := Sum(tc.a, tc.b)
        if result != tc.expected {
            t.Errorf("Sum(%d, %d) = %d; expected %d", tc.a, tc.b, result, tc.expected)
        }
    }
}

Let’s put into practice everything we’ve learned so far with an example using our shopping cart microservice.

These tests focus on the “shoppingcart” package and are designed to test the create and get shopping cart functions in the context of a shopping cart microservice.

In this example will be implemented “gomock”, which is a mocking library for the Go (Golang) programming language. This library allows developers to create mock objects for the interfaces of their programs, which is useful for unit testing and ensuring the correct functionality of individual parts of a program.

Some of the characteristics of “gomock” are the following:

  • Automated Generation of Mocks
  • Setting Expectations
  • Call Verification
  • Integration with Unit Tests
  • Facilitates Dependency Injection
  • Compatible with Standard Test Package

You can find more specifications in the following link: “https://github.com/golang/mock"

The first step before starting with the test code is to generate the mock files, which will be done by running the following command inside of the “/internal/domain/shopping_cart” package:

mockgen -source=shopping_cart_repository.go  -destination=mock/shopping_cart_repository_mock.go

That will generate a new folder named mock inside of the shopping_cart package, the file autogenerated there will be used by our test in the following code:

shopping_cart_service_test.go

package shoppingcart_test
import (
    "errors"
    "testing"
    shoppingcart "github.com/go-microservices/shopping-cart-service/internal/domain/shopping_cart"
    mock_repository "github.com/go-microservices/shopping-cart-service/internal/domain/shopping_cart/mock"
    "github.com/go-microservices/shopping-cart-service/internal/shared/logger"
    "github.com/golang/mock/gomock"
    uuid "github.com/satori/go.uuid"
    "github.com/stretchr/testify/assert"
)
func TestCreate(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    testLogger := logger.NewLogger()
    mockRepo := mock_repository.NewMockShoppingCartRepository(ctrl)
    scService := shoppingcart.NewShoppingCartService(mockRepo, testLogger)
    randUUID := uuid.NewV4()
    testErrMsg := "error creating shopping cart"
    testErr := errors.New(testErrMsg)
    tt := []struct {
        name     string
        obj      shoppingcart.ShoppingCart
        err      error
        wantsErr bool
    }{
        {
            name: "create shopping cart - success result",
            obj: shoppingcart.ShoppingCart{
                UserID: &randUUID,
                ID:     nil,
            },
            err:      nil,
            wantsErr: false,
        },
        {
            name: "create shopping cart - bad request result",
            obj: shoppingcart.ShoppingCart{
                UserID: nil,
                ID:     nil,
            },
            err:      testErr,
            wantsErr: true,
        },
    }
    for _, tc := range tt {
        t.Run(tc.name, func(t *testing.T) {
            mockRepo.EXPECT().
                Create(gomock.Any()).
                Times(1).
                Return(tc.err)
            err := scService.Create(tc.obj)
            if tc.wantsErr {
                assert.NotNil(t, err)
            } else {
                assert.Nil(t, err)
            }
        })
    }
}

func TestGetByUserID(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    testLogger := logger.NewLogger()
    mockRepo := mock_repository.NewMockShoppingCartRepository(ctrl)
    scService := shoppingcart.NewShoppingCartService(mockRepo, testLogger)
    randUUID := uuid.NewV4()
    testErrMsg := "error getting shopping cart"
    testNotFoundErrMsg := "shopping cart not found"
    testErr := errors.New(testErrMsg)
    testNotFoundErr := errors.New(testNotFoundErrMsg)
    tt := []struct {
        name     string
        obj      *shoppingcart.ShoppingCart
        err      error
        wantsErr bool
    }{
        {
            name: "list shopping cart - success result",
            obj: &shoppingcart.ShoppingCart{
                UserID: &randUUID,
                ID:     &randUUID,
            },
            err:      nil,
            wantsErr: false,
        },
        {
            name:     "list shopping cart - error not found",
            obj:      nil,
            err:      testNotFoundErr,
            wantsErr: true,
        },
        {
            name:     "list shopping cart - db error",
            obj:      nil,
            err:      testErr,
            wantsErr: true,
        },
    }
    for _, tc := range tt {
        t.Run(tc.name, func(t *testing.T) {
            mockRepo.EXPECT().
                GetByUserID(gomock.Any()).
                Times(1).
                Return(tc.obj, tc.err)
            sp, err := scService.GetByUserID(&randUUID)
            if tc.wantsErr {
                assert.NotNil(t, err)
            } else {
                assert.Nil(t, err)
                assert.Equal(t, sp.ID, tc.obj.ID)
                assert.Equal(t, sp.ID, tc.obj.UserID)
            }
        })
    }
}

Explaining the code: This code represents unit tests for the create and get shopping cart functions in a shopping cart microservice. It uses the “gomock” package to simulate the behavior of the repository and verify that functions behave as expected in a variety of scenarios.

“TestCreate” function: This test function rigorously assesses the functionality related to the creation of a “shopping cart”.

It creates a “ctrl” test handler utilizing the “gomock” package and ensures its proper closure after the test function through the defer mechanism. Additionally, it establishes a test logger object and instantiates a shopping cart repository named “mockRepo” derived from the auto-generated Mocks crafted earlier.

Then, a set of “tt” test cases containing different shopping cart creation scenarios is defined. Each test case has a name, a shopping cart object “obj”, an error “err”, and an error flag “wantsErr”. These test cases include success scenarios and failure scenarios.

The central segment of this test function revolves around a “for” loop that iterates through each predefined test case outlined in the preceding test table. Within this loop, expectations are meticulously configured on the “mockRepo” object, effectively simulating the behavior of the Create function call.

Subsequently, the Create function within the scService shopping cart service is invoked, utilizing the test object as its parameter. The outcome of this operation is scrutinized, aligning it with the criteria specified in the respective test case.

“TestGetByUserID” Function: Similar to the previous function, this test focuses on the functionality of getting shopping carts by user ID. It also uses gomock to create a test controller, a test logger, and a mock shopping cart repository.

A couple of test cases with different scenarios are defined. These scenarios include successfully getting a shopping cart, cases where the shopping cart is not found resulting in a “not found” error, and database error cases.

The for loop iterates through the test cases, sets the expectations on the mockRepo object to mock the GetByUserID function call, calls this function on the shopping cart service, and checks if the GetByUserID function is called. produces an error or if the results are consistent with what is expected.

Next readings …

Wait for Chapter 16 “Coding our Microservice (Part 6) — Adding OpenAPI documentation”.

Golang
Software Development
Software Architecture
Software Engineering
Programming Languages
Recommended from ReadMedium