Get Started Testing an API Built With Golang

November 30, 2022
Written by
Oluyemi Olususi
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

This post was written by a third-party contributor as a part of our Twilio Voices program. It is no longer actively maintained.

Writing code that "works" is the easy part. The real issue is writing code that "lasts". Whether it’s a change in team membership or a change in requirements, your code should be able to grow with the changes, by virtue of a well-defined architecture, and also identify breaking changes/unhandled edge cases that may arise due to said changes.

This article - the second in a series; aims to show you how testing will help you with the latter requirement. It will introduce testing in Golang and focus on unit testing a REST API. You will build on the first article to write tests for existing features, as well as employ test-driven development in the implementation of a new feature.

Prerequisites

To follow this tutorial, you will need the following:

Get started

If you already have the code from the first part of this series, you can skip this section. However, if you’re just joining in, get started by cloning the repository and change into the cloned directory by running the commands below:

git clone https://github.com/yemiwebby/gin-gorm-restful-api
cd gin-gorm-restful-api

Next, install the project dependencies using the following command.

go mod tidy

Next, create a new PostgreSQL database named diary_app. You can do so by running the following command

createdb -h localhost -p <DB_PORT> -U <DB_USER> diary_app

Where prompted, provide the password associated with DB_USER.

Alternatively, create the database using your preferred tool of choice.

Next, make a copy of .env.example named .env.local using the following command.

cp .env.example .env.local

.env.**.local files are ignored by Git as an accepted best practice for storing credentials outside of code to keep them safe.

Replace the placeholder values in .env.local with the respective details for your PostgreSQL database.

Set up the test environment

Testify is one of the most popular testing packages for Golang and will be used in this article. Install it using the following command.

go get -u github.com/stretchr/testify

Next, create a new database named diary_app_test.

createdb -h localhost -p <DB_PORT> -U <DB_USER> diary_app_test --password

With that done, create a .env.test.local file from the .env.local file using the command below.

cp .env.local .env.test.local

Doing this will provide a separate, local test environment configuration, preventing unexpected behavior.

As with .env.local, .env.test.local is also not tracked by Git.

Update the DB_NAME parameter in .env.test.local to match the name of the newly created database. This change avoids using the same database in test and development environments.

Next, create a new file named main_test.go within the controller folder. This file will hold the global functions required to test all the endpoints for the application. Now, add the following code to the newly created file.

package controller

import (
        "bytes"
        "diary_api/database"
        "diary_api/middleware"
        "diary_api/model"
        "encoding/json"
        "log"
        "net/http"
        "net/http/httptest"
        "os"
        "testing"

        "github.com/gin-gonic/gin"
        "github.com/joho/godotenv"
)

func TestMain(m *testing.M) {
        gin.SetMode(gin.TestMode)
        setup()
        exitCode := m.Run()
        teardown()

        os.Exit(exitCode)
}

func router() *gin.Engine {
        router := gin.Default()

        publicRoutes := router.Group("/auth")
        publicRoutes.POST("/register", Register)
        publicRoutes.POST("/login", Login)

        protectedRoutes := router.Group("/api")
        protectedRoutes.Use(middleware.JWTAuthMiddleware())
        protectedRoutes.POST("/entry", AddEntry)
        protectedRoutes.GET("/entry", GetAllEntries)

        return router
}

func setup() {
        err := godotenv.Load("../.env.test.local")
        if err != nil {
                log.Fatal("Error loading .env file")
        }

        database.Connect()
        database.Database.AutoMigrate(&model.User{})
        database.Database.AutoMigrate(&model.Entry{})
}

func teardown() {
        migrator := database.Database.Migrator()
        migrator.DropTable(&model.User{})
        migrator.DropTable(&model.Entry{})
}

func makeRequest(method, url string, body interface{}, isAuthenticatedRequest bool) *httptest.ResponseRecorder {
        requestBody, _ := json.Marshal(body)
        request, _ := http.NewRequest(method, url, bytes.NewBuffer(requestBody))
        if isAuthenticatedRequest {
                request.Header.Add("Authorization", "Bearer "+bearerToken())
        }
        writer := httptest.NewRecorder()
        router().ServeHTTP(writer, request)
        return writer
}

