- read

OnlineNoteZ part 3 — Handler functions, and how to test them.

Adam Toth 2

Source: Google images

As promised in part 2, in this article we’re gonna look at how main.go looks right now, understand the note service, and some tests around it.

main.go

package main

import (
"log"

"github.com/adykaaa/online-notes/db"
"github.com/adykaaa/online-notes/db/migrations"
"github.com/adykaaa/online-notes/lib/config"
logger "github.com/adykaaa/online-notes/lib/logger"
"github.com/adykaaa/online-notes/note"
server "github.com/adykaaa/online-notes/server/http"
)

func main() {

config, err := config.Load(".")
if err != nil {
log.Fatalf("Could not load config. %v", err)
}

l := logger.New(config.LogLevel)

sqldb, err := db.NewSQL("postgres", config.DBConnString, &l)
if err != nil {
l.Fatal().Err(err).Send()
}

err = migrations.MigrateDB(config.DBConnString, "file://db/migrations/", &l)
if err != nil {
l.Fatal().Err(err).Send()
}

s := note.NewService(sqldb)

r, err := server.NewChiRouter(s, config.PASETOSecret, config.AccessTokenDuration, &l)
if err != nil {
l.Fatal().Err(err).Send()
}

httpServer, err := server.NewHTTP(r, config.HTTPServerAddress, &l)
if err != nil {
l.Fatal().Err(err).Send()
}

err = httpServer.Shutdown()
if err != nil {
l.Fatal().Err(err).Send()
}
}

When I’m looking at a Go code I have not seen before, after the documentation my first stop is usually the main.go file (or if it’s a cmd line utility, I look into /cmd). I really like keeping my main file as simple and as readable as possible, so that other developers reading it have a good starting point as well. (Will this change in the future? Heck yes. When we introduce more stuff like gRPC, caching, telemetry, chat functionality, and so on, there will likely be 1 or more structs with methods as the service, router, etc. but for now, this is enough).

I think this code is easy to read, the order of things are easy to follow — and represents the order of dependencies really nicely. Going backwards:

1. server -> it needs config,logger, and router
2. router
-> it needs config, logger, and NoteService
3. service
-> it needs our db
4. db
-> it needs config and logger
5.
logger -> it needs config
6.
config

Also we can see that non of the inner layers are dependent on the outer layers, so dependency inversion principle stands STRONG!

Service layer

I wanted to introduce a service layer into the application not because my brain is filled with over-engineered clean architecture Go repos, but because the DB operations are performed whenever an HTTP handler function is being called. If we did NOT have this service layer, then it would be the handlers’ responsibility to interact with the DB, which is not a good design. A handler function in my opinion should be only responsible for processing the request (unmarshaling, error checking, validating, etc.), interacting with the service layer, and sending back a response to the client (separation of duties).

note.go

package note

import (
"context"
"database/sql"
"errors"
"time"

db "github.com/adykaaa/online-notes/db/sqlc"
"github.com/google/uuid"
"github.com/lib/pq"
)

var (
ErrAlreadyExists = errors.New("note already exists")
ErrDBInternal = errors.New("internal DB error during operation")
ErrNotFound = errors.New("requested note is not found")
ErrUserAlreadyExists = errors.New("note already exists")
ErrUserNotFound = errors.New("requested note is not found")
)

type service struct {
q db.Querier
}

func NewService(q db.Querier) *service {
return &service{q}
}

func (s *service) RegisterUser(ctx context.Context, args *db.RegisterUserParams) (string, error) {
uname, err := s.q.RegisterUser(ctx, args)

switch {
case err != nil:
if err.(*pq.Error).Code.Name() == "unique_violation" {
return "", ErrUserAlreadyExists
}
return "", ErrDBInternal
default:
return uname, nil
}
}

func (s *service) GetUser(ctx context.Context, username string) (db.User, error) {
user, err := s.q.GetUser(ctx, username)

switch {
case errors.Is(err, sql.ErrNoRows):
return db.User{}, ErrUserNotFound
case err != nil:
return db.User{}, ErrDBInternal
default:
return user, nil
}
}

func (s *service) CreateNote(ctx context.Context, title string, username string, text string) (uuid.UUID, error) {
retID, err := s.q.CreateNote(ctx, &db.CreateNoteParams{
ID: uuid.New(),
Title: title,
Username: username,
Text: sql.NullString{String: text, Valid: true},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})

switch {
case err != nil:
if err.(*pq.Error).Code.Name() == "unique_violation" {
return uuid.Nil, ErrAlreadyExists
}
return uuid.Nil, ErrDBInternal
default:
return retID, nil
}
}

