Unit Testing

In order to maintain the correctness of the code, unit test must be implemented in the business aspect of the code where most of the logic happens. There are two layers that can be tested, the core and the handler. Both testing require a mock instance of the dependencies to be able to be properly tested.

Creating Database Instance with Docker

In attempt to properly demonstrate the correctness of the code and minimize the amount of unused code, this project takes the approach of not mocking the Database interface and test the code with a mock Database. Instead, a real Database instance is set up with Docker. A series of tests will later be executed against the docker container. After all the tests has been conducted, the Docker containers will be deleted.

c docker.Container

func TestMain(m *testing.M) {
    fmt.Printf("Testing in %v environment...", os.Getenv("ENV"))
    var err error

    // Initiate Firestore container
    fArgs := []string{"-e", "FIRESTORE_PROJECT_ID=mock-project"}
    c, err = docker.NewContainer("mtlynch/firestore-emulator", "8080", fArgs...)
    if err != nil {
        panic(err.Error())
    }

    // Run the test and return exit code
    m.Run()

    // Remove all containers once the test is done
    defer func() {
        fmt.Print("Removing firestore container...\n")
        stop := exec.Command("docker", "rm", "-f", "mockfirestore")
        stop.Run()
    }()
}

The docker package is located in internal/business/sys/docker/docker.go. This package has a struct for Container which represents a container instance, a function called NewContainer that spins up a container instance, and a private function called extractIPPort to extract the host and the port of the container created by the NewContainer function.

NewContainer function requires image parameter for the image name, port parameter for the port number exposed by the container, and args... parameter if other arguments are required to build the Docker image. This function returns a Container instance that contains an information of the container ID and the host (including the host and the IP) to access to container.

In the previous example a Firestore container is created using the following code:

// Initiate Firestore container
fArgs := []string{"-e", "FIRESTORE_PROJECT_ID=mock-project"}
f, err = docker.NewContainer("mtlynch/firestore-emulator", "8080", fArgs...)

An image from https://hub.docker.com/r/mtlynch/firestore-emulator is used to create a local Firestore emulator that is contained inside a Docker container. To run the image, an environment variable FIRESTORE_PROJECT_ID is required, so the argument for docker -e FIRESTORE_PROJECT_ID=mock-project is assigned to a variable fArgs and is used as the parameter args... in the function. This function will create the following docker container

docker ps
package docker

import (
    "bytes"
    "encoding/json"
    "fmt"
    "os"
    "os/exec"
    "strings"
)

type Container struct {
    Id   string
    Host string
}

// Create a Docker container in the local environment
func NewContainer(name string, image string, port string, args ...string) (c Container, err error) {
    arg := []string{"run", "-d"}
    arg = append(arg, "--name")
    arg = append(arg, name) // container name
    arg = append(arg, "-P")
    arg = append(arg, args...)
    arg = append(arg, image) // container image
    cmd := exec.Command("docker", arg...)

    var out bytes.Buffer
    cmd.Stdout = &out

    fmt.Printf("Starting %v container... \n", image)
    if err := cmd.Run(); err != nil {
        return c, fmt.Errorf("could not start %v container: %v", image, err.Error())
    }

    str := out.String()
    c.Id = str[:12]
    hostIp, hostPort, err := extractIPPort(c.Id, port)
    if err != nil {
        return c, err
    }

    c.Host = fmt.Sprintf("%v:%v", hostIp, hostPort)
    return c, err
}

// Get Host IP and port on a running container
// Source: https://github.com/ardanlabs/service/blob/master/foundation/docker/docker.go
func extractIPPort(id string, port string) (hostIP string, hostPort string, err error) {
    tmpl := fmt.Sprintf("[{{range $k,$v := (index .NetworkSettings.Ports \"%s/tcp\")}}{{json $v}}{{end}}]", port)

    cmd := exec.Command("docker", "inspect", "-f", tmpl, id)
    var out bytes.Buffer
    cmd.Stdout = &out
    if err := cmd.Run(); err != nil {
        return "", "", fmt.Errorf("could not inspect container %s: %w", id, err)
    }

    // When IPv6 is turned on with Docker.
    // Got  [{"HostIp":"0.0.0.0","HostPort":"49190"}{"HostIp":"::","HostPort":"49190"}]
    // Need [{"HostIp":"0.0.0.0","HostPort":"49190"},{"HostIp":"::","HostPort":"49190"}]
    data := strings.ReplaceAll(out.String(), "}{", "},{")

    var docs []struct {
        HostIP   string
        HostPort string
    }
    if err := json.Unmarshal([]byte(data), &docs); err != nil {
        return "", "", fmt.Errorf("could not decode json: %w", err)
    }

    for _, doc := range docs {
        if doc.HostIP != "::" {

            // Running unit test with Docker within GitLab runner.
            // use "docker" instead of "localhost" to refer to local image container
            if os.Getenv("ENV") == "GITLAB" {
                hostIP = "docker"
            } else {
                hostIP = doc.HostIP
            }
            return hostIP, doc.HostPort, nil
        }
    }

    return "", "", fmt.Errorf("could not locate ip/port")
}

Core Testing

Core is the layer where most of the business logic for the project happens. It obtains data from the store interface. An instance of a Core may require multiple dependencies depending on the model's business process, but most Core require at least a single database dependency may it be Redis, Firestore, or Postgresql. Dependencies of a Core is listed as the fields in the Config struct.

type Core struct {
    store	db.Store
}

type Config struct {
    DB *firestore.Client
}

