Hello everyone, welcome to the backend master class! So far, we've written a lot of codes for our gRPC web services, but we haven't written any unit tests for them yet. So in this lecture, I'm gonna show you how to do that.
The API we're gonna test today is the CreateUser RPC. It's a bit more
complicated than other APIs, because it involves a DB transaction that
creates a new user, and if the user is successfully created, the
AfterCreate
callback function is called to distribute an async task that
sends a verification email to the user.
As you already know, the task is normally stored in Redis. So in order to unit test this RPC, we'll have to deal with 2 mock entities, one is the mock store for the database, and the other one is a mock task distributor for Redis.
First, let's create a new file called rpc_create_user_test.go
inside
the gapi
package. The tests we're gonna write will be pretty similar
to the ones we've written in the api
package for the Gin HTTP services.
So let's open the user_test.go
file, and copy the function
TestCreateUserAPI
from it, then let's paste it to the new file we've
just created.
package gapi
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"testing"
mockdb "github.com/MaksimDzhangirov/backendBankExample/db/mock"
db "github.com/MaksimDzhangirov/backendBankExample/db/sqlc"
"github.com/MaksimDzhangirov/backendBankExample/util"
"github.com/gin-gonic/gin"
"github.com/golang/mock/gomock"
"github.com/lib/pq"
"github.com/stretchr/testify/require"
)
type eqCreateUserParamsMatcher struct {
arg db.CreateUserParams
password string
}
func (e eqCreateUserParamsMatcher) Matches(x interface{}) bool {
arg, ok := x.(db.CreateUserParams)
if !ok {
return false
}
err := util.CheckPassword(e.password, arg.HashedPassword)
if err != nil {
return false
}
e.arg.HashedPassword = arg.HashedPassword
return reflect.DeepEqual(e.arg, arg)
}
func (e eqCreateUserParamsMatcher) String() string {
return fmt.Sprintf("matches arg %v and password %v", e.arg, e.password)
}
func EqCreateUserParams(arg db.CreateUserParams, password string) gomock.Matcher {
return eqCreateUserParamsMatcher{arg, password}
}
func TestCreateUserAPI(t *testing.T) {
user, password := randomUser(t)
testCases := []struct {
name string
body gin.H
buildStubs func(store *mockdb.MockStore)
checkResponse func(recoder *httptest.ResponseRecorder)
}{
{
name: "OK",
body: gin.H{
"username": user.Username,
"password": password,
"full_name": user.FullName,
"email": user.Email,
},
buildStubs: func(store *mockdb.MockStore) {
arg := db.CreateUserParams{
Username: user.Username,
FullName: user.FullName,
Email: user.Email,
}
store.EXPECT().
CreateUser(gomock.Any(), EqCreateUserParams(arg, password)).
Times(1).
Return(user, nil)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusOK, recorder.Code)
requireBodyMatchUser(t, recorder.Body, user)
},
},
{
name: "InternalError",
body: gin.H{
"username": user.Username,
"password": password,
"full_name": user.FullName,
"email": user.Email,
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
CreateUser(gomock.Any(), gomock.Any()).
Times(1).
Return(db.User{}, sql.ErrConnDone)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusInternalServerError, recorder.Code)
},
},
{
name: "DuplicateUsername",
body: gin.H{
"username": user.Username,
"password": password,
"full_name": user.FullName,
"email": user.Email,
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
CreateUser(gomock.Any(), gomock.Any()).
Times(1).
Return(db.User{}, &pq.Error{Code: "23505"})
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusForbidden, recorder.Code)
},
},
{
name: "InvalidUsername",
body: gin.H{
"username": "invalid-user#1",
"password": password,
"full_name": user.FullName,
"email": user.Email,
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
CreateUser(gomock.Any(), gomock.Any()).
Times(0)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusBadRequest, recorder.Code)
},
},
{
name: "InvalidEmail",
body: gin.H{
"username": user.Username,
"password": password,
"full_name": user.FullName,
"email": "invalid-email",
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
CreateUser(gomock.Any(), gomock.Any()).
Times(0)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusBadRequest, recorder.Code)
},
},
{
name: "TooShortPassword",
body: gin.H{
"username": user.Username,
"password": "123",
"full_name": user.FullName,
"email": user.Email,
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
CreateUser(gomock.Any(), gomock.Any()).
Times(0)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusBadRequest, recorder.Code)
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
store := mockdb.NewMockStore(ctrl)
tc.buildStubs(store)
server := newTestServer(t, store)
recorder := httptest.NewRecorder()
// Marshal body data to JSON
data, err := json.Marshal(tc.body)
require.NoError(t, err)
url := "/users"
request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
require.NoError(t, err)
server.router.ServeHTTP(recorder, request)
tc.checkResponse(recorder)
})
}
}
As you can see, this test features a custom gomock.Matcher
, that we've
written in lecture 18 of the course, in order to correctly compare the input
arguments of the CreateUser
function, since the hashed password field
is a bit tricky to handle. You can rewatch that lecture to understand what
we're doing here before continue.
Alright, now let's update the code of this test to make it work with our gRPC server.
First, we have to create a random user with this randomUser()
function. It
is written in the api
package, so let's head over there to copy it and
paste it here, right before the TestCreateUserAPI
function.
func randomUser(t *testing.T) (user db.User, password string) {
password = util.RandomString(6)
hashedPassword, err := util.HashPassword(password)
require.NoError(t, err)
user = db.User{
Username: util.RandomOwner(),
HashedPassword: hashedPassword,
FullName: util.RandomOwner(),
Email: util.RandomEmail(),
}
return
}
func TestCreateUserAPI(t *testing.T) {
...
}
Next, we're gonna define some test cases. This name
field will store
the name of the case. Then, the body
field will have to be changed,
because we're not using Gin here, but gRPC instead. If we look at the
CreateUser RPC handler, we will see that the input data is sent via this
CreateUserRequest
object. So that's exactly the data type that we're
gonna store in this case.
testCases := []struct {
name string
req *pb.CreateUserRequest
}{
...
}
OK, now we have to update the content of this request. It is super easy to
write codes with gRPC, since everything is strongly typed, so we will
have the autocomplete feature from Visual Studio Code. We have to change
this asterisk *pb.CreateUserRequest
to ampersand, because we want to
obtain the address of this request object.
req: &pb.CreateUserRequest{
Username: user.Username,
Password: password,
FullName: user.FullName,
Email: user.Email,
}
OK, next let's move to the buildStubs
function. This is where we tell
gomock
, which function we expect to be called, with which parameters,
and return which output. When we implemented the CreateUser HTTP API with
Gin, we didn't have the async task, that sends emails, so that's why
we expect the CreateUser
method of the store
to be called directly.
store.EXPECT().
CreateUser(gomock.Any(), EqCreateUserParams(arg, password)).
Times(1).
Return(user, nil)
However, in out new implementation of the CreateUser RPC, we're not calling that method directly, but we're calling the CreateUser transaction instead.
txResult, err := server.store.CreateUserTx(ctx, arg)
The CreateUser
method only gets called inside that transaction, together
with the callback function. So here, in buildStubs
function, I'm
gonna change this argument arg := db.CreateUserParams
to
db.CreateUserTxParams
and set the CreateUserParams
as an inner
field of it.
buildStubs: func(store *mockdb.MockStore) {
arg := db.CreateUserTxParams{
CreateUserParams: db.CreateUserParams{
Username: user.Username,
FullName: user.FullName,
Email: user.Email,
},
}
...
}
Note that this struct also has another field for the AfterCreate
callback, but there's no way to compare 2 functions in Golang, so let's
ignore this callback for now. We will deal with it later.
OK, here we should expect the CreateUser transaction of the mock
store
to be called exactly 1 time with the created input argument.
store.EXPECT().
CreateUserTx(gomock.Any(), EqCreateUserParams(arg, password)).
Times(1).
Return(user, nil)
However, since we're using this custom gomock
matcher to compare
the input, we will have to update its data type a little bit.
First, I'm gonna change its name to eqCreateUserTxParamsMatcher
. Then
this arg db.CreateUserParams
argument's type should be
db.CreateUserTxParams
. By the way, to make it easier to understand,
I'm gonna change the name of this func (e eqCreateUserTxParamsMatcher)
variable to "expected", since it contains the expected value of the
arguments. And this x
variable in Matches(x interface{})
will contain
the actual value, that the gRPC handler will use when it calls the
CreateUser transaction. So, when we convert it to db.CreateUserTxParams
,
I'm gonna assign the result to a variable named actualArg
. Next, we will
use util.CheckPassword
function to check that the expected password
matches the actual hashed password. And finally, we assign the actual hashed
password to the expected argument.
func (expected eqCreateUserTxParamsMatcher) Matches(x interface{}) bool {
actualArg, ok := x.(db.CreateUserTxParams)
if !ok {
return false
}
err := util.CheckPassword(expected.password, actualArg.HashedPassword)
if err != nil {
return false
}
expected.arg.HashedPassword = actualArg.HashedPassword
return reflect.DeepEqual(expected.arg, actualArg)
}
This is a trick, because we want to use the DeepEqual
function to
compare them. I already explained the reason we have to do this in
lecture 18. Every time we hash the password, a random salt us added, so
the output hash value will be different, which will help us prevent rainbow
table attacks.
OK, now we have to change the type of this func EqCreateUserTxParams(arg db.CreateUserParams, password string)
argument
to db.CreateUserTxParams
. Then go back to the unit test, that we're
writing.
We will have to update the checkResponse
function, because for gRPC
services, we won't use an HTTP recorder to store the response, but we will
get a CreateUserResponse
object or an error directly from the RPC
handler. So I'm gonna put them as the input arguments of this
checkResponse
function.
checkResponse: func(t *testing.T, res *pb.CreateUserResponse, err error) {
...
}
I also add a testing.T
object as the first input argument, since we're
gonna need it when comparing the real output with the expected value.
Alright, let's copy this function signature, and paste it into the
testCases
struct definition.
testCases := []struct {
name string
req *pb.CreateUserRequest
buildStubs func(store *mockdb.MockStore)
checkResponse func(t *testing.T, res *pb.CreateUserResponse, err error)
}{
...
}
OK, now let's fix the content of the checkResponse
function. Since this
is a successful case, we expect no errors to be returned. And the response
should be not nil
. Next, we can get the created user object from the
response. And require the created user's username to equal the input
user.Username
. We expect the same thing for the other fields, such as
the full name and email address.
checkResponse: func(t *testing.T, res *pb.CreateUserResponse, err error) {
require.NoError(t, err)
require.NotNil(t, res)
createdUser := res.GetUser()
require.Equal(t, user.Username, createdUser.Username)
require.Equal(t, user.FullName, createdUser.FullName)
require.Equal(t, user.Email, createdUser.Email)
}
Oh, I've just noticed that we should change this
store.EXPECT().
CreateUserTx(gomock.Any(), EqCreateUserParams(arg, password)).
Times(1).
Return(user, nil)
EqCreateUserParams
function's name to EqCreateUserTxParams
, because the
input argument is now, in fact, a CreateUserTxParams
. And one more
thing, we're expecting the CreateUserTx
function to be called once, and
according to the code, it will return a CreateUserTxResult
object. So
here, we cannot just return a user
object like this, but we will have
to change it into a db.CreateUserTxResult
, and User
will be just one
field of this object. And that's how the test data for the happy case
should look like.
store.EXPECT().
CreateUserTx(gomock.Any(), EqCreateUserTxParams(arg, password)).
Times(1).
Return(db.CreateUserTxResult{User: user}, nil)
We will have to update the unsuccessful case as well, but let's save it for later, and just focus on the happy case first. So for now, to keep things simple, I'm just gonna delete all of them.
testCases := []struct {
name string
req *pb.CreateUserRequest
buildStubs func(store *mockdb.MockStore)
checkResponse func(t *testing.T, res *pb.CreateUserResponse, err error)
}{
{
name: "OK",
req: &pb.CreateUserRequest{
Username: user.Username,
Password: password,
FullName: user.FullName,
Email: user.Email,
},
buildStubs: func(store *mockdb.MockStore) {
arg := db.CreateUserTxParams{
CreateUserParams: db.CreateUserParams{
Username: user.Username,
FullName: user.FullName,
Email: user.Email,
},
}
store.EXPECT().
CreateUserTx(gomock.Any(), EqCreateUserTxParams(arg, password)).
Times(1).
Return(db.CreateUserTxResult{User: user}, nil)
},
checkResponse: func(t *testing.T, res *pb.CreateUserResponse, err error) {
require.NoError(t, err)
require.NotNil(t, res)
createdUser := res.GetUser()
require.Equal(t, user.Username, createdUser.Username)
require.Equal(t, user.FullName, createdUser.FullName)
require.Equal(t, user.Email, createdUser.Email)
},
},
}
The next thing we need to do is update the body of the unit test. As you can see here,
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
store := mockdb.NewMockStore(ctrl)
tc.buildStubs(store)
server := newTestServer(t, store)
recorder := httptest.NewRecorder()
// Marshal body data to JSON
data, err := json.Marshal(tc.body)
require.NoError(t, err)
url := "/users"
request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
require.NoError(t, err)
server.router.ServeHTTP(recorder, request)
tc.checkResponse(recorder)
})
we created a gomock
controller, and use it to create a mock store
for
the database. We will then use the mock store
to create a test server. This
newTestServer
function is missing in the gapi
package. We can find its
content from the main_test.go
file in the api
package.
func TestMain(m *testing.M) {
gin.SetMode(gin.TestMode)
os.Exit(m.Run())
}
func newTestServer(t *testing.T, store db.Store) *Server {
config := util.Config{
TokenSymmetricKey: util.RandomString(32),
AccessTokenDuration: time.Minute,
}
server, err := NewServer(config, store)
require.NoError(t, err)
return server
}
There's also a TestMain()
function in this file, which we used to set
the Gin test mode, but we don't need it for our gRPC service, so let's copy
only the newTestServer
function.
I'm gonna create a main_test.go
file inside the gapi
package, and
paste in the content of the function we've just copied.
package gapi
import (
"testing"
"time"
db "github.com/MaksimDzhangirov/backendBankExample/db/sqlc"
"github.com/MaksimDzhangirov/backendBankExample/util"
"github.com/stretchr/testify/require"
)
func newTestServer(t *testing.T, store db.Store) *Server {
config := util.Config{
TokenSymmetricKey: util.RandomString(32),
AccessTokenDuration: time.Minute,
}
server, err := NewServer(config, store)
require.NoError(t, err)
return server
}
We'll have to update this a bit though, since the NewServer
function
of the gapi
package requires 1 more input argument for the task
distributor. And if we open its definition, we'll see that it's an interface
with this only method.
type TaskDistributor interface {
DistributeTaskSendVerifyEmail(
ctx context.Context,
payload *PayloadSendVerifyEmail,
opts ...asynq.Option,
) error
}
This is good for writing unit tests, because we don't want to connect to
a real Redis server. So just like what we did for the Store
interface,
we can also generate a mock for the TaskDistributor
interface, and use
it in the test.
To do that, I'm gonna open the Makefile
, and scroll down to the "mock"
command.
mock:
mockgen -package mockdb -destination db/mock/store.go github.com/MaksimDzhangirov/backendBankExample/db/sqlc Store
Let's duplicate this command that generates a mock Store
. Then change
the package name to mockwk
(which means "mock worker"). The output
destination will be inside the worker
folder. A mock
folder will be
created, and inside it, there will be a distributor.go
file to contain
the generated codes of the mock task distributor. We'll have to change
the path of this input package backendBankExample/db/sqlc
to
backendBankExample/worker
like this.
mock:
mockgen -package mockdb -destination db/mock/store.go github.com/MaksimDzhangirov/backendBankExample/db/sqlc Store
mockgen -package mockwk -destination worker/mock/distributor.go github.com/MaksimDzhangirov/backendBankExample/worker TaskDistributor
And of course, the name of the input interface should be TaskDistributor
.
OK, now we can run
make mock
in the terminal to generate the mock objects. It's successful.
So in Visual Studio Code, we will see a new mock
folder inside the
worker
folder, and inside this folder there's a distributor.go
file
with the MockTaskDistributor
struct, which we can use in the unit tests.
Now let's get back to the main_test.go
file. I'm gonna add a task
distributor argument to the newTestServer
function. And we can pass it
into this NewServer(config, store)
function to create a new server.
func newTestServer(t *testing.T, store db.Store, taskDistributor worker.TaskDistributor) *Server {
config := util.Config{
TokenSymmetricKey: util.RandomString(32),
AccessTokenDuration: time.Minute,
}
server, err := NewServer(config, store, taskDistributor)
require.NoError(t, err)
return server
}
Alright, with all these changes in place, we can continue writing our unit tests.
As the newTestServer
function now requires a task distributor, we'll
create a mock for it, just like how we created a mock store. So I'm
gonna define a taskDistributor
variable, and assign it to
mockwk.NewMockTaskDistributor
.
taskDistributor := mockwk.NewMockTaskDistributor()
Since this is the first time we use this package, Visual Studio Code
doesn't know about it yet, so we'll have to import this package manually.
Let's duplicate this import mockdb
line, then change its name to
mockwk
, and the path of the package should be
backendBankExample/worker/mock
.
import (
...
mockwk "github.com/MaksimDzhangirov/backendBankExample/worker/mock"
...
)
OK, now if we go back to the test, we will see that Visual Studio Code has recognized the package. It also requires a mock controller object as input, so let's pass in the same controller we used for the mock store. Then, we can now use the mock task distributor to create the test server.
store := mockdb.NewMockStore(ctrl)
tc.buildStubs(store)
taskDistributor := mockwk.NewMockTaskDistributor(ctrl)
server := newTestServer(t, store, taskDistributor)
recorder := httptest.NewRecorder()
Next, I'm gonna get rid of all these codes that set up and send HTTP requests.
recorder := httptest.NewRecorder()
// Marshal body data to JSON
data, err := json.Marshal(tc.body)
require.NoError(t, err)
url := "/users"
request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
require.NoError(t, err)
server.router.ServeHTTP(recorder, request)
For gRPC server unit testing, we can just call the RPC handler function
directly like this: server.CreateUser
. Then pass in a background
context and the input request object of the test case. It will return a
response object and an error, so we can send them to the checkResponse()
function of the test case together with the testing.T
object for final
verification of the result.
server := newTestServer(t, store, taskDistributor)
res, err := server.CreateUser(context.Background(), tc.req)
tc.checkResponse(t, res, err)
Note that this t
is different from the global t
, because it has been
shadowed by the input argument of this t.Run
function. So basically,
it's the t
object of the sub-test, created by the Run()
function. This
way, the checkResponse
call for each case will be independent, and not
interfere with each other, when we add more cases in the future.
Alright, the happy case is ready. Let's run it to see what happens!
=== RUN TestCreateUserAPI
=== RUN TestCreateUserAPI/OK
...
--- FAIL: TestCreateUserAPI (0.18s)
--- FAIL: TestCreateUserAPI/OK (0.11s)
FAIL
FAIL github.com/MaksimDzhangirov/backendBankExample/gapi 0.545s
FAIL
It failed with this error: "missing a call to mockStore.CreateUserTx. The
expected call doesn't match the argument at index 1", which is the
CreateUserTxParams
. Ok, so we know this expectation
store.EXPECT().
CreateUserTx(gomock.Any(), EqCreateUserTxParams(arg, password)).
Times(1).
Return(db.CreateUserTxResult{User: user}, nil)
of function call is not satisfied. But what can we do about it? How can we figure out what's wrong with our implementation?
Well, one way to debug this kind of error is, to print out some logs.
We must find out up to which part the code has been executed. So I'm gonna
add several fmt.Println()
messages, one before validating the request,
one before hashing the password and one right before running the CreateUser
transaction.
func (server *Server) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
fmt.Println("validate request")
violations := validateCreateUserRequest(req)
if violations != nil {
return nil, invalidArgumentError(violations)
}
fmt.Println("request:", req)
hashedPassword, err := util.HashPassword(req.GetPassword())
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to hash password: %s", err)
}
arg := db.CreateUserTxParams{
CreateUserParams: db.CreateUserParams{
Username: req.GetUsername(),
HashedPassword: hashedPassword,
FullName: req.GetFullName(),
Email: req.GetEmail(),
},
AfterCreate: func(user db.User) error {
taskPayload := &worker.PayloadSendVerifyEmail{
Username: user.Username,
}
opts := []asynq.Option{
asynq.MaxRetry(10),
asynq.ProcessIn(10 * time.Second),
asynq.Queue(worker.QueueCritical),
}
return server.taskDistributor.DistributeTaskSendVerifyEmail(ctx, taskPayload, opts...)
},
}
fmt.Println("create user tx", arg)
txResult, err := server.store.CreateUserTx(ctx, arg)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok {
switch pqErr.Code.Name() {
case "unique_violation":
return nil, status.Errorf(codes.AlreadyExists, "username already exists: %s", err)
}
}
return nil, status.Errorf(codes.Internal, "failed to create user: %s", err)
}
rsp := &pb.CreateUserResponse{
User: convertUser(txResult.User),
}
return rsp, nil
}
OK, now let's rerun the test.
=== RUN TestCreateUserAPI
=== RUN TestCreateUserAPI/OK
validate request
request: username:"fthteb" full_name:"vklrvh" email:"[email protected]" password:"ouhhdy"
create user tx {{fthteb $2a$10$vPpxT8MAnxflwHnw7mw6auUSwqRBdGnLbahTljqPMMnbnWol6.Xpe vklrvh [email protected]} 0x15ea480}
...
This time, we can see the 3 lines of logs that we've just added. So it has indeed run up to the CreateUser transaction, but it couldn't go through, because the argument doesn't match with the one we expect.
So let's take a look at the custom gomock
matcher. I'm gonna add some
more debug logs here.
func (expected eqCreateUserTxParamsMatcher) Matches(x interface{}) bool {
fmt.Println(">> check param matches")
actualArg, ok := x.(db.CreateUserTxParams)
if !ok {
return false
}
fmt.Println(">> check password", actualArg)
err := util.CheckPassword(expected.password, actualArg.HashedPassword)
if err != nil {
return false
}
fmt.Println(">> deep equal")
expected.arg.HashedPassword = actualArg.HashedPassword
return reflect.DeepEqual(expected.arg, actualArg)
}
First, before converting the params, second, before checking the password,
third, before comparing the arguments with DeepEqual
. And I want to add
1 more log at the end, so let's change this
return reflect.DeepEqual(expected.arg, actualArg)
into an if
clause. If
the arguments are not equal, we return false
. Else, we print a message
saying "param matches!", and return true
.
func (expected eqCreateUserTxParamsMatcher) Matches(x interface{}) bool {
...
fmt.Println(">> deep equal")
expected.arg.HashedPassword = actualArg.HashedPassword
if !reflect.DeepEqual(expected.arg, actualArg) {
return false
}
fmt.Println(">> param matches!")
return true
}
Alright, let's rerun the test one more time.
=== RUN TestCreateUserAPI
=== RUN TestCreateUserAPI/OK
validate request
request: username:"ohhojh" full_name:"hstuwy" email:"[email protected]" password:"onzfew"
create user tx {{ohhojh $2a$10$r9mAzKfxXw1lCI19zb.dt07RvIkdeTZKnc8AdMdNqhZJMpb/yhLBC hstuwy [email protected]} 0x15ea480}
>> check param matches
>> check password {{ohhojh $2a$10$r9mAzKfxXw1lCI19zb.dt07RvIkdeTZKnc8AdMdNqhZJMpb/yhLBC hstuwy [email protected]} 0x15ea480}
>> deep equal
...
This time, we can see all the logs up until the DeepEqual
statement.
But there's no log saying "param matches!", so the issue must come from the
DeepEqual
comparison of the arguments. But we've already taken care of
the tricky hashed password, then what makes this DeepEqual
call fail?
Well, I just remember another tricky field in the CreateUserTxParams
,
which is the AfterCreate
callback function. As I said before, there's
no way to compare 2 functions in Go, so this is exactly what makes
the DeepEqual
statement fail.
To avoid this, we should compare only the CreateUserParams
fields instead
of the whole argument objects.
func (expected eqCreateUserTxParamsMatcher) Matches(x interface{}) bool {
...
fmt.Println(">> deep equal")
expected.arg.HashedPassword = actualArg.HashedPassword
if !reflect.DeepEqual(expected.arg.CreateUserParams, actualArg.CreateUserParams) {
return false
}
fmt.Println(">> param matches!")
return true
}
Just like that, and I think the error should be gone when we rerun the test.
=== RUN TestCreateUserAPI
=== RUN TestCreateUserAPI/OK
...
--- PASS: TestCreateUserAPI (0.19s)
--- PASS: TestCreateUserAPI/OK (0.12s)
PASS
ok github.com/MaksimDzhangirov/backendBankExample/gapi 0.514s
Indeed, this time the test passed. So, I'm gonna delete all the debug logs that we've added before.
func (expected eqCreateUserTxParamsMatcher) Matches(x interface{}) bool {
actualArg, ok := x.(db.CreateUserTxParams)
if !ok {
return false
}
err := util.CheckPassword(expected.password, actualArg.HashedPassword)
if err != nil {
return false
}
expected.arg.HashedPassword = actualArg.HashedPassword
if !reflect.DeepEqual(expected.arg.CreateUserParams, actualArg.CreateUserParams) {
return false
}
return true
}
There's a warning here, since this if
can be replaced with 1 single
return statement, but I'm gonna keep it like this for now, because we're
gonna do something with the callback later if the arguments match.
OK, let's remove the 3 debug logs in the rpc_create_user.go
file as
well.
Now, the test has passed, but it only checks that the CreateUser
transaction is executed with the expected arguments. One thing it hasn't
checked is, the AfterCreate
callback function. In this case, we would
like to be sure that the DistributeTaskSendVerifyEmail
function is called
every time a new user is created. That way, an email will be delivered
to user in the future. So, how can we test this?
Well, we can use the MockTaskDistributor
for this purpose, just like
what we did for the mock store. I'm gonna add a new taskDistributor
argument to the buildStubs()
function and update its function signature
in the testCases
struct.
testCases := []struct {
name string
req *pb.CreateUserRequest
buildStubs func(store *mockdb.MockStore, taskDistributor *mockwk.MockTaskDistributor)
checkResponse func(t *testing.T, res *pb.CreateUserResponse, err error)
}{
{
name: "OK",
req: &pb.CreateUserRequest{
Username: user.Username,
Password: password,
FullName: user.FullName,
Email: user.Email,
},
buildStubs: func(store *mockdb.MockStore, taskDistributor *mockwk.MockTaskDistributor) {
arg := db.CreateUserTxParams{
CreateUserParams: db.CreateUserParams{
Username: user.Username,
FullName: user.FullName,
Email: user.Email,
},
}
store.EXPECT().
CreateUserTx(gomock.Any(), EqCreateUserTxParams(arg, password)).
Times(1).
Return(db.CreateUserTxResult{User: user}, nil)
},
checkResponse: func(t *testing.T, res *pb.CreateUserResponse, err error) {
...
},
},
}
Then, in the body of the test, let's move the taskDistributor
declaration
up here.
store := mockdb.NewMockStore(ctrl)
taskDistributor := mockwk.NewMockTaskDistributor(ctrl)
tc.buildStubs(store)
And pass it into the tc.buildStubs()
call, together with the mock store.
store := mockdb.NewMockStore(ctrl)
taskDistributor := mockwk.NewMockTaskDistributor(ctrl)
tc.buildStubs(store, taskDistributor)
server := newTestServer(t, store, taskDistributor)
Just like that!
Then at the end of the buildStubs
function, I'm gonna call
taskDistributor.EXPECT()
. This time, we want the
DistributeTaskSendVerifyEmail()
function to be called. It has several
input arguments, such as the context, the task payload, and some asynq
options. So, I'm gonna use gomock.Any()
for the context, then a
taskPayload
, which we will declare a bit later, finally, another
gomock.Any()
matcher for the asynq
options.
taskDistributor.EXPECT().
DistributeTaskSendVerifyEmail(gomock.Any(), taskPayload, gomock.Any())
This Any()
matcher can match variadic arguments, so it will replace the
3 options we send in this opt
array.
opts := []asynq.Option{
asynq.MaxRetry(10),
asynq.ProcessIn(10 * time.Second),
asynq.Queue(worker.QueueCritical),
}
If you want, you can also use gomock.Eq()
matcher to match each of them.
For me, I'm gonna keep it simple with gomock.Any()
, and focus on the
more important param, which is the taskPayload
. We can copy it from the
callback function here.
AfterCreate: func(user db.User) error {
taskPayload := &worker.PayloadSendVerifyEmail{
Username: user.Username,
}
...
},
Basically, it should be of type PayloadSendVerifyEmail
, with the Username
field set to the input user.Username
.
buildStubs: func(store *mockdb.MockStore, taskDistributor *mockwk.MockTaskDistributor) {
arg := db.CreateUserTxParams{
CreateUserParams: db.CreateUserParams{
Username: user.Username,
FullName: user.FullName,
Email: user.Email,
},
}
store.EXPECT().
CreateUserTx(gomock.Any(), EqCreateUserTxParams(arg, password)).
Times(1).
Return(db.CreateUserTxResult{User: user}, nil)
taskPayload := &worker.PayloadSendVerifyEmail{
Username: user.Username,
}
taskDistributor.EXPECT().
DistributeTaskSendVerifyEmail(gomock.Any(), taskPayload, gomock.Any())
},
So, now, we can expect this distribute function to be called exactly one
time and it will return an output result of type error
. This is a happy case,
so we're going to return a nil
error here.
taskDistributor.EXPECT().
DistributeTaskSendVerifyEmail(gomock.Any(), taskPayload, gomock.Any()).
Times(1).
Return(nil)
And that's it! Let's try to run the test to see what happens!
=== RUN TestCreateUserAPI
=== RUN TestCreateUserAPI/OK
...
--- FAIL: TestCreateUserAPI (0.18s)
--- FAIL: TestCreateUserAPI/OK (0.11s)
FAIL
FAIL github.com/MaksimDzhangirov/backendBankExample/gapi 0.531s
FAIL
It failed! And the error is missing calls to the
DistributeTaskSendVerifyEmail()
function. So what happened? Why is this
function not getting called? This is a bit tricky, so you can pause the
video and think about it for a while.
Did you figure it out? Well, remember that besides the TaskDistributor
,
we're also mocking the CreateUser transaction. And if we look at its
implementation, we'll see that the function to distribute task send verify
email is only called inside the AfterCreate
callback function. That
means, it will only be called if the real DB transaction is executed, and
a real user is successfully created inside the database. However, since
we're mocking this CreateUser transaction, the actual code that talks to
the DB is not executed. Therefore, the AfterCreate
function doesn't get
a chance to run either.
So what can we do? Can we somehow still trigger the AfterCreate
callback
in the mock transaction? And it should not be a fake trigger, but should
come from the actual AfterCreate
callback function.
Well, luckily we have this custom gomock
matcher, that compares the
actual arguments of the transaction with the expected one. And we know
that, if it is a match, then it's kind of the same as if the transaction
is executed. So, we can safely assume that the AfterCreate
function
can be executed here.
func (expected eqCreateUserTxParamsMatcher) Matches(x interface{}) bool {
actualArg, ok := x.(db.CreateUserTxParams)
if !ok {
return false
}
err := util.CheckPassword(expected.password, actualArg.HashedPassword)
if err != nil {
return false
}
expected.arg.HashedPassword = actualArg.HashedPassword
if !reflect.DeepEqual(expected.arg.CreateUserParams, actualArg.CreateUserParams) {
return false
}
// call the AfterCreate function here
return true
}
And since we have the actual implementation of the callback function inside
this actualArg
object, we can simply call actualArg.AfterCreate()
,
and pass in a db.User
object as input. Although, it's possible to
construct the User
from the actual input arguments, it will be faster
if we just store the expected user object inside this
eqCreateUserTxParamsMatcher
matcher struct, and just use it here when
executing this callback function.
type eqCreateUserTxParamsMatcher struct {
arg db.CreateUserTxParams
password string
user db.User
}
The callback function will return an error, so we will return true
if that error is nil
at the end.
func (expected eqCreateUserTxParamsMatcher) Matches(x interface{}) bool {
...
// call the AfterCreate function here
err = actualArg.AfterCreate(expected.user)
return err == nil
}
Alright, now we have to update this EqCreateUserTxParams
constructor
function to accept one more parameter for the expected user object and
use it to create the custom matcher.
func EqCreateUserTxParams(arg db.CreateUserTxParams, password string, user db.User) gomock.Matcher {
return eqCreateUserTxParamsMatcher{arg, password, user}
}
Then, in the buildStubs()
function, we'll add the target user to this
constructor.
buildStubs: func(store *mockdb.MockStore, taskDistributor *mockwk.MockTaskDistributor) {
arg := db.CreateUserTxParams{
CreateUserParams: db.CreateUserParams{
Username: user.Username,
FullName: user.FullName,
Email: user.Email,
},
}
store.EXPECT().
CreateUserTx(gomock.Any(), EqCreateUserTxParams(arg, password, user)).
Times(1).
Return(db.CreateUserTxResult{User: user}, nil)
...
},
And that should be it!
Let's rerun the test to see if it's working or not.
=== RUN TestCreateUserAPI
=== RUN TestCreateUserAPI/OK
Wow, it seems to take a really long time! It feels like the test gets stuck somewhere. If that's the case, it will be timed out after 30 seconds. And indeed, we've got a panic, because the test cannot finish in time.
panic: test timed out after 30s
The reason for this can be difficult to spot if you don't fully understand
the way gomock
works. In fact, the problem comes from the way we use the
same controller for both the mock store and the mock task distributor.
ctrl := gomock.NewController(t)
defer ctrl.Finish()
store := mockdb.NewMockStore(ctrl)
taskDistributor := mockwk.NewMockTaskDistributor(ctrl)
There's a locking mechanism in the controller. Every time it checks for
a matching function call. So when the CreteUserTx
function is being
checked for matching arguments, the mock controller will be locked.
store.EXPECT().
CreateUserTx(gomock.Any(), EqCreateUserTxParams(arg, password, user)).
Times(1).
Return(db.CreateUserTxResult{User: user}, nil)
taskPayload := &worker.PayloadSendVerifyEmail{
Username: user.Username,
}
taskDistributor.EXPECT().
DistributeTaskSendVerifyEmail(gomock.Any(), taskPayload, gomock.Any()).
Times(1).
Return(nil)
// DistributeTaskSendVerifyEmail indicates an expected call of DistributeTaskSendVerifyEmail.
func (mr *MockTaskDistributorMockRecorder) DistributeTaskSendVerifyEmail(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0, arg1}, arg2...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DistributeTaskSendVerifyEmail", reflect.TypeOf((*MockTaskDistributor)(nil).DistributeTaskSendVerifyEmail), varargs...)
}
// RecordCallWithMethodType is called by a mock. It should not be called by user code.
func (ctrl *Controller) RecordCallWithMethodType(receiver interface{}, method string, methodType reflect.Type, args ...interface{}) *Call {
ctrl.T.Helper()
call := newCall(ctrl.T, receiver, method, methodType, args...)
ctrl.mu.Lock()
defer ctrl.mu.Unlock()
ctrl.expectedCalls.Add(call)
return call
}
That's why when we call the AfterCreate()
callback function, it can no
longer acquire the lock to record the call to the mock task distributor.
To fix this, we can simply use two different controllers, one for the
mock store, and the other one for the mock task distributor.
t.Run(tc.name, func(t *testing.T) {
storeCtrl := gomock.NewController(t)
defer storeCtrl.Finish()
store := mockdb.NewMockStore(storeCtrl)
taskCtrl := gomock.NewController(t)
defer taskCtrl.Finish()
taskDistributor := mockwk.NewMockTaskDistributor(taskCtrl)
tc.buildStubs(store, taskDistributor)
server := newTestServer(t, store, taskDistributor)
res, err := server.CreateUser(context.Background(), tc.req)
tc.checkResponse(t, res, err)
})
Just like that, and we're done. This time, I expect the test to pass.
Let's run it one more time!
=== RUN TestCreateUserAPI
=== RUN TestCreateUserAPI/OK
--- PASS: TestCreateUserAPI (0.19s)
--- PASS: TestCreateUserAPI/OK (0.12s)
PASS
ok github.com/MaksimDzhangirov/backendBankExample/gapi 0.521s
And it really does! Excellent!
Now, to make sure the test can detect a wrong implementation, let's
pretend that we forget to create a task to send emails. I'm gonna comment
out all the codes inside the AfterCreate
callback function. And just
return nil
here.
AfterCreate: func(user db.User) error {
// taskPayload := &worker.PayloadSendVerifyEmail{
// Username: user.Username,
// }
// opts := []asynq.Option{
// asynq.MaxRetry(10),
// asynq.ProcessIn(10 * time.Second),
// asynq.Queue(worker.QueueCritical),
// }
// return server.taskDistributor.DistributeTaskSendVerifyEmail(ctx, taskPayload, opts...)
return nil
},
This time, if the test is strong enough, it should fail. Let's run it to verify!
=== RUN TestCreateUserAPI
=== RUN TestCreateUserAPI/OK
...
--- FAIL: TestCreateUserAPI (0.18s)
--- FAIL: TestCreateUserAPI/OK (0.11s)
FAIL
FAIL github.com/MaksimDzhangirov/backendBankExample/gapi 0.414s
FAIL
Indeed, the test has failed due to a missing call to the
DistributeTaskSendVerifyEmail
function.
So that's how we implement a unit test for the gRPC API, which involves multiple mock entities interacting with each other. However, the test we've just written is only for the successful case.
Can you try adding some more failure cases to this on your own? It's pretty easy, isn't it?
Let's implement the "internal server error" case. I'm gonna duplicate this "OK" case,
{
name: "OK",
req: &pb.CreateUserRequest{
Username: user.Username,
Password: password,
FullName: user.FullName,
Email: user.Email,
},
buildStubs: func(store *mockdb.MockStore, taskDistributor *mockwk.MockTaskDistributor) {
arg := db.CreateUserTxParams{
CreateUserParams: db.CreateUserParams{
Username: user.Username,
FullName: user.FullName,
Email: user.Email,
},
}
store.EXPECT().
CreateUserTx(gomock.Any(), EqCreateUserTxParams(arg, password, user)).
Times(1).
Return(db.CreateUserTxResult{User: user}, nil)
taskPayload := &worker.PayloadSendVerifyEmail{
Username: user.Username,
}
taskDistributor.EXPECT().
DistributeTaskSendVerifyEmail(gomock.Any(), taskPayload, gomock.Any()).
Times(1).
Return(nil)
},
checkResponse: func(t *testing.T, res *pb.CreateUserResponse, err error) {
require.NoError(t, err)
require.NotNil(t, res)
createdUser := res.GetUser()
require.Equal(t, user.Username, createdUser.Username)
require.Equal(t, user.FullName, createdUser.FullName)
require.Equal(t, user.Email, createdUser.Email)
},
},
and change the name to "InternalError".
The request object will be the same, but in the buildStubs()
function,
we don't need to create a real input argument of the CreateUser
transaction. We can just use gomock.Any()
matcher here, and fake the
return of this call with an empty result, and a not-nil
error, such as
sql.ErrConnDone
. And since the transaction fail in this case, we expect
the DistributeTaskSendVerifyEmail
to be called zero times, no matter
what the input arguments are.
buildStubs: func(store *mockdb.MockStore, taskDistributor *mockwk.MockTaskDistributor) {
store.EXPECT().
CreateUserTx(gomock.Any(), gomock.Any()).
Times(1).
Return(db.CreateUserTxResult{}, sql.ErrConnDone)
taskDistributor.EXPECT().
DistributeTaskSendVerifyEmail(gomock.Any(), gomock.Any(), gomock.Any()).
Times(0)
},
Then, in the checkResponse()
function, we should expect to see a
not-nil
error. There's no need to check the response struct, but we should
check the error code to make sure it's really Internal Error. To do this,
we simply call status.FromError()
, and pass in the err
object. This
"status" is a sub-package of the gRPC framework. And this function will
return a status.Status
object, together with a bool value to tell us
if the conversion is successful or not. I'm gonna store them in the st
and ok
variables. Then require the value of ok
to be true
, and the
value of st.Code()
to be equal to codes.Internal
.
checkResponse: func(t *testing.T, res *pb.CreateUserResponse, err error) {
require.Error(t, err)
st, ok := status.FromError(err)
require.True(t, ok)
require.Equal(t, codes.Internal, st.Code())
},
And that's basically it!
Let's run the tests!
=== RUN TestCreateUserAPI/OK
=== RUN TestCreateUserAPI/InternalError
--- PASS: TestCreateUserAPI (0.24s)
--- PASS: TestCreateUserAPI/OK (0.12s)
--- PASS: TestCreateUserAPI/InternalError (0.05s)
PASS
ok github.com/MaksimDzhangirov/backendBankExample/gapi 0.565s
They all passed! Both the "OK" case and the "InternalError" case.
Awesome!
There are still several more cases we can test, such as: when the username already exists, or when the provided input arguments are invalid. But I leave that as an exercise for you to do on your own.
You can always check my codes on GitHub of you want to see how I implement them.
And that brings us to the end today's lecture about writing unit tests for gRPC services. I hope it was interesting and useful for you. Thanks a lot for watching, happy learning, and see you in the next lecture!