Unit testing
Go is a "batteries included" type of language, just like Python, so naturally it comes with its own testing package, which provides support for automated execution of unit tests. Here's an excerpt from its documentation:
Package testing provides support for automated testing of Go packages. It is intended to be used in concert with the “go test” command, which automates execution of any function of the form
func TestXxx(*testing.T)
where Xxx can be any alphanumeric string (but the first letter must not be in [a-z]) and serves to identify the test routine.
The functionality offered by the testing package is fairly bare-bones though, so I've actually been using another package called testify which provides test suites and more friendly assertions.Whether you're using testing or a 3rd party package such as testify, the Go way of writing unit tests is to include them in a file ending with _test.go in the same directory as your code under test. For example, if you have a file called customers.go which deals with customer management business logic, you would write unit tests for that code and put them in file called customers_test.go in the same directory as customers.go. Then, when you run the "go test" command in that same directory, your unit tests will be automatically run. In fact, "go test" will discover all tests in files named *_test.go and run them. You can find more details on Go unit testing in the Testing section of the "How to Write Go Code" article.
Integration testing
I'll give some examples of how I organize my integration tests. Let's take again the example of testing an API what deals with the management of customers. An integration test, by definition, will hit the API endpoint from the outside, via HTTP. This is in contrast with a unit test which will test the business logic of the API handler internally, and will live as I said above in the same package as the API code.
For my integration tests, I usually create a directory per set of endpoints that I want to test, something like core-api for example. In there I drop a file called main.go where I set some constants used throughout my tests:
package main
import (
"fmt"
)
const API_VERSION = "v2"
const API_HOST = "myapi.example.com"
const API_PORT = 8000
const API_PROTO = "http"
const API_INIT_KEY = "some_init_key"
const API_SECRET_KEY = "some_secret_key"
const TEST_PHONE_NUMBER = "+15555550000"
const DEBUG = true
func init() {
fmt.Printf("API_PROTO:%s; API_HOST:%s; API_PORT:%d\n", API_PROTO, API_HOST, API_PORT)
}
func main() {
}
For integration tests related to the customer API, I create a file called customer_test.go with the following boilerplate:
package main
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
// Define the suite, and absorb the built-in basic suite
// functionality from testify - including a T() method which
// returns the current testing context
type CustomerTestSuite struct {
suite.Suite
apiURL string
testPhoneNumber string
}
// Set up variables used in all tests
// this method is called before each test
func (suite *CustomerTestSuite) SetupTest() {
suite.apiURL = fmt.Sprintf("%s://%s:%d/%s/customers", API_PROTO, API_HOST, API_PORT, API_VERSION)
suite.testPhoneNumber = TEST_PHONE_NUMBER
}
// Tear down variables used in all tests
// this method is called after each test
func (suite *CustomerTestSuite) TearDownTest() {
}
// In order for 'go test' to run this suite, we need to create
// a normal test function and pass our suite to suite.Run
func TestCustomerTestSuite(t *testing.T) {
suite.Run(t, new(CustomerTestSuite))
}
By using the testify package, I am able to define a test suite, a struct I call CustomerTestSuite which contains a testify suite.Suite as an anonymous field. Golang uses composition over inheritance, and the effect of embedding a suite.Suite in my test suite is that I can define methods such as SetupTest and TearDownTest on my CustomerTestSuite. I do the common set up for all test functions in SetupTest (which is called before each test function is executed), and the common tear down for all test functions in TearDownTest (which is called after each test function is executed).
In the example above, I set some variables in SetupTest which I will use in every test function I'll define. Here is an example of a test function:
func (suite *CustomerTestSuite) TestCreateCustomerNewEmailNewPhone() {
url := suite.apiURL
random_email_addr := fmt.Sprintf("test-user%d@example.com", common.RandomInt(1, 1000000))
phone_num := suite.testPhoneNumber
status_code, json_data := create_customer(url, phone_num, random_email_addr)
customer_id := get_nested_item_property(json_data, "customer", "id")
assert_success_response(suite.T(), status_code, json_data)
assert.NotEmpty(suite.T(), customer_id, "customer id should not be empty")
}
The actual HTTP call to the backend API that I want to test happens inside the create_customer function, which I defined in a separate utils.go file:
func create_customer(url, phone_num, email_addr string) (int, map[string]interface{}) {
fmt.Printf("Sending request to %s\n", url)
payload := map[string]string{
"phone_num": phone_num,
"email_addr": email_addr,
}
ro := &grequests.RequestOptions{}
ro.JSON = payload
var resp *grequests.Response
resp, _ = grequests.Post(url, ro)
var json_data map[string]interface{}
status_code := resp.StatusCode
err := resp.JSON(&json_data)
if err != nil {
fmt.Println("Unable to coerce to JSON", err)
return 0, nil
}
return status_code, json_data
}
Notice that I use the grequests package, which is a Golang port of the Python Requests package. Using grequests allows me to encapsulate the HTTP request and response in a sane way, and to easily deal with JSON.
To go back to the TestCreateCustomerNewEmailNewPhone test function, once I get back the response from the API call to create a customer, I call another helper function called assert_success_response, which uses the assert package from testify in order to verify that the HTTP response code was 200 and that certain JSON parameters that we send back with every response (such as error_msg, error_code, req_id) are what we expect them to be:
func assert_success_response(testobj *testing.T, status_code int, json_data map[string]interface{}) {
assert.Equal(testobj, 200, status_code, "HTTP status code should be 200")
assert.Equal(testobj, 0.0, json_data["error_code"], "error_code should be 0")
assert.Empty(testobj, json_data["error_msg"], "error_msg should be empty")
assert.NotEmpty(testobj, json_data["req_id"], "req_id should not be empty")
assert.Equal(testobj, true, json_data["success"], "success should be true")
}
To actually run the integration test, I run the usual 'go test' command inside the directory containing my test files.
This pattern has served me well in creating an ever-growing collection of integration tests against our API endpoints.
Test coverage
Part of Golang's "batteries included" series of tools is a test coverage tool. To use it, you first need to run 'go test' with various coverage options. Here is a shell script we use to produce our test coverage numbers:
#!/bin/bash
#
# Run all of our go unit-like tests from each package
#
CTMP=$GOPATH/src/core_api/coveragetmp.out
CREAL=$GOPATH/src/core_api/coverage.out
CMERGE=$GOPATH/src/core_api/merged_coverage.out
set -e
set -x
cp /dev/null $CTMP
cp /dev/null $CREAL
cp /dev/null $CMERGE
go test -v -coverprofile=$CTMP -covermode=count -parallel=9 ./auth
cat $CTMP > $CREAL
go test -v -coverprofile=$CTMP -covermode=count -parallel=9 ./customers
cat $CTMP |tail -n+2 >> $CREAL
#
# Finally run all the go integration tests
#
go test -v -coverprofile=$C -covermode=count -coverpkg=./auth,./customers ./all_test.go
cat $CTMP |tail -n+2 >> $CREAL
rm $CTMP
#
# Merge the coverage report from unit tests and integration tests
#
cd $GOPATH/src/core_api/
cat $CREAL | go run ../samples/mergecover/main.go >> $CMERGE
#
set +x
echo "You can run the following to view the full coverage report!::::"
echo "go tool cover -func=$CMERGE"
echo "You can run the following to generate the html coverage report!::::"
echo "go tool cover -html=$CMERGE -o coverage.html"
The first section of the bash script above runs 'go test' in covermode=count against every sub-package we have (auth, customers etc). It combines the coverprofile output files (CTMP) into a single file (CREAL).
The second section runs the integration tests by calling 'go test' in covermode=count, with coverpkg=[comma-separated list of our packages], against a file called all_test.go. This file starts an HTTP server exposing our APIs, then hits our APIs by calling 'go test' from within the integration test directory.
The coverage numbers from the unit tests and integration tests are then merged into the CMERGE file by running the mergecover tool.
At this point, you can generate an html file via go tool cover -html=$CMERGE -o coverage.html, then inspect coverage.html in a browser. Aim for more than 80% coverage for each package under test.
No comments:
Post a Comment