func bearerToken() string {
        user := model.AuthenticationInput{
                Username: "yemiwebby",
                Password: "test",
        }

        writer := makeRequest("POST", "/auth/login", user, false)
        var response map[string]string
        json.Unmarshal(writer.Body.Bytes(), &response)
        return response["jwt"]
}

TestMain() is an inbuilt function in Golang that allows more control over running tests. There are methods defined within the testing.M struct to access and run tests. It is a perfect place to run specific code before and after tests. Here, it is used to run migrations for the database and delete all tables after the test.

The router() is a method to return an instance of the Gin router. It will come in handy when testing other functions for each endpoint.

In the setup() function, the test environment variables are loaded and the database connection is established. The migrations are also run to create the appropriate database tables.

To ensure that every test starts with a clean slate, the teardown() function is used to delete all the tables in the database. This function will be called when all the tests have been run.

The makeRequest() function will be used to make requests to the various endpoints. The returned ResponseRecorder can then be used for further assertions. In the event that the endpoint to be called requires authentication, the bearerToken() function is used to get a token for the test user.

Each test file within your project must end with _test.go and each test method must start with a Test prefix. This is a standard naming convention for a valid test.

Write tests for a happy path

The first set of tests will ensure that, provided the right data, the API will return the correct response. To begin, create a new file in the controller directory named authentication_test.go, and add the following code.

package controller

import (
        "diary_api/model"
        "encoding/json"
        "net/http"
        "testing"

        "github.com/stretchr/testify/assert"
)

func TestRegister(t *testing.T) {
        newUser := model.AuthenticationInput{
                Username: "yemiwebby",
                Password: "test",
        }
        writer := makeRequest("POST", "/auth/register", newUser, false)
        assert.Equal(t, http.StatusCreated, writer.Code)
}

func TestLogin(t *testing.T) {
        user := model.AuthenticationInput{
                Username: "yemiwebby",
                Password: "test",
        }

        writer := makeRequest("POST", "/auth/login", user, false)

        assert.Equal(t, http.StatusOK, writer.Code)

        var response map[string]string
        json.Unmarshal(writer.Body.Bytes(), &response)
        _, exists := response["jwt"]

        assert.Equal(t, true, exists)
}

The TestRegister() sends a POST request with a mock user detail to the /auth/register endpoint and asserts that the appropriate status code was returned.

In addition to ensuring that the expected status was returned after logging in as a user, the TestLogin() function also asserts that the response contains the jwt.

Still, within the controller folder, create another file to test the endpoints for entries named entry_test.go. Add the following code to it

package controller

import (
        "diary_api/model"
        "net/http"
        "testing"

        "github.com/stretchr/testify/assert"
)

func TestAddEntry(t *testing.T) {
        newEntry := model.Entry{
                Content: "This is a test entry :)",
        }
        writer := makeRequest("POST", "/api/entry", newEntry, true)
        assert.Equal(t, http.StatusCreated, writer.Code)
}

func TestGetAllEntries(t *testing.T) {
        writer := makeRequest("GET", "/api/entry", nil, true)
        assert.Equal(t, http.StatusOK, writer.Code)
}

Both TestAddEntry() and TestGetAllEntries() ensure that a new entry can be created and a list of entries retrieved with the appropriate status codes respectively.

Since the tests files are housed within the controller folder, use the following command to run all tests for the controller package specifically

go test -v -cover ./controller

You will see output similar to this:

