Everything You Need to Know About Golang App Testing
Oftentimes, people starting their journey in the field of software development don’t understand the importance of testing, including Golang app testing, until late in their careers.
It’s essential to think about testing as an integral part of the software development lifecycle (SDLC) not only in theory but in practice, too. When building cutting-edge software, you need to make sure that the version being upgraded is error-free and that almost all of the failure cases have been considered. Tests help to make sure that certain conditions are met every time a change is made.
Why testing matters
Consider a scenario where a software solution is never tested. If you deploy changes to the production environment (accessible to the actual customer base) and your service doesn’t have a proper monitoring set, it could take a long time for errors to be discovered. As a result, the mean time to detect (MTTD) metric increases.
On the other hand, if you had test cases in the early stage of development, you would have been able to prevent erroneous changes from reaching production.
Another scenario is when you are managing a large open source project like Mattermost. With developers from around the world contributing to your code, it would be a nightmare if you had to manage and manually run each change to be deployed to production.
Instead of manually reviewing every PR contributors submit, you can use automated tests with predetermined specifications for every incremental change. If the change is approved, you won’t have to manually write any code (like building the binary or Docker image). Instead, the testing and building is done using continuous integration and continuous delivery (CI/CD) pipelines.
This guide helps you understand the different types of tests, how to conduct Golang testing, and how to understand their scope. It touches up HTTP mocking, error cases, tests covering file manipulation, and coverage in unit testing. Additionally, the guide also examines a simple scenario for functional testing and its importance. Finally, the guide covers building Golang CI/CD pipelines using Jenkins, Docker, and Minikube to help you understand how things work in the cloud.
JSON Validation, YAML Rules, and Identifying Testing Requirements
The logic for the demo app has been written for testing purposes. The microservice presented reads multiple JSON files from a given folder representing orders from two different regions (i.e., APAC and the EU). These files are traversed and parsed in Golang structs. Based on respective region rules stored in YAML for each region, we validate these structs and finally transform the objects into a single struct. This final transformation is sent to an output HTTP endpoint.
Example Order.JSON | Example Rule.JSON |
{ "region": "APAC", | region: APAC |
To write test cases, you need to understand what each function does. In this guide, we’ll start with the parser function, and we’ll also identify the logic and error cases for each function. When you’re starting to write unit tests, this is a good practice. The parser function takes two inputs, which contain the directory for the rules and orders listed region-wise.
func parser(store_directory, rules_directory string) (OrderCompilation, RulesCompilation, error) {
// we go over the JSON directory with orders
files, err := ioutil.ReadDir(store_directory)
if err != nil {
return OrderCompilation{}, RulesCompilation{}, err
}
// for each file, we identify which region it identifies to and add it golang struct
allOrders := OrderCompilation{}
for _, file := range files {
orderFile, err := ioutil.ReadFile(store_directory + file.Name())
if err != nil {
return OrderCompilation{}, RulesCompilation{}, err
}
contents := string(orderFile)
orders, err := store.CreateOrdersStruct(orderFile)
if err != nil {
return OrderCompilation{}, RulesCompilation{}, err
}
if strings.Contains(contents, "APAC") {
allOrders.APAC = orders
} else if strings.Contains(contents, "EU") {
allOrders.EU = orders
} else {
return OrderCompilation{}, RulesCompilation{}, errors.New("incorrect region provided in orders")
}
}
// we repeat this for rule files.
files, err = ioutil.ReadDir(rules_directory)
if err != nil {
return OrderCompilation{}, RulesCompilation{}, err
}
allRules := RulesCompilation{
rules: map[string]store.Rules{},
}
for _, file := range files {
rulesFile, err := ioutil.ReadFile(rules_directory + file.Name())
if err != nil {
return OrderCompilation{}, RulesCompilation{}, err
}
contents := string(rulesFile)
rules, err := store.CreateRulesStruct(rulesFile)
if err != nil {
return OrderCompilation{}, RulesCompilation{}, err
}
if strings.Contains(contents, "APAC") {
allRules.rules["APAC"] = rules
fmt.Println(allRules.rules["APAC"])
} else if strings.Contains(contents, "EU") {
allRules.rules["EU"] = rules
} else {
return OrderCompilation{}, RulesCompilation{}, errors.New("incorrect region provided in rules")
}
}
// return the compiled orders and rules
return allOrders, allRules, nil
}
If you look closely, there are four main error points in this function:
- Nonexistent folder
- Nonexistent file
- Incorrect JSON format in the file
- Region is not APAC or EU
Any of these will trigger an error in the function. This means we can write five unit test cases, including the “all valid” case, and four error cases.
The next function validates the JSON in accordance with the rule struct we just made. Based on the orders and rules, we can have filters on Amount
, Email
, and product ID
.
func validation(order store.Order, allRules RulesCompilation, region string) (bool, error) {
// first we identify the region of rule and order
regionRules := allRules.rules[region]
// We can have filters on amount, email and productid
for _, rule := range regionRules.RuleList {
if rule.AmountFilter != "" {
// we find the amount filter in the rules
rg := regexp.MustCompile(`>|' {
if order.Amount < filterAmt {
return false, nil
}} else if rule.AmountFilter[0] == ' filterAmt {
return false, nil
}} else if rule.AmountFilter[0] == '=' {
if order.Amount != filterAmt {
return false, nil
}}}
// if email matches email filter, return true
if rule.EmailFilter != "" {
if order.UserEmail != rule.EmailFilter {
return false, nil
}} // if there are blacklisted products remove them
if len(rule.BlacklistProduct) != 0 {
for _, id := range rule.BlacklistProduct {
for _, rg := range order.ProductList {
if rg == id {
return false, nil
}}}}}
return true, nil
}
As you can see, there are fewer error points here. However, these are breaking errors that we can get based on incorrect input by users.
Unit test cases must be used not only for error identification but also for logic validation. There are many conditional statements in this function, and so unit tests must be written to ensure prevention of false positives and true negatives. Example error point and test cases include:
- If the user provides incorrect
- False positives: invalid JSON seen as valid
- True negatives: valid JSON seen as invalid
Finally, we consider a function with an external call. Often, external APIs that you’re using will cost money. So, each time you do a test using this endpoint, you’ll incur a cost. Of course, it’s important to prevent this, and we’ll discuss how to do that in the next section.
func processOrderTransformation(finalFiles []store.Order) (string, error) {
output := OrderTransformation{filteredOrders: finalFiles}
byteOutput, err := json.Marshal(output)
if err != nil {
return "", err}
c := &http.Client{}
// we create an HTTP Endpoint
req, err := http.NewRequest("POST", "https://EXAMPLE-ENDPOINT/post", bytes.NewBuffer(byteOutput))
if err != nil {
return "", err}
response, err := c.Do(req)
if err != nil {
return "", err}
// finally we send and close the response body
defer response.Body.Close()
responseBody, err := ioutil.ReadAll(response.Body)
if err != nil {
return "", err}
return string(responseBody), nil}
There are three error points here:
- JSON marshaling
- Error from the external API during our POST request.
- There can even be an error making the response body, but that’s very rare.
Combining these functions gives us a fully working microservice. The workflow of all these functions combined looks like this:
func (ctrl *TransformerController) TransformerHandler(rw http.ResponseWriter, r *http.Request) {
// read and parse the rules
orderCompilation, ruleCompilation, err := parser(ctrl.Store_json_dir, ctrl.Region_rules_dir)
if err != nil {
ctrl.logger.Error("Error in Parsing orders and rules", zap.Any("error", err))
rw.WriteHeader(http.StatusInternalServerError)
rw.Write([]byte("Error Parsing Files and rules"))
return}
filteredFiles := []store.Order{}
// Region 1
apacOrders := orderCompilation.APAC
for _, apacOrder := range apacOrders.OrderList {
check, err := validation(apacOrder, ruleCompilation, "APAC")
if err != nil {
ctrl.handleInternalError(rw, "APAC", err)
return}
if check {
filteredFiles = append(filteredFiles, apacOrder)
} else {
ctrl.logger.Info("Order Rejected", zap.Any("order", apacOrder))}
}
// Add logic for the remaining regions.
// send the output to the external API
linkinfo, err := processOrderTransformation(filteredFiles)
if err != nil {
ctrl.logger.Error("Error in sending file processed orders", zap.Any("error", err))
rw.WriteHeader(http.StatusInternalServerError)
rw.Write([]byte("Error in sending file processed orders"))
}
ctrl.logger.Info("Link", zap.Any("Details", linkinfo))
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("Files Parsed and Validated" + linkinfo + "\n" + fmt.Sprintf("%v", filteredFiles)))
}
How to Implement Unit and Functional Tests
- Preventing false positives and true negatives with conditional statements
- Use file manipulation to test data sources
- Use HTTP mocking to test external services and APIs
- Bring things together with functional tests
In the previous section, you learned how to find error points in your application. This is a good practice, since you can stage unit tests based on these error points.
Test coverage refers to the percentage of lines covered with unit tests. These error points help us bump up test coverage and avoid making breaking changes to functions. Although the idea of software that is fully tested is a myth, industry standards suggest to keep test coverage above 70%.
Unit tests are written in a file in the same package as the code you are testing. If you’re writing tests for transformer.go
, the test file will be in the same location with the name transformer_test.go
.
To check the coverage of the tests you can use this:
$ go test ./... -cover
These concepts for unit testing in Golang have been considered:
- Testing functions with conditional statements
- Testing functions with file manipulation
- Testing functions calling external functions or APIs
1. Preventing false positives and true negatives with conditional statements
For functions with simple conditional cases, it’s best to validate each and every condition that may be possible. This example covers the error point and passthrough case and makes sure we get the expected output.
To write any test case, you should first formulate the input to the function based on your own understanding. While writing an all-pass case, provide an input that you believe will pass. If that doesn’t happen, the function logic is incorrect. In other words, you hold the complete power to validate and check each function during unit testing.
func TestValidation(t *testing.T) {
order := store.Order{
OrderId: "1",
ProductList: []string{"2", "3"},
Amount: 100,
UserEmail: "[email protected]",}
allRules := RulesCompilation{
rules: map[string]store.Rules{
"APAC": {
Region: "APAC",
RuleList: []store.Rule{
{
AmountFilter: ">>>"
_, err := validation(order, allRules, "APAC")
if err == nil {
t.Fatalf("Did not get error when expected: %v", err)}
})}
2. Use file manipulation to test data sources
The first step in testing files with file read or write operations is to set up temporary folders and files. This can be easily done in Golang using ioutil.TempDir()
.
Keep in mind that you need to remove the temporary files. First, set up the orders and rules folder. Once that’s done, create temporary files in these folders and write content in them.
Golang has its own testing library, which we’ll use in this case. We can write subtests for any test scenario, which can be run using t.Run("SubtTest name", func)
. In this case, a subtest for the parser function is being called after the basic setup that is supposed to pass. Call the parser function after the directories have been set.
To ensure that your logic is correct, you should also compare the output structs. A good library to test if two structs are deep-equal is reflect
. For any unexpected case, we use t.Fatalf()
which panics and gives us a sense of what went wrong.
func TestParser(t *testing.T) {
// we need to set up temporary directories for this case.
// golang provides this in ioutil.TempDir
dir1, err := ioutil.TempDir("./", "")
if err != nil {
t.Fatalf("Unable to create Temp Dir 1 : %s", dir1)
}
dir2, err := ioutil.TempDir("./", "")
if err != nil {
t.Fatalf("Unable to create Temp Dir 1 : %s", dir2)
}
// we need to make sure the temp directories are removed
defer os.RemoveAll(dir1)
defer os.RemoveAll(dir2)
// now we can set up temporary files
oTempName := "apacOrder.json"
f1, err := os.Create(fmt.Sprintf("./%s/%s", dir1, oTempName))
if err != nil {
t.Fatalf("Unable to create temporary file %s", oTempName)
}
// write contents to the orders file
contents := `{"region": "APAC","orderlist": [{"orderid" : "1","amount":33.44,"useremail":"[email protected]","create_at":""}]}`
f1.WriteString(contents)
rTempName := "apacDirective.yaml"
f2, err := os.Create(fmt.Sprintf("./%s/%s", dir2, rTempName))
if err != nil {
t.Fatalf("Unable to create temporary file %s", rTempName)
}
contents = "region: APAC\nrulelist:\n - amountfilter: \"<18000\""
// write contents to the rules file
f2.WriteString(contents)
// first test case
t.Run("Good: All Pass", func(t *testing.T) {
oc, rc, err := parser(fmt.Sprintf("./%s/", dir1), fmt.Sprintf("./%s/", dir2))
if err != nil {
t.Fatalf("Got unexpected error %v", err)
}
expectedOc := OrderCompilation{
APAC: store.Orders{
Region: "APAC",
OrderList: []store.Order{
{ OrderId: "1",
Amount: 33.44,
UserEmail: "[email protected]",}}}}
expectedRc := RulesCompilation{
rules: map[string]store.Rules{
"APAC": {
Region: "APAC",
RuleList: []store.Rule{{
AmountFilter: "<18000",}}}}}
if !reflect.DeepEqual(oc, expectedOc) {
t.Fatalf("Incorrect OrderCompilation Generated. %v, %v", oc, expectedOc)
}
if !reflect.DeepEqual(rc, expectedRc) {
t.Fatalf("Incorrect ruleCompilation Generated. %v, %v", rc, expectedRc)
}})}
3. Use HTTP mocking to test external services and APIs
Many functions will have external API calls. During unit testing, you don’t want to go out of the scope of the given function. That being the case, you need to handle anything that goes outside the scope of the function.
There are many ways to create alternate functions that replicate the behavior of an external function or API, which is called mocking. In this example, all external HTTP requests to “https://EXAMPLE-ENDPOINT/post” will be mocked. This means that it will make these requests and return the output based on the code we have written. Now, you can call your function confidently since there aren’t any external calls.
func TestProcessOrderTransformtion(t *testing.T) {
// we active http mocking
httpmock.Activate()
defer httpmock.DeactivateAndReset()
// for every request to this endpoint, we can mock the behavior of the API and give responses.
httpmock.RegisterResponder("POST", "https://EXAMPLE-ENDPOINT/post", func(req *http.Request) (*http.Response, error) {
resp, err := httpmock.NewJsonResponse(200, "200 OK")
if err != nil {
return httpmock.NewStringResponse(500, "Error Generating Response"), nil}
return resp, nil})
// now we call the main function
resp, err := processOrderTransformation([]store.Order{})
if err != nil {
t.Fatalf("Unexpected Error Encountered :%v", err)
}
// it is a good practice to ensure we get the correct code.
expected := "200 OK"
if !strings.Contains(resp, expected) {
t.Fatalf("Unexpected Message from server. %v", resp)}
}
4. Bring things together with functional tests
Finally, we can take a look at functional tests.
Like the name suggests, functional tests are about testing all the functions together. Again, you don’t want external calls outside the software boundary. To make sure that’s the case, you can use HTTP mocking here, too! However, it starts with how the actual software will be triggered (e.g., a GET HTTP request in our case).
The functional tests can be written in the main directory outside the scope of any Golang project. A simple functional test which tests an all-pass case is shown below. In functional tests, you will have to enable all of the environment and global variables used by the application.
We send the request to our own server using http/test
framework, which is very useful. This makes sure that you can send an HTTP request from one server to another server without running anything!
To further simplify the scope, you can record the output using NewRecorder()
and compare it with the expected output of the whole software.
func TestHealthCheckHandler(t *testing.T) {
log, _ := zap.NewProduction()
defer log.Sync()
transc := ordertransformerservice.NewTransformerController(log)
transc.Store_json_dir = "../ordertransformerservice/json_store/"
transc.Region_rules_dir = "../ordertransformerservice/region_rules/"
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpmock.RegisterResponder("POST", "https://httpbin.org/post", func(req *http.Request) (*http.Response, error) {
resp, err := httpmock.NewJsonResponse(200, "200 OK")
if err != nil {
return httpmock.NewStringResponse(500, "Error Generating Response"), nil
}
return resp, nil
})
req, err := http.NewRequest("GET", "/transformer/transform", nil)
if err != nil {
t.Fatalf("Got error in creating Http Test Request, %v", err)
}
outputCatcher := httptest.NewRecorder()
h := http.HandlerFunc(transc.TransformerHandler)
h.ServeHTTP(outputCatcher, req)
outputBody := outputCatcher.Body
want := "Files Parsed and Validated"
if !strings.Contains(outputBody.String(), want) {
t.Errorf("Got Unexpected Output in the HTTP Response. want: %v, got: %v\n", outputBody.String(), want)
}
}
Stay tuned: In the next installment in this series, we’ll examine how to set up a Jenkins CI/CD pipeline for your Golang app!
This blog post was created as part of the Mattermost Community Writing Program and is published under the CC BY-NC-SA 4.0 license. To learn more about the Mattermost Community Writing Program, check this out.