func (s *service) GetAllNotesFromUser(ctx context.Context, username string) ([]db.Note, error) {
notes, err := s.q.GetAllNotesFromUser(ctx, username)

if err != nil {
return nil, ErrDBInternal
}
return notes, nil
}

func (s *service) DeleteNote(ctx context.Context, reqID uuid.UUID) (uuid.UUID, error) {
id, err := s.q.DeleteNote(ctx, reqID)

switch {
case errors.Is(err, sql.ErrNoRows):
return uuid.Nil, ErrNotFound
case err != nil:
return uuid.Nil, ErrDBInternal
default:
return id, nil
}
}

func (s *service) UpdateNote(ctx context.Context, reqID uuid.UUID, title string, text string, isTextValid bool) (uuid.UUID, error) {
id, err := s.q.UpdateNote(ctx, &db.UpdateNoteParams{
ID: reqID,
Title: sql.NullString{String: title, Valid: true},
Text: sql.NullString{String: text, Valid: isTextValid},
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
})

switch {
case errors.Is(err, sql.ErrNoRows):
return uuid.Nil, ErrNotFound
case err != nil:
return uuid.Nil, ErrDBInternal
default:
return id, nil
}
}

As you can see, the service layer has its own errors — which are exported so they can be used in packages depending on the service, and the service struct has the Querier interface inside it (repository pattern), which looks something like this (it was generated by SQLC because of an option I supplied — more on this later. This interface is really important to have for abstraction — and for mocking as well):

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.16.0

package db

import (
"context"

"github.com/google/uuid"
)

type Querier interface {
CreateNote(ctx context.Context, arg *CreateNoteParams) (uuid.UUID, error)
DeleteNote(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetAllNotesFromUser(ctx context.Context, username string) ([]Note, error)
GetUser(ctx context.Context, username string) (User, error)
ListUsers(ctx context.Context) ([]User, error)
RegisterUser(ctx context.Context, arg *RegisterUserParams) (string, error)
UpdateNote(ctx context.Context, arg *UpdateNoteParams) (uuid.UUID, error)
}

var _ Querier = (*Queries)(nil)

The Querier interface describes all the operations that are needed for a service to be able to do all the necessary DB operations on notes and users. (the last line of the file is a really great trick btw, it checks whether the Queries struct still implements the Querier interface, a really good safeguard — if a method signature of the struct is altered or deleted, the code will fail because it no longer implements the Querier interface)

All the methods implemented on the service struct do the same thing: perform the necessary interaction with the database, and return the values which come out of them — which can be errors, UUIDs, Note objects, etc.

note_test.go (implemented with table-driven tests)

This file is pretty huge so I won’t go over all the tests here. Let’s pick the first one — registering a user, and go through that. I know it may look intimidating at first, but the other tests are really based on the same structure.

If we look at the RegisterUser function again:

func (s *service) RegisterUser(ctx context.Context, args *db.RegisterUserParams) (string, error) {
uname, err := s.q.RegisterUser(ctx, args)

switch {
case err != nil:
if err.(*pq.Error).Code.Name() == "unique_violation" {
return "", ErrUserAlreadyExists
}
return "", ErrDBInternal
default:
return uname, nil
}
}

How should we go about testing this function? Let’s think. We need to see whether given an input (args, which is of type *db.RegisterUserParams) we get the expected output which is either ErrUserAlreadyExists, ErrDBInternal, or we get back the username if the operation was successful — without caring about how the DB does it (again, we don’t test for implementation details!).

The beauty of table driven tests is that you create a struct (testCases) of structs — which contain your test cases, and later down the line you can just add other structs (test cases)— it makes it really extendable without touching the shared logic of the tests.

I put things that are global (and are safe to access concurrently in case we’re ussing t.Parallel()) in front of the test cases declaration, in this case args.

 args := db.RegisterUserParams{
Username: "user1",
Password: "password1",
Email: "[email protected]",
}

We are testing all of our functions for these input parameters, so it makes sense to have it in the beginning of the function (reminder: I should write stuff around fuzz testing in Go)

Then, I’m defining how my test cases for this function will look like:

 testCases := []struct {
name string
mockdbCreateUser func(mockdb *mockdb.MockQuerier, args *db.RegisterUserParams)
checkReturnValues func(t *testing.T, username string, err error)
}

All test cases should have names, they should all interact with the database (a mocked one in fact as these are unit tests), and then we should check whether given the input — we got the given output. These principles can be generally applied to our other tests as well. The struct fields are not the same for all of our tests, so it makes sense to have them as the struct fields — each test will have a different name, we likely check for a different outcome as well, and the db operation is different.

I’m gonna jump to the end of the test function here now to show you things that ARE in fact the same between our test cases:

 for c := range testCases {
tc := testCases[c]

t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
mockdb := mockdb.NewMockQuerier(ctrl)
ns := NewService(mockdb)

tc.mockdbCreateUser(mockdb, &args)
u, err := ns.q.RegisterUser(context.Background(), &args)
tc.checkReturnValues(t, u, err)
})
}