Successfully connected to the database
=== RUN   TestRegister
[GIN] 2022/10/21 - 07:53:08 | 201 |   87.083917ms |                 | POST     "/auth/register"
--- PASS: TestRegister (0.09s)
=== RUN   TestLogin
[GIN] 2022/10/21 - 07:53:08 | 200 |   65.553167ms |                 | POST     "/auth/login"
--- PASS: TestLogin (0.07s)
=== RUN   TestAddEntry
[GIN] 2022/10/21 - 07:53:08 | 200 |   65.558959ms |                 | POST     "/auth/login"
[GIN] 2022/10/21 - 07:53:08 | 201 |    2.787375ms |                 | POST     "/api/entry"
--- PASS: TestAddEntry (0.07s)
=== RUN   TestGetAllEntries
[GIN] 2022/10/21 - 07:53:08 | 200 |   65.531333ms |                 | POST     "/auth/login"
[GIN] 2022/10/21 - 07:53:08 | 200 |     802.125µs |                 | GET      "/api/entry"
--- PASS: TestGetAllEntries (0.07s)
PASS
coverage: 56.5% of statements
ok          diary_api/controller        0.611s        coverage: 56.5% of statements

Testing for edge cases

So far, you’ve written tests to verify that, given the correct parameters, the application will provide the expected results. However, testing goes beyond that, it also involves making sure that your application is able to handle edge cases or likely cases of misuse. In such cases, you want to be sure that the integrity of your application is not compromised.

To demonstrate this, consider the event that an authentication request without a password field is made to your API. Your code already specifies a required binding which ensures that a password must be provided. However, you would like to ensure that if absent, an appropriate error response code and message should be returned.

Instead of diving straight into the code, this is a perfect opportunity to try your hands at a testing paradigm known as TDD (Test-driven Development) where you write the tests before writing the code that will make the tests pass.

Add the following function to authentication_test.go

func TestIncompleteLoginRequest(t *testing.T) {
        request := map[string]string{"username": "demo"}
        writer := makeRequest("POST", "/auth/login", request, true)
        assert.Equal(t, http.StatusBadRequest, writer.Code)

        var response map[string]string
        json.Unmarshal(writer.Body.Bytes(), &response)
        message, exists := response["error"]

        assert.Equal(t, true, exists)
        assert.Equal(t, message, "Password not provided")
}

Next, run your tests. This time you get a failure as shown below.

Successfully connected to the database
=== RUN   TestRegister
[GIN] 2022/10/21 - 06:37:29 | 201 |   87.869792ms |                 | POST     "/auth/register"
--- PASS: TestRegister (0.09s)
=== RUN   TestLogin
[GIN] 2022/10/21 - 06:37:29 | 200 |   65.604166ms |                 | POST     "/auth/login"
--- PASS: TestLogin (0.07s)
=== RUN   TestIncompleteLoginRequest
[GIN] 2022/10/21 - 06:37:29 | 200 |      65.852ms |                 | POST     "/auth/login"
[GIN] 2022/10/21 - 06:37:29 | 400 |        9.75µs |                 | POST     "/auth/login"
    authentication_test.go:49: 
                Error Trace:        /Users/yemiwebby/tutorial/twilio/gin-gorm-restful-api/controller/authentication_test.go:49
                Error:              Not equal: 
                                    expected: "Key: 'AuthenticationInput.Password' Error:Field validation for 'Password' failed on the 'required' tag"
                                    actual  : "Password not provided"
                                    
                                    Diff:
                                    --- Expected
                                    +++ Actual
                                    @@ -1 +1 @@
                                    -Key: 'AuthenticationInput.Password' Error:Field validation for 'Password' failed on the 'required' tag
                                    +Password not provided
                Test:               TestIncompleteLoginRequest
--- FAIL: TestIncompleteLoginRequest (0.07s)
=== RUN   TestAddEntry
[GIN] 2022/10/21 - 06:37:29 | 200 |   65.848292ms |                 | POST     "/auth/login"
[GIN] 2022/10/21 - 06:37:29 | 201 |    5.387958ms |                 | POST     "/api/entry"
--- PASS: TestAddEntry (0.07s)
=== RUN   TestGetAllEntries
[GIN] 2022/10/21 - 06:37:29 | 200 |     65.3855ms |                 | POST     "/auth/login"
[GIN] 2022/10/21 - 06:37:29 | 200 |     716.875µs |                 | GET      "/api/entry"
--- PASS: TestGetAllEntries (0.07s)
FAIL
coverage: 60.9% of statements
FAIL        diary_api/controller        0.685s
FAIL

