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.
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
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 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.
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)) } } }
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.
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 ./....
go test -coverprofile cover.out
go tool cover -html=cover.out