DEV Community

Cover image for Testing REST APIs in Go: A Guide to Unit and Integration Testing with Go's Standard Testing Library
sean
sean

Posted on

Testing REST APIs in Go: A Guide to Unit and Integration Testing with Go's Standard Testing Library

Introduction

This article is going to take you through how to use unit test and integration test to improve you development experience as you create rest apis in golang.

  • Unit tests are designed to verify the functionality of the smallest, individual parts of an application, often focusing on a single function or method. These tests are conducted in isolation from other parts of the code to ensure each component works as expected on its own.

  • Integration tests, on the other hand, assess how different modules or components of the application work together. In this article, we’ll focus on integration testing for our Go application, specifically checking that it interacts correctly with a PostgreSQL database by successfully making and executing SQL queries.

This article assumes that you are familiar with golang and how to create rest api in golang the main focus will be on creating test for your routes (unit tests) and testing your sql query functions (integration tests) for reference visit the github to have a look at the project.

Setting Up

Assuming you have setup your project similar to the one linked above you will have a folder structure similar to this

test_project
|__cmd
   |__api
      |__api.go
   |__main.go
|__db
   |___seed.go
|__internal
   |___db
       |___db.go
   |___services
       |___records
           |___routes_test.go
           |___routes.go
           |___store_test.go
           |___store.go
       |___user
           |___routes_test.go
           |___routes.go
           |___store_test.go
           |___store.go
|__test_data
|__docker-compose.yml
|__Dockerfile
|__Makefile

Enter fullscreen mode Exit fullscreen mode

Testing in golang is easy compored to other language you may have encountered becase of the inbuilt testing package that provides tools needed to write tests.
Test files are named with _test.go this suffix allows for go to target this files for execution when running the command go test.

The entrypoint for our project is the main.go file located in the cmd folder

// main.go

package main

import (
    "log"

    "finance-crud-app/cmd/api"
    "finance-crud-app/internal/db"

    "github.com/gorilla/mux"
    "github.com/jmoiron/sqlx"
    _ "github.com/lib/pq"
)

type Server struct {
    db  *sqlx.DB
    mux *mux.Router
}

func NewServer(db *sqlx.DB, mux *mux.Router) *Server {
    return &Server{
        db:  db,
        mux: mux,
    }
}

func main() {

    connStr := "postgres://postgres:Password123@localhost:5432/crud_db?sslmode=disable"

    dbconn, err := db.NewPGStorage(connStr)
    if err != nil {
        log.Fatal(err)
    }
    defer dbconn.Close()

    server := api.NewAPIServer(":8085", dbconn)
    if err := server.Run(); err != nil {
        log.Fatal(err)
    }
}

Enter fullscreen mode Exit fullscreen mode

From the code you can see we are creating a new api server by passing a database connection and port number. After creating the server we run it on the stated port.

The NewAPIServer command comes from the api.go file which

// api.go
package api

import (
    "finance-crud-app/internal/services/records"
    "finance-crud-app/internal/services/user"
    "log"
    "net/http"

    "github.com/gorilla/mux"
    "github.com/jmoiron/sqlx"
)

type APIServer struct {
    addr string
    db   *sqlx.DB
}

func NewAPIServer(addr string, db *sqlx.DB) *APIServer {
    return &APIServer{
        addr: addr,
        db:   db,
    }
}

func (s *APIServer) Run() error {
    router := mux.NewRouter()
    subrouter := router.PathPrefix("/api/v1").Subrouter()

    userStore := user.NewStore(s.db)
    userHandler := user.NewHandler(userStore)
    userHandler.RegisterRoutes(subrouter)

    recordsStore := records.NewStore(s.db)
    recordsHandler := records.NewHandler(recordsStore, userStore)
    recordsHandler.RegisterRoutes(subrouter)

    log.Println("Listening on", s.addr)

    return http.ListenAndServe(s.addr, router)
}

Enter fullscreen mode Exit fullscreen mode

For this api we are using mux as our http router.

Integration Test

We have a user Store struct that handles sql queries related to the user entity.

// store.go
package user

import (
    "errors"
    "finance-crud-app/internal/types"
    "fmt"
    "log"

    "github.com/jmoiron/sqlx"
)