Because the returned error message did not match what you specified in the test, your code fails the test. This is the red phase of TDD where you write a test that fails. The next step is to write just enough code for the test to pass.

To do this, open the controller/authentication.go and update the Login() function to match the following.

func Login(context *gin.Context) {
        var input model.AuthenticationInput

        if err := context.ShouldBindJSON(&input); err != nil {
                var errorMessage string
                var validationErrors validator.ValidationErrors
                if errors.As(err, &validationErrors) {
                        validationError := validationErrors[0]
                        if validationError.Tag() == "required" {
                                errorMessage = fmt.Sprintf("%s not provided", validationError.Field())
                        }
                }
                context.JSON(http.StatusBadRequest, gin.H{"error": errorMessage})
                return
        }

        user, err := model.FindUserByUsername(input.Username)

        if err != nil {
                context.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
                return
        }

        err = user.ValidatePassword(input.Password)

        if err != nil {
                context.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
                return
        }

        jwt, err := helper.GenerateJWT(user)
        if err != nil {
                context.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
                return
        }

        context.JSON(http.StatusOK, gin.H{"jwt": jwt})
}

Remember to update your import statements.

import (
        "diary_api/helper"
        "diary_api/model"
        "errors"
        "fmt"
        "github.com/gin-gonic/gin"
        "github.com/go-playground/validator/v10"
        "net/http"
)

After calling the ShouldBindJson() function, if any errors are present, the tag and field of the first error are retrieved and used to generate a more friendly error message.

Run your tests again. This time everything passes.

Successfully connected to the database
=== RUN   TestRegister
[GIN] 2022/10/21 - 06:46:05 | 201 |   95.576917ms |                 | POST     "/auth/register"
--- PASS: TestRegister (0.10s)
=== RUN   TestLogin
[GIN] 2022/10/21 - 06:46:05 | 200 |   67.002917ms |                 | POST     "/auth/login"
--- PASS: TestLogin (0.07s)
=== RUN   TestIncompleteLoginRequest
[GIN] 2022/10/21 - 06:46:05 | 200 |      66.507ms |                 | POST     "/auth/login"
[GIN] 2022/10/21 - 06:46:05 | 400 |        15.5µs |                 | POST     "/auth/login"
--- PASS: TestIncompleteLoginRequest (0.07s)
=== RUN   TestAddEntry
[GIN] 2022/10/21 - 06:46:05 | 200 |   65.863084ms |                 | POST     "/auth/login"
[GIN] 2022/10/21 - 06:46:05 | 201 |    3.062917ms |                 | POST     "/api/entry"
--- PASS: TestAddEntry (0.07s)
=== RUN   TestGetAllEntries
[GIN] 2022/10/21 - 06:46:05 | 200 |   65.420917ms |                 | POST     "/auth/login"
[GIN] 2022/10/21 - 06:46:05 | 200 |     667.333µs |                 | GET      "/api/entry"
--- PASS: TestGetAllEntries (0.07s)
PASS
coverage: 65.4% of statements
ok          diary_api/controller        0.712s        coverage: 65.4% of statements

Conclusion

In this article, you’ve looked at how to write tests for an existing codebase. It’s key to remember that testing is about making sure that your application is also prepared for the unexpected and as such your tests should cover as many “unpleasant” scenarios as you can think of.

You also briefly looked at TDD which, essentially, involves writing your tests before writing code. While it is still a heavily debated subject, one thing that cannot be overemphasized is that your code without tests is a disaster in the making. Your code should be backed by a suite of test cases regardless of whether or not they were written before the actual application code.

The entire codebase for this tutorial is available on GitHub. Feel free to explore further. Happy coding!

Oluyemi is a tech enthusiast with a background in Telecommunication Engineering. With a keen interest to solve day-to-day problems encountered by users, he ventured into programming and has since directed his problem-solving skills at building software for both web and mobile.

A full-stack software engineer with a passion for sharing knowledge, Oluyemi has published a good number of technical articles and content on several blogs on the internet. Being tech-savvy, his hobbies include trying out new programming languages and frameworks.