Go E2E Tutorial Part 3: Unit Testing with Ginkgo and Gomega for GORM Implementation (SQLite)

haRies Efrika
5 min readJul 30, 2023

--

In this part we will be learning on how to do unit test using Gingko/Gomega, on SQLite implementation served by GORM. The files are hosted here for your reference: https://github.com/hariesef/myschool

Check here for the previous part of tutorial: https://hariesef.medium.com/go-e2e-tutorial-part-2-models-and-implementation-via-gorm-19ac6f9104e6

Introduction of Ginkgo and Gomega

Ginkgo is unit testing framework for Go, while Gomega is matcher/ assertion library, which is best to be paired with Ginkgo.

Why Ginkgo? I suppose you have to see it yourself later on. But for me it is very well structured, and is flexible to define and group testcases. It can handle parallel test runs, or serial. It also supports flaky type test cases, which Ginkgo will run for couple of times when failed. For more information please check here: https://onsi.github.io/ginkgo/#why-ginkgo

Why Gomega? It has enormous of complex matcher functions. Go check here: https://onsi.github.io/gomega/#provided-matchers

Basic structure of unit test with Ginkgo

The following is the empty gingko testcase example:

package student_test

import (
"github.com/golang/mock/gomock"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)


func TestStudent(t *testing.T) {
RegisterFailHandler(Fail)


BeforeSuite(func() {
})

AfterSuite(func() {
})

RunSpecs(t, "Student Suite")
}


var _ = Describe("Student Repo Implementation", func() {

BeforeEach(func() {

})

AfterEach(func() {

})

//serial mode due to I/O testing with DB
Describe("Testing all student repo functions using actual SQLite DB", Serial, func() {
Context("User Creation", func() {
It("tests creating first user", func() {

})

It("tests creating 2nd user", func() {

})


})
}) //end of describe

})

Explanation:

  • The initialization happens in one of go test case (func TestStudent). We only need to create this one.
  • RunSpecs is Ginkgo function that will execute all gingko test cases.
  • BeforeSuite and AfterSuite are routines we expect to run once, at beginning and at end of all test cases run.
  • BeforeEach and AfterEach are routines we expect to run, in every test case.
  • Describe() and Context() are ways to group your test cases.
  • The real test case is declared using It() function.

How to Test Storage Component

Storage implementation like SQL DB, NoSQL DB, Redis, etc, are project and business logic specifics. What the schema and how we are querying them are very customized. To test storage component in unit testing, it is encouraged to actually connect to real storage, i.e. DB installed in localhost or docker. Even if we can categorize them as semi-integration-testing (because using online component), but the fact this kind of testing is cheap and reliable. It also brings confidence to correctness on business logic specifics we apply to schema and queries, compared to create all unit tests that rely solely on database mocking.

So, DB mocking test is useless?

Not really. Whatever we do to do DB unit test online, we would never reach 100% code coverage, that is if you are targeting for perfectness. The rest, edge cases can only be tested using mock, i.e. DB or query failure. So the point is, most of unit tests will be online, and minority for edge cases shall be unit tests against mock DB.

What to Setup (BeforeSuite)

PS: for complete test file content, please refer here: https://github.com/hariesef/myschool/blob/master/internal/storage/sqlite/student/student_repo_impl_test.go

In order to do real SQL DB using GORM, we have to initialize these on BeforeSuite() function:

 BeforeSuite(func() {
db, err = gorm.Open(sqliteDriver.Open("localtest.db"), &gorm.Config{})
if err != nil {
errString := "Cannot access localtest.db: " + err.Error()
panic(errString)
}
studentRepoImpl = sqLiteStudent.NewRepo(db)
db.Migrator().DropTable(&sqLiteStudent.Student{})
db.Migrator().CreateTable(&sqLiteStudent.Student{})


})
  • db is the *gorm.DB that we need in our repo implementation
  • studentRepoImpl is the object constructed by NewRepo(db), we will use it to call/tests our functions, i.e. Create(), Delete(), etc.
  • db.Migrator() is used to clean/ refresh the state of database, by dropping and re-creating the table everytime the unit test runs.

PS: As mentioned in previous chapter, if we change the driver from sqlite to i.e. postgresql, it will still run and connect to other DB instead 😘

dsn := "host=localhost user=gorm password=gorm dbname=testdb port=9920 sslmode=disable TimeZone=Asia/Bangkok"
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})

How to Test

Although this is unit test, but because this is connecting to real DB driver of SQLite, the test is quite straight forward:

   It("tests creating first user", func() {

student, err := studentRepoImpl.Create(context.TODO(), model.StudentCreationParam{Name: "Steve Haries", Gender: "M"})
Expect(err).To((BeNil()))
Expect(student.GetUID()).To(Equal(uint(1)))
Expect(student.GetName()).To(Equal("Steve Haries"))
Expect(student.GetGender()).To(Equal("M"))
Expect(student.GetCreatedAt()).Should(BeNumerically(">", 1600000000))
Expect(student.GetUpdatedAt()).Should(BeNumerically(">", 1600000000))
})

PS: The Expect, Equal(), Should(BeNumerically()) are the examples of Gomega functions.

We just need to verify the return value (created student) to have expected properties or not.

The following are the rest of test cases:

2) Creating 2nd user, the primary key incremented from 1 to 2.

3) Creating 3rd user with similar name of 1st user (contains “teve”).

4) Test Read() function, by reading back 2nd user.

5) Test FindByName() function to get all users with name containing “teve”.

6) Test Delete() function by deleting 2nd user.

7) Test Read() function again, to read the already-deleted 2nd user: not found.

8) Test Delete() again, by deleting the already-deleted user.

PS: These test cases are meant to show the examples on how to test the functions

Executing the test cases

To execute the gingko testcase, can be done directly from IDE, i.e. vscode:

Just click the left-top run package tests.

After test suite execution finished, we can also see the line coverage on vscode:

The blue color is the covered. The red one is not covered by our test yet.

Other way to run the ginkgo test is to by running from command line:

Generating coverage file

Please check the Makefile, there is example in how to run ginkgo suite to cover specific folders, generate coverage file and get the summary:

go-coverage: 
@if [ -f coverage.txt ]; then rm coverage.txt; fi;
@echo ">> running unit test and calculating coverage"
@ginkgo -r -cover -output-dir=. -coverprofile=coverage.txt -covermode=count -coverpkg= \
internal/storage/sqlite/student \
internal/controller/rpc/student \
internal/storage/mongodb/token \
internal/storage/mongodb/user \
internal/services/account
@go tool cover -func=coverage.txt

That’s all for part 3. I hope by now we all can start creating gingko test cases quickly.

Please continue reading the next part of tutorial: https://hariesef.medium.com/go-e2e-tutorial-part-4-twirp-rpc-c7bd8eeae925

Thank you for reading! 🍻 Cheers!

--

--