var (
    CreateUserError   = errors.New("cannot create user")
    RetrieveUserError = errors.New("cannot retrieve user")
    DeleteUserError   = errors.New("cannot delete user")
)

type Store struct {
    db *sqlx.DB
}

func NewStore(db *sqlx.DB) *Store {
    return &Store{db: db}
}

func (s *Store) CreateUser(user types.User) (user_id int, err error) {
    query := `
    INSERT INTO users
    (firstName, lastName, email, password)
    VALUES ($1, $2, $3, $4)
    RETURNING id`

    var userId int
    err = s.db.QueryRow(query, user.FirstName, user.LastName, user.Email, user.Password).Scan(&userId)
    if err != nil {
        return -1, CreateUserError
    }

    return userId, nil
}

func (s *Store) GetUserByEmail(email string) (types.User, error) {
    var user types.User

    err := s.db.Get(&user, "SELECT * FROM users WHERE email = $1", email)
    if err != nil {
        return types.User{}, RetrieveUserError
    }

    if user.ID == 0 {
        log.Fatalf("user not found")
        return types.User{}, RetrieveUserError
    }

    return user, nil
}

func (s *Store) GetUserByID(id int) (*types.User, error) {
    var user types.User
    err := s.db.Get(&user, "SELECT * FROM users WHERE id = $1", id)
    if err != nil {
        return nil, RetrieveUserError
    }

    if user.ID == 0 {
        return nil, fmt.Errorf("user not found")
    }

    return &user, nil
}