// NewCore constructs a core for api access.
func NewCore(cfg Config) Core {
    return Core{
        store: db.NewStore(cfg.DB),
    }
}

// Sample function to get a list of items using the Store interface
func (c Core) GetItems(ctx context.Context) ([]Items, error) {
    res, err := c.store.GetItems(ctx)
    if err != nil {
        return nil, err
    }

    return res, err
}

When conducting a unit test for a Core, the instance of the Core must be initialized using the NewCore function. In this example, sample Core only require a single dependency which is *firestore.Client as the database.

Arrange, Act, Assert

You have the Database instance running in a Docker container. Now you need to setup the Core interface so it can be used in the testing process. Unit testing process can be separated into 3 steps. Arrange, act ,and assert.

Arrange is the process when you setup all the required variables, instances, and interface for the test. In this example, an Instance of Firestore client is required to be able to create an instance of sample Core. If a Core depends on other dependencies such as Kafka, logger, gRPC, etc, they must first be initialized by creating a mock of the interface.

In line 3, a Firestore instance is created by using the Init function. Note that this Firestore instance connects to a local Firestore connection that has been setup previously by the Docker command. The Core instance is then created in line 6 using the Firestore client instance assigned to fc variable. After the Core instance has been created, you can continue creating the action and assertion process to validate the desired test output.

In case of using Firestore emulator, os.Setenv("FIRESTORE_EMULATOR_HOST", c.Host) is used to set the environment variable for Firestore Emulator host.

Act is the execution process. This is done by calling the function using the Core interface. A single test is not limited to only a single function call. For example if you are testing a core that inserts data to the database, you will call a function that inserts the data, and another call to the get function to make sure that the data you previously inserted is available on the response.

Comparing the result from the act with the expected result is called the assertion process. Description for a single assertion can be put inside the t.Log() function and the assertion process can be written inside that log function for more readibility.

func TestSample(t *testing.T) {
// Arrange
os.Setenv("FIRESTORE_EMULATOR_HOST", c.Host)

fc, err := firestore.Init(firestore.Config{
    ProjectId: "mock-project"
})

sCore := NewCore(Config{
    DB: fc,	
})

ctx := context.Background()

// Act
res, err := sCore.GetItems(ctx)
if err != nil {
    t.Errorf("Error occurred when getting a list of items: %v", err.Error())
}

// Assert
t.Log("Length of the initial sample data must be 5")
{
    if len(res) != 5 {
        t.Errorf("The length of the response must be 5, but it returns %d", len(res))
    }
}
}

Handler Testing

We have conducted a core testing in the previous section to test the business logic of data models. In this section, we will cover a method for testing the application handler using similar approach. Initially, create TestMain() to initiate all Docker containers for required dependencies.

type HandlerTest struct {
    https *gin.Engine
}

// Start testing handlers
func TestHandler(t *testing.T) {
    // Setup environment variable for testing
    os.Setenv("FIRESTORE_EMULATOR_HOST", f.Host)

    // Setup database dependency
    fir, err := firestore.Init(firestore.Config{
        ProjectID: "mock-project",
    })
    if err != nil {
        panic(err.Error())
    }

    // Create Gin HTTP router instance
    dep := integrationtest.NewIntegration()
    httpS := gin.Default()
    ginConfig := Config{
        Logger: dep.Log,
        DB:     fir,
        Tracer: dep.Tracer,
        Kafka:  dep.Kafka,
    }

    GinHttpRouter(ginConfig, httpS)

    // Run different tests
    tests := HandlerTest{
        https: httpS,
    }

    t.Run("healthCheck200", tests.TestHealthCheck)
    t.Run("notFound404", tests.TestNotFound)
}

Handler test entry point is in the TestHandler() function. First, setup all the dependencies required by the handler Config struct. Most basic dependencies can be obtained from the integrationtest module using the NewIntegration function. Just like in the main.go, initialization of the handler must be triggered by running the GinHttpRouter function to assign the routes in v1.go to the http handler.

func (h HandlerTest) TestHealthCheck(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()

h.https.ServeHTTP(w, r)

t.Log("Testing health check endpoint. Should return status of 200")
{
    if w.Code != http.StatusOK {
        var got v1.Response
        if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
            t.Errorf("Unable to unmarshal JSON response: %v", err.Error())
        }
        t.Errorf("Status code should be 200. Got %v instead. Error: %v", w.Code, got)
    }
}
}

func (h HandlerTest) TestNotFound(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/nonexistent", nil)
w := httptest.NewRecorder()

h.https.ServeHTTP(w, r)

t.Log("Testing unavailable path. Should return status of 404")
{
    if w.Code != http.StatusNotFound {
        var got v1.Response
        if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
            t.Errorf("Unable to unmarshal JSON response: %v", err.Error())
        }
        t.Errorf("Status code should be 404. Got %v instead. Error: %v", w.Code, got)
    }
}
}

For each test, a method from the struct HandlerTest is created. These functions are called in the TestHandler function. In a test, since the default HTTP handler from /net/http is used, r and w are variables to store the http request and response writer. The HTTP call is triggered by the ServeHTTP function and the response will be written to the w variable.

Assertion for handler should be done by comparing the response code to the expected response code. If a more detailed assertion needs to be done, the response body can be parsed and used for comparison using the json.NewDecoder() function.

Running Test

To run the test, simply run go test. If the test fails, it will return an exit code 1. Use go test -v if you want to see a detailed information of the test.

To test all test case in the project, go to the main directory and run go test ./....

Checking Test Coverage

go test -coverprofile cover.out go tool cover -html=cover.out

References

https://go.dev/doc/tutorial/add-a-test