Tests

Writing unit and integration tests is important part of software development and it's something ideally done during development, but in this guide i dedicated one chapter for that since there are some prerequisites required before we can start to write tests. For instance, main thing to do is to create test database. This will be done by using already created development database schema.

We will start by creating new test configuration. Add function below to internal/conf/conf.go:

func NewTestConfig() Config {
  testConfig := NewConfig()
  testConfig.DbName = testConfig.DbName + "_test"
  return testConfig
}

This creates new configuration which is same as usual configuration, but with _test suffix appended to database name. So in our case, test database will be named rgb_test. To create test database connect to postgres database in the same way as explained in chapter 7 and run:

DROP DATABASE IF EXISTS rgb_test;
CREATE DATABASE rgb_test WITH TEMPLATE rgb;

This will create new database rgb_test using schema from rgb. This needs to be executed every time you change development database schema.

Every test case must be independent from others, so we should work with empty database for every test case. To do that, function to reset our test database will be created and called at the beginning of every test case. In this function we will define slice of all table names we wish to reset, clear all those tables, and reset their counters so all ID sequencing will start from 1 again. We can add that function to internal/store/store.go:

func ResetTestDatabase() {
  // Connect to test database
  SetDBConnection(database.NewDBOptions(conf.NewTestConfig()))

  // Empty all tables and restart sequence counters
  tables := []string{"users", "posts"}
  for _, table := range tables {
    _, err := db.Exec(fmt.Sprintf("DELETE FROM %s;", table))
    if err != nil {
      log.Panic().Err(err).Str("table", table).Msg("Error clearing test database")
    }

    _, err = db.Exec(fmt.Sprintf("ALTER SEQUENCE %s_id_seq RESTART;", table))
  }
}

One thing we will need to do in most of the tests is to setup test env and create new user. We don't want to repeat that in every test case. Let's create file internal/store/main_test.go and add helper functions:

package store

import (
  "rgb/internal/conf"
  "rgb/internal/store"

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

func testSetup() *gin.Engine {
  gin.SetMode(gin.TestMode)
  store.ResetTestDatabase()
  cfg := conf.NewConfig("dev")
  jwtSetup(cfg)
  return setRouter(cfg)
}

func addTestUser() (*User, error) {
  user := &User{
    Username: "batman",
    Password: "secret123",
  }
  err := AddUser(user)
  return user, err
}

Now we can start adding tests. Let's create new file internal/store/users_test.go and create first test:

package store

import (
  "testing"

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

func TestAddUser(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)
  assert.Equal(t, 1, user.ID)
  assert.NotEmpty(t, user.Salt)
  assert.NotEmpty(t, user.HashedPassword)
}

Another test that we can add for user account creation is when user tries to create account with already existing username:

func TestAddUserWithExistingUsername(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)
  assert.Equal(t, 1, user.ID)

  user, err = addTestUser()
  assert.Error(t, err)
  assert.Equal(t, "Username already exists.", err.Error())
}

To test Authenticate() function, we will create 3 tests: successful authentication, authenticating with invalid username, and authenticating with invalid password:

func TestAuthenticateUser(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)

  authUser, err := Authenticate(user.Username, user.Password)
  assert.NoError(t, err)
  assert.Equal(t, user.ID, authUser.ID)
  assert.Equal(t, user.Username, authUser.Username)
  assert.Equal(t, user.Salt, authUser.Salt)
  assert.Equal(t, user.HashedPassword, authUser.HashedPassword)
  assert.Empty(t, authUser.Password)
}

func TestAuthenticateUserInvalidUsername(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)

  authUser, err := Authenticate("invalid", user.Password)
  assert.Error(t, err)
  assert.Nil(t, authUser)
}

func TestAuthenticateUserInvalidPassword(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)

  authUser, err := Authenticate(user.Username, "invalid")
  assert.Error(t, err)
  assert.Nil(t, authUser)
}

Finally, we will test FetchUser() function with 2 tests: successfull fetch, and fetching not existing user:

func TestFetchUser(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)

  fetchedUser, err := FetchUser(user.ID)
  assert.NoError(t, err)
  assert.Equal(t, user.ID, fetchedUser.ID)
  assert.Equal(t, user.Username, fetchedUser.Username)
  assert.Empty(t, fetchedUser.Password)
  assert.Equal(t, user.Salt, fetchedUser.Salt)
  assert.Equal(t, user.HashedPassword, fetchedUser.HashedPassword)
}

func TestFetchNotExistingUser(t *testing.T) {
  testSetup()

  fetchedUser, err := FetchUser(1)
  assert.Error(t, err)
  assert.Nil(t, fetchedUser)
  assert.Equal(t, "Not found.", err.Error())
}

With that we finished writing tests for internal/store/user.go. You can try to add tests for rest of the files by yourself for practice, or check my solution in RGB GitHub.

Test functions above are testing only database communication, but our router and handlers are not tested here. For that we will need another set of tests. For start, we should create few more helper functions. We will create new file internal/server/main_test.go:

package server

import (
  "bytes"
  "encoding/json"
  "net/http"
  "net/http/httptest"
  "rgb/internal/store"
  "strings"

  "github.com/gin-gonic/gin"
  "github.com/rs/zerolog/log"
)

func testSetup() *gin.Engine {
  gin.SetMode(gin.TestMode)
  store.ResetTestDatabase()
  jwtSetup()
  return setRouter()
}

func userJSON(user store.User) string {
  body, err := json.Marshal(map[string]interface{}{
    "Username": user.Username,
    "Password": user.Password,
  })
  if err != nil {
    log.Panic().Err(err).Msg("Error marshalling JSON body.")
  }
  return string(body)
}

func jsonRes(body *bytes.Buffer) map[string]interface{} {
  jsonValue := &map[string]interface{}{}
  err := json.Unmarshal(body.Bytes(), jsonValue)
  if err != nil {
    log.Panic().Err(err).Msg("Error unmarshalling JSON body.")
  }
  return *jsonValue
}

func performRequest(router *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
  req, err := http.NewRequest(method, path, strings.NewReader(body))
  if err != nil {
    log.Panic().Err(err).Msg("Error creating new request")
  }
  rec := httptest.NewRecorder()
  req.Header.Add("Content-Type", "application/json")
  router.ServeHTTP(rec, req)
  return rec
}

Most of those functions are nothing new, except last one, performRequest(). In that function we are creating new request using http package and new recorder using httptest package. We also need to add Content-Type header with value application/json to our test request. We are now ready to serve that test request using passed router and record response with recorder. Let's now see how to use these functions in action. Create new file internal/server/user_test.go:

package server

import (
  "net/http"
  "rgb/internal/store"
  "testing"

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

func TestSignUp(t *testing.T) {
  router := testSetup()

  body := userJSON(store.User{
    Username: "batman",
    Password: "secret123",
  })
  rec := performRequest(router, "POST", "/api/signup", body)

  assert.Equal(t, http.StatusOK, rec.Code)
  assert.Equal(t, "Signed up successfully.", jsonRes(rec.Body)["msg"])
  assert.NotEmpty(t, jsonRes(rec.Body)["jwt"])
}

Again, i will not write rest of the tests here, so you can try to add it by yourself. If stuck, you can check RGB GitHub repo.

One thing to keep in mind is to run all test sequentially, without parallelism. They can affect each other if run simultaneously since it's expected for database to be empty for every test case. Go by default uses multiple goroutines to run tests if your machine has multiple cores. To make sure, only 1 goroutine is used, add -p 1 option. That means you should run tests using command:

go test -p 1 ./internal/...

16