func (s *Store) DeleteUser(email string) error {

    user, err := s.GetUserByEmail(email)
    if err != nil {
        return DeleteUserError
    }
    // delete user records first
    _, err = s.db.Exec("DELETE FROM records WHERE userid = $1", user.ID)
    if err != nil {
        return DeleteUserError
    }

    _, err = s.db.Exec("DELETE FROM users WHERE email = $1", email)
    if err != nil {
        return DeleteUserError
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

In the file above we have 3 pointer receiver methods:

  • CreateUser
  • GetUserByEmail
  • GetUserById

For these methods to perform their function they have to interact with an external systems which ,in this case that is Postgres DB .

To test this methods we will first create a store_test.go file. In go we usually name our test files after the file we are targetting to test and add the suffix _test.go .

// store_test.go

package user_test

import (
    "finance-crud-app/internal/db"
    "finance-crud-app/internal/services/user"
    "finance-crud-app/internal/types"
    "log"
    "os"
    "testing"

    "github.com/jmoiron/sqlx"
    _ "github.com/lib/pq"
)

var (
    userTestStore *user.Store
    testDB        *sqlx.DB
)

func TestMain(m *testing.M) {
    // database
    ConnStr := "postgres://postgres:Password123@localhost:5432/crud_db?sslmode=disable"
    testDB, err := db.NewPGStorage(ConnStr)
    if err != nil {
        log.Fatalf("could not connect %v", err)
    }
    defer testDB.Close()
    userTestStore = user.NewStore(testDB)

    code := m.Run()
    os.Exit(code)
}

func TestCreateUser(t *testing.T) {
    test_data := map[string]struct {
        user   types.User
        result any
    }{
        "should PASS valid user email used": {
            user: types.User{
                FirstName: "testfirsjjlkjt-1",
                LastName:  "testlastkjh-1",
                Email:     "validuser@email.com",
                Password:  "00000000",
            },
            result: nil,
        },
        "should FAIL invalid user email used": {
            user: types.User{
                FirstName: "testFirstName1",
                LastName:  "testLastName1",
                Email:     "test1@email.com",
                Password:  "800890",
            },
            result: user.CreateUserError,
        },
    }

    for name, tc := range test_data {
        t.Run(name, func(t *testing.T) {
            value, got := userTestStore.CreateUser(tc.user)
            if got != tc.result {
                t.Errorf("test fail expected %v got %v instead and value %v", tc.result, got, value)
            }
        })
    }

    t.Cleanup(func() {
        err := userTestStore.DeleteUser("validuser@email.com")
        if err != nil {
            t.Errorf("could not delete user %v got error %v", "validuser@email.com", err)
        }
    })
}

func TestGetUserByEmail(t *testing.T) {
    test_data := map[string]struct {
        email  string
        result any
    }{
        "should pass valid user email address used": {
            email:  "test1@email.com",
            result: nil,
        },
        "should fail invalid user email address used": {
            email:  "validuser@email.com",
            result: user.RetrieveUserError,
        },
    }

    for name, tc := range test_data {
        got, err := userTestStore.GetUserByEmail(tc.email)
        if err != tc.result {
            t.Errorf("test fail expected %v instead got %v", name, got)
        }
    }
}

func TestGetUserById(t *testing.T) {
    testUserId, err := userTestStore.CreateUser(types.User{
        FirstName: "userbyid",
        LastName:  "userbylast",
        Email:     "unique_email",
        Password:  "unique_password",
    })
    if err != nil {
        log.Panicf("got %v when creating testuser", testUserId)
    }

    test_data := map[string]struct {
        user_id int
        result  any
    }{
        "should pass valid user id used": {
            user_id: testUserId,
            result:  nil,
        },
        "should fail invalid user id used": {
            user_id: 0,
            result:  user.RetrieveUserError,
        },
    }

    for name, tc := range test_data {
        t.Run(name, func(t *testing.T) {
            _, got := userTestStore.GetUserByID(tc.user_id)
            if got != tc.result {
                t.Errorf("error retrieving user by id got %v want %v", got, tc.result)
            }
        })
    }

    t.Cleanup(func() {
        err := userTestStore.DeleteUser("unique_email")
        if err != nil {
            t.Errorf("could not delete user %v got error %v", "unique_email", err)
        }
    })
}

func TestDeleteUser(t *testing.T) {
    testUserId, err := userTestStore.CreateUser(types.User{
        FirstName: "userbyid",
        LastName:  "userbylast",
        Email:     "delete_user@email.com",
        Password:  "unique_password",
    })
    if err != nil {
        log.Panicf("got %v when creating testuser", testUserId)
    }

    test_data := map[string]struct {
        user_email string
        result     error
    }{
        "should pass user email address used": {
            user_email: "delete_user@email.com",
            result:     nil,
        },
    }

    for name, tc := range test_data {
        t.Run(name, func(t *testing.T) {
            err = userTestStore.DeleteUser(tc.user_email)
            if err != tc.result {
                t.Errorf("error deletig user got %v instead of %v", err, tc.result)
            }
        })
    }

    t.Cleanup(func() {
        err := userTestStore.DeleteUser("delete_user@email.com")
        if err != nil {
            log.Printf("could not delete user %v got error %v", "delete_user@email.com", err)
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

Lets go through the file looking at what each section does.

The first action is to declare the variables userTestStore and testDB. These variables will be used to store pointers to the user store and db respectively. The reason we have declared them in the global file scope is because we want all functions in the test file to have access to the pointers.

The TestMain function allows us to do some setting up actions before the main test are run. We are initially connecting to the postgres store and saving the pointer into our global variable.
We have used that pointer to create a userTestStore that we will use to execute the sql queries we are trying to connect.

defer testDB.Close() closes the database connection after the test has completed

code := m.Run() runs the rest of the test function before returning and exiting.

TestCreateUser function will handle the testing of the create_user function. Our goal is to test if the function will create the user if a unique email is passed and the function should not be able to create a user if a non-unique email has already been used to create another user.

First we create the test data that we will use to test both case scenarios.

test_data := map[string]struct {
        user   types.User
        result any
    }{
        "should PASS valid user email used": {
            user: types.User{
                FirstName: "testfirsjjlkjt-1",
                LastName:  "testlastkjh-1",
                Email:     "validuser@email.com",
                Password:  "00000000",
            },
            result: nil,
        },
        "should FAIL invalid user email used": {
            user: types.User{
                FirstName: "testFirstName1",
                LastName:  "testLastName1",
                Email:     "test1@email.com",
                Password:  "800890",
            },
            result: user.CreateUserError,
        },
    }
Enter fullscreen mode Exit fullscreen mode

I will loop through the map executing create_user function with the test date as parameters and compare if the returned value is the same the result we expect

for name, tc := range test_data {
        t.Run(name, func(t *testing.T) {
            value, got := userTestStore.CreateUser(tc.user)
            if got != tc.result {
                t.Errorf("test fail expected %v got %v instead and value %v", tc.result, got, value)
            }
        })
    }
Enter fullscreen mode Exit fullscreen mode

For cases where the returned result is not the same as the expected result then our test will fail

The last part of this function is using the inbuilt testing package function Cleanup. This function registered a function that will be called when all the function in the test have already been executed. In our example case here we are using the function to clear up user data that was used during this test function execution.

Unit Tests

For our unit tests we are going to test the route handlers for our api. In this case the routes related to the user entity. Observe below.

package user

import (
    "finance-crud-app/internal/services/auth"
    "finance-crud-app/internal/types"
    "finance-crud-app/internal/utils"
    "fmt"
    "net/http"
    "strconv"

    "github.com/go-playground/validator/v10"
    "github.com/gorilla/mux"
)

type Handler struct {
    store types.UserStore
}

func NewHandler(store types.UserStore) *Handler {
    return &Handler{store: store}
}

func (h *Handler) RegisterRoutes(router *mux.Router) {
    router.HandleFunc("/login", h.handleLogin).Methods("POST")
    router.HandleFunc("/register", h.handleRegister).Methods("POST")

    // secured routes
    router.HandleFunc("/users/{userID}", auth.JWTAuthMiddleWare(h.handleGetUser, h.store)).Methods(http.MethodGet)
}

func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
    var user types.LoginUserPayload
    if err := utils.ParseJSON(r, &user); err != nil {
        utils.WriteError(w, http.StatusBadRequest, fmt.Errorf("invalid payload: %v", err))
        return
    }

    if err := utils.Validate.Struct(user); err != nil {
        errors := err.(validator.ValidationErrors)
        utils.WriteError(w, http.StatusBadRequest, fmt.Errorf("invalid payload: %v", errors))
        return
    }

    u, err := h.store.GetUserByEmail(user.Email)
    if err != nil {
        utils.WriteError(w, http.StatusBadRequest, fmt.Errorf("invalid credentials"))
        return
    }

    if !auth.ComparePasswords(u.Password, []byte(user.Password)) {
        utils.WriteError(w, http.StatusBadRequest, fmt.Errorf("invalid credentials"))
        return
    }

    token, err := auth.CreateJWT(u.ID)
    if err != nil {
        utils.WriteError(w, http.StatusBadRequest, fmt.Errorf("error %v", err))
        return
    }

    utils.WriteJSON(w, http.StatusAccepted, map[string]string{"token": token})
}

func (h *Handler) handleRegister(w http.ResponseWriter, r *http.Request) {
    var user types.RegisterUserPayload
    if err := utils.ParseJSON(r, &user); err != nil {
        utils.WriteError(w, http.StatusBadRequest, err)
        return
    }

    if err := utils.Validate.Struct(user); err != nil {
        errors := err.(validator.ValidationErrors)
        utils.WriteError(w, http.StatusBadRequest, fmt.Errorf("invalid payload: %v", errors))
        return
    }

    hashedPassword, err := auth.HashPassword(user.Password)
    if err != nil {
        utils.WriteError(w, http.StatusInternalServerError, err)
        return
    }

    userId, err := h.store.CreateUser(types.User{
        FirstName: user.FirstName,
        LastName:  user.LastName,
        Email:     user.Email,
        Password:  hashedPassword,
    })

    if err != nil {
        utils.WriteError(w, http.StatusInternalServerError, err)
        return
    }
    utils.WriteJSON(w, http.StatusCreated, map[string]int{"user_id": userId})
}

func (h *Handler) handleGetUser(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    str, ok := vars["userID"]
    if !ok {
        utils.WriteError(w, http.StatusBadRequest, fmt.Errorf("missing user ID"))
        return
    }

    userID, err := strconv.Atoi(str)
    if err != nil {
        utils.WriteError(w, http.StatusBadRequest, fmt.Errorf("invalid user ID"))
        return
    }

    user, err := h.store.GetUserByID(userID)
    if err != nil {
        utils.WriteError(w, http.StatusInternalServerError, err)
        return
    }

    utils.WriteJSON(w, http.StatusOK, user)
}

Enter fullscreen mode Exit fullscreen mode

We have 3 function here that we would want to test

  • HandleLogin
  • HandleRegister
  • HandleGetUser

HandleGetUser

The handleGetUser function in this handler retrieves user details based on a user ID provided in the HTTP request URL. It starts by extracting the userID from the request path variables using the mux router. If the userID is missing or invalid (non-integer), it responds with a 400 Bad Request error. Once validated, the function calls the GetUserByID method on the data store to retrieve user information. If an error occurs during retrieval, it returns a 500 Internal Server Error. On success, it responds with a 200 OK status, sending the user details as JSON in the response body.

As stated before for you to for as to test the handler functions we need to create a routes_test.go. See mine below

package user

import (
    "finance-crud-app/internal/types"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/gorilla/mux"
)

type mockUserStore struct{}

func (m *mockUserStore) DeleteUser(email string) error {
    return nil
}

func (m *mockUserStore) GetUserByEmail(email string) (types.User, error) {
    return types.User{}, nil
}

func (m *mockUserStore) CreateUser(u types.User) (userId int, err error) {
    return 1, nil
}

func (m *mockUserStore) GetUserByID(id int) (*types.User, error) {
    return &types.User{}, nil
}

func TestGetUserHandler(t *testing.T) {
    userStore := &mockUserStore{}
    handler := NewHandler(userStore)

    t.Run("should fail to get user with user_id that is not a number", func(t *testing.T) {
        req, err := http.NewRequest(http.MethodGet, "/user/abc", nil)
        if err != nil {
            t.Fatal(err)
        }

        rr := httptest.NewRecorder()
        router := mux.NewRouter()

        router.HandleFunc("/user/{userID}", handler.handleGetUser).Methods(http.MethodGet)

        router.ServeHTTP(rr, req)

        if rr.Code != http.StatusBadRequest {
            t.Errorf("expected status code %d, got %d", http.StatusBadRequest, rr.Code)
        }
    })

    t.Run("should pass to get user with numeric id", func(t *testing.T) {
        req, err := http.NewRequest(http.MethodGet, "/user/23", nil)
        if err != nil {
            t.Fatal(err)
        }

        rr := httptest.NewRecorder()
        router := mux.NewRouter()

        router.HandleFunc("/user/{userID}", handler.handleGetUser).Methods(http.MethodGet)
        router.ServeHTTP(rr, req)

        if rr.Code != http.StatusOK {
            t.Errorf("expected status code %d, got %d", http.StatusBadGateway, rr.Code)
        }
    })
}

Enter fullscreen mode Exit fullscreen mode

Our New Handler function requires a user store as a parameter for it to create a handler struct.
Since we do not need actual store we create a mock struct and create receiver functions that mock the function of the actual struct. We do this because we are handling the store function tests separetly therefore we dont need to test that part of the code in the handler tests.

The test function TestGetUserHandler test two case scenarios, the first is attempting to retrieve a user without providing the user id

t.Run("should fail to get user with user_id that is not a number", func(t *testing.T) {
        req, err := http.NewRequest(http.MethodGet, "/user/abc", nil)
        if err != nil {
            t.Fatal(err)
        }

        rr := httptest.NewRecorder()
        router := mux.NewRouter()

        router.HandleFunc("/user/{userID}", handler.handleGetUser).Methods(http.MethodGet)

        router.ServeHTTP(rr, req)

        if rr.Code != http.StatusBadRequest {
            t.Errorf("expected status code %d, got %d", http.StatusBadRequest, rr.Code)
        }
    })
Enter fullscreen mode Exit fullscreen mode

The test is expected to pass if the http request responds with a 400 status code.

The second test case scenario is cases where we are retrieving, user information by using the correct url containing a valid user id. In this test case we expected a response with 200 status code. If not that test will have failed.

t.Run("should pass to get user with numeric id", func(t *testing.T) {
        req, err := http.NewRequest(http.MethodGet, "/user/23", nil)
        if err != nil {
            t.Fatal(err)
        }

        rr := httptest.NewRecorder()
        router := mux.NewRouter()

        router.HandleFunc("/user/{userID}", handler.handleGetUser).Methods(http.MethodGet)
        router.ServeHTTP(rr, req)

        if rr.Code != http.StatusOK {
            t.Errorf("expected status code %d, got %d", http.StatusBadGateway, rr.Code)
        }
    })

Enter fullscreen mode Exit fullscreen mode

Conclusion

We have managed to implement unit test in our project by creating test for our route handlers. We have seen how to use mocks to only test a small unit of code. We have been able to developed integration test for our function that interact with Postgresql DB.
If you would want some hands on time with the project code clone the repo from github here

Top comments (0)