Go E2E Tutorial Part 7: Unit Testing with MongoDB Mock (mtest)
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!