Go E2E Tutorial Part 7: Unit Testing with MongoDB Mock (mtest)

haRies Efrika
4 min readJul 30, 2023

Previously we have learned using online mongoDB for unit test. Now we will be mocking it. The files are hosted here for your reference: https://github.com/hariesef/myschool

Link to previous chapter: https://hariesef.medium.com/go-e2e-tutorial-part-6-service-creation-with-mongodb-d5422ac6c6ee

In previous chapter we created Account service that uses two repositories: User and Token. We have shown how to unit test User Repo using online DB connection, now for Token Repo, we will use different method, mongo DB mocking, called mtest.

Mtest itself is not just mock provider, it is actually unit test framework just link Ginkgo. I have had hours trying to make mongoDB Mock working inside Ginkgo to no avail. It appears it only works inside mtest.

Before continuing, just for summary, Token Repo has three functions: Create, Find and Delete. Please refer here for full implementation: https://github.com/hariesef/myschool/blob/master/internal/storage/mongodb/token/token_repo_impl.go

Basic Structure of mtest

func TestToken(t *testing.T) {

mt := mtest.New(t, mtest.NewOptions().ClientType(mtest.Mock))
defer mt.Close()

mt.Run("first test case", func(mt *mtest.T) {

})

mt.Run("second test case", func(mt *mtest.T) {

})

In order to assert the result, because we don’t have gomega, we need to import other library, i.e. “github.com/stretchr/testify/assert”

Mocking result with mtest

The usage of mtest to mock Mongo DB query result is relatively easy:

func TestToken(t *testing.T) {

mt := mtest.New(t, mtest.NewOptions().ClientType(mtest.Mock))
defer mt.Close()

mt.Run("test create", func(mt *mtest.T) {

tokenRepoImpl := token.NewRepo(mt.DB)
mt.AddMockResponses(mtest.CreateSuccessResponse())
model, err := tokenRepoImpl.Create(context.TODO(), model.TokenCreationParam{
Token: "123",
UserID: "idharies",
Email: "haries@banget.net",
Expiry: 10002000,
})

assert.Nil(t, err)
assert.Equal(t, len(model.GetID()), 24)
})

Please refer here for full file content: https://github.com/hariesef/myschool/blob/master/internal/storage/mongodb/token/token_repo_impl_test.go

In the snippet, our main target is to execute tokenRepoImpl.Create(). The implementation of Create() looks like this:

func (repo *repoPrivate) Create(ctx context.Context, args model.TokenCreationParam) (model.AuthTokenModel, error) {

newDocument := &AuthToken{
Token: args.Token,
UserID: args.UserID,
Email: args.Email,
Expiry: args.Expiry,
}
res, err := repo.db.Collection(TokenCollectionName).InsertOne(ctx, newDocument)
if err != nil {
return nil, err
}
newDocument.ID = res.InsertedID.(primitive.ObjectID).Hex()
return newDocument, nil
}

It basically calls InsertOne which returns objectID, and error code. The creation of objectID is handled internally by mongoDB hence, not part of result/ output to mock. That’s why we only needed to add one mock line:

mt.AddMockResponses(mtest.CreateSuccessResponse())

— to announce that InsertOne operation will be a success.

To announce error during create, we need to set the following, like noted on 2nd test case:

  mt.AddMockResponses(
bson.D{
{Key: "ok", Value: -1},
},
)

bson primitive.D with “ok” value not equals 1, is considered as mongo error.

When we need to mock specific result (because of mongo FindOne()), the next test case can be example:

 mt.Run("test find a token", func(mt *mtest.T) {

tokenRepoImpl := token.NewRepo(mt.DB)
mt.AddMockResponses(mtest.CreateCursorResponse(1, "foo.bar", mtest.FirstBatch,
bson.D{
{Key: "_id", Value: "abcdefg"},
{Key: "token", Value: "12345"},
{Key: "userId", Value: "idharies"},
{Key: "email", Value: "haries@banget.net"},
{Key: "expiry", Value: 10002000},
}))

model, err := tokenRepoImpl.Find(context.TODO(), "12345")
assert.Nil(t, err)
assert.Equal(t, model.GetID(), "abcdefg")
assert.Equal(t, model.GetToken(), "12345")
assert.Equal(t, model.GetEmail(), "haries@banget.net")
assert.Equal(t, model.GetUserID(), "idharies")
assert.Equal(t, model.GetExpiry(), 10002000)
})

Basically operation of FindOne() needs CreateCursorResponse().

Next test case emulates case when FindOne() does not find a match:

 mt.Run("test find a token not found", func(mt *mtest.T) {

tokenRepoImpl := token.NewRepo(mt.DB)
mt.AddMockResponses(mtest.CreateCursorResponse(0, "foo.bar", mtest.FirstBatch)) //notice the 0 and no bson data in last argument

_, err := tokenRepoImpl.Find(context.TODO(), "12345")
assert.NotNil(t, err)
assert.Equal(t, err.Error(), "mongo: no documents in result")
})

Please notice the 0 number, and after mtest.FirstBatch, there is no following bson.D argument.

Next test case is about mongo operation of DeleteOne() which is called inside tokenRepoImpl.Delete():

 mt.Run("test delete a token", func(mt *mtest.T) {

tokenRepoImpl := token.NewRepo(mt.DB)
mt.AddMockResponses(
bson.D{
{Key: "ok", Value: 1}, {Key: "acknowledged", Value: true}, {Key: "n", Value: 1}})
err := tokenRepoImpl.Delete(context.TODO(), "12345")
assert.Nil(t, err)
})

mt.Run("test delete a token but not found", func(mt *mtest.T) {

tokenRepoImpl := token.NewRepo(mt.DB)
mt.AddMockResponses(
bson.D{
{Key: "ok", Value: 1}, {Key: "acknowledged", Value: true}, {Key: "n", Value: 0}})
err := tokenRepoImpl.Delete(context.TODO(), "12345")
assert.NotNil(t, err)
assert.Equal(t, err.Error(), "the token is not found")
})

DeleteOne has unique response requirement. It needs “acknowledged” to indicate if deletion is successful or not. Also the next key/value “n” represents how many documents were deleted by this operation.

With this flexibility of mtest it is very easy to get 100% of code coverage. Of course in real project most of the test must be done with online DB and use mtest just for edge cases.

Running tool: /usr/local/go/bin/go test -timeout 30s -coverprofile=/tmp/vscode-goyBwaJv/go-code-cover myschool/internal/storage/mongodb/token

ok myschool/internal/storage/mongodb/token 0.004s coverage: 100.0% of statements

Other Operations

As future reference, what the responses needed for other mongo operations:

InsertMany

Similar with InsertOne, only require a success response.

Find

this operation needs cursor response with one or multiple batches and an end of the cursor.

first := mtest.CreateCursorResponse(1, "foo.bar", mtest.FirstBatch, bson.D{// our data})
getMore := mtest.CreateCursorResponse(1, "foo.bar", mtest.NextBatch, bson.D{// our data})
lastCursor := mtest.CreateCursorResponse(0, "foo.bar", mtest.NextBatch)
mt.AddMockResponses(first, getMore, lastCursor)

FindOneAndUpdate

mt.AddMockResponses(bson.D{
{"ok", 1},
{"value", bson.D{// our data }},
})

Upsert

Same as FindOneAndUpdate.

FindOneAndDelete

Same as FindOneAndUpdate.

That’s all for this chapter. Please read also the final part of this series of tutorial: https://hariesef.medium.com/go-e2e-tutorial-part-8-end-miscellaneous-to-do-1a42d5065bb1

Thank you reading! 🍻 Cheers!

--

--