What the hell is this loop you ask? Well, this loop is going through all of our test cases that we previously defined in the form of subtests.

ctrl := gomock.NewController(t)
mockdb := mockdb.NewMockQuerier(ctrl)
ns := NewService(mockdb)

For every test case we must create a new mock controller, which then will be passed into the mocked database (The usage of GoMock is pretty well documented in its repo, definitely go and check it out), and we also need the service we test — with the mocked DB as it’s gonna perform operations on that. All the mocked DB operations were generated based on the Querier interface (see somewhere above).

tc.mockdbCreateUser(mockdb, &args)

Then, we call the mockdbCreateUser function — calling this function is necessary because we must tell the test: “Hey, I’m expecting a DB operation to be done here, with input X producing output Y” so that we can assert the results of the operation.

Important note: This does NOT call the mocked db function alone. It just tells the test that we EXPECT an operation to be done here (which will be invoked with the below function eventually).

u, err := ns.q.RegisterUser(context.Background(), &args)

After that we call the RegisterUser function on the service — which either returns the username, of an error — after all, the whole point of all of this is to check this function :)

tc.checkReturnValues(t, u, err)

Lastly, we call tc.checkReturnValues on the output of service method, and see if we got what we expected. This function determines whether the test is successful or not.

Okay, so far so good! Now we know that we define our test cases, range through them, and check whether they went alright or not. But what test cases? This is where your imagination and experience comes into the picture. I did not have time to cover all edge cases as this is for tutorial purposes, so I only covered three:

  1. when user registration goes so smooth that a fully stoned australian surfer could envy it:
 {
name: "user registration OK",
mockdbCreateUser: func(mockdb *mockdb.MockQuerier, args *db.RegisterUserParams) {
mockdb.EXPECT().RegisterUser(gomock.Any(), args).Times(1).Return(args.Username, nil)
},
checkReturnValues: func(t *testing.T, username string, err error) {
require.Equal(t, username, args.Username)
require.Nil(t, err)
},
},

When we successfully register a user, we expect the DB operation to be called with our arguments (gomock.Any() here signals that we don’t care about the context), and return the username, and no errors. Then, we actually tell the test (checkReturnValues function) that we want the returned username to be equal to the supplied one, and that the error should be nil.

2. when a user tries to register, but another user already exists with that username:

 {
name: "user registration returns ErrUserAlreadyExists",
mockdbCreateUser: func(mockdb *mockdb.MockQuerier, args *db.RegisterUserParams) {
mockdb.EXPECT().RegisterUser(gomock.Any(), args).Times(1).Return("", ErrUserAlreadyExists)
},
checkReturnValues: func(t *testing.T, username string, err error) {
require.ErrorIs(t, err, ErrUserAlreadyExists)
require.Empty(t, username)
},
},

Here we mock the DB operation so that we expect an ErrUserAlreadyExists returned. Then, we check whether that’s the case in our test, and if the username is empty (again, the username is only returned after a successful registration).

3. our DB got DDoS attacked by hackers in hoodies, so there was an error during the registration resulting in an internal DB error:

 {
name: "user registration returns ErrDBInternal",
mockdbCreateUser: func(mockdb *mockdb.MockQuerier, args *db.RegisterUserParams) {
mockdb.EXPECT().RegisterUser(gomock.Any(), args).Times(1).Return("", ErrDBInternal)
},
checkReturnValues: func(t *testing.T, username string, err error) {
require.ErrorIs(t, err, ErrDBInternal)
require.Empty(t, username)
},
},

You can see how easy it is to add other test cases now, all we’d have to do is just add another struct like so:

 {
name: "another test case I came up with that should fail",
mockdbCreateUser: func(mockdb *mockdb.MockQuerier, args *db.RegisterUserParams) {
mockdb.EXPECT().RegisterUser(gomock.Any(), args).Times(1).Return("", ErrAnotherWhichICameUpWith)
},
checkReturnValues: func(t *testing.T, username string, err error) {
require.ErrorIs(t, err, ErrAnotherWhichICameUpWith)
require.Empty(t, username)
},
},

As I stated in part 1, it can be hard to wrap your head around these tests if you look at them first. It took me some time to get used to this, but believe me — using table-driven tests always pays off in the long run for bigger projects.

In part 4, we will look at how I implemented the Chi router, talk about interfaces a bit (and bash Java devs), and look at handler functions — with their tests.