Skip to main content

Principles and Guidelines

1.1、F.I.R.S.T Principles 

The unit testing case should follow these principles no matter how you implement it.

Fast

  • All of these including setup, the actual test and tear down should execute really fast (milliseconds) as you may have thousands of tests in your entire project.

Isolated/Independent

  • For any given unit test, for its environment variables or for its setup. It should be independent of everything else should so that it results is not influenced by any other factor.
  • Should follow the 3 A’s of tes__ting: Arrange, Act, Assert.
  • In some literature, it’s also called as Given, when, then.

Repeatable

  • tests should be repeatable and deterministic, their values shouldn’t change based on being run on different environments.
  • Each test should set up its own data and should not depend on any external factors to run its test.

Self-validating

  • you shouldn’t need to check manually, whether the test passed or not.

Thorough

  • should cover all the happy paths
  • try covering all the edge cases, where the author would feel the function would fail.
  • test for illegal arguments and variables.
  • test for security and other issues
  • test for large values, what would a large input do their program.
  • should try to cover every use case scenario and not just aim for 100% code coverage.

1.2、Guidelines

In addition to the above principles, there are also some guidelines for unit testing, which will not be introduced in detail here. You can refer to: Unit Testing Guidelines.

2、Unit Testing Scope

2.1、Scope

Unit testing is mainly to test our business code logic. According to the business characteristics, the following codes need to be unit tested:

  • Services layer
  • DAL layer
  • DAO layer
  • Cache layer
  • Common code or utility functions

If there is only one SQL command or one redis command in the DAO and Cache layers, unit testing not need be provided.

2.2、Unit testing tech for layers

Recommend:

Third-party InterfaceDALDAOCachePoints to Focus
ServicesUse GoMock for API callsMock DAO interactionsMock cache accessLogic, boundary exception
DALUse GoMock to simulate DBUse SQLMock for DB interactionsUse GoMock for DAL methodsLogic, boundary exception
DAO-Use dedicated test databaseMock cache access or bypassLogic and SQL correctness
Cache--Use miniredis or mock RedisCache consistency and logic

Unit testing is performed based on gitlab-runner.

Since the environment can be directly connected to the db of the test environment. Therefore, the db can be directly used for unit testing of the DAO layer (sqlMock can also be used, just keep unify in project ). And note the following:

  • Before each unit test, truncate the table to ensure that the DB for each unit test case is clean.
  • Different jobs of same unit test should be guaranteed serialize processing, no concurrency or parallelism(this maybe can be guaranteed by gitlab-runner).
  • In order to ensure that the unit test job and the test environment do not affect each other, the unit test DB and the test environment should be isolated: create your tables in your own database.

3、Unit Testing Dimension

For each unit test case, the following test dimensions need to be met:

  • Interface function test: the correctness of the function, that is, to ensure that the function can be called normally and the return is correct.
  • Boundary Condition Testing: It is necessary to verify whether the boundary conditions are handled correct, such as parameter is null, maximum, minimum, etc.
  • Independent code testing: Unit testing covers all branch code as much as possible to improve the branch coverage of test code (tool function coverage must reach 100%).

4、Directory structure and naming

The golang language has certain specifications for unit testing, mainly as follows:

  • The unit test file and the file under test must be in the same package(directory).
  • Unit test file names must end with _test.go .
  • Unit test files must correspond one-to-one with the tested files. If there is too much content in a single file, consider splitting it to control the size of the tested file.
  • Methods must start with TestXxx , and the style should be consistent(mixedcaps is recommended).
  • The arguments to the method must be t *testing.T .

You can get more details from link.

5、Unit Testing Coding Specification

Use the official test tool: go test , more details: link.

5.2、Unit Testing Package/Framework

5.2.1、GoConvey

GoConvey  is a test framework for golang  to manage and run test cases, while providing a wealth of assertion functions and supports many web interface features.

We use it to manage and run test cases instead of golang  official testing package.

Here is a usage of GoConvey :

import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)

func StringSliceEqual(a []string, b []string) interface{} {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
if len(a) != len(b) {
return false
}
for i := 0; i < len(a); i++ {
if a[i] != b[i] {
return false
}
}
return true
}

func TestStringSliceEqual(t *testing.T) {
Convey("TestStringSliceEqual", t, func() {
Convey("should return true when a != nil && b != nil", func() {
a := []string{"hello", "goconvey"}
b := []string{"hello", "goconvey"}
So(StringSliceEqual(a, b), ShouldBeTrue)
})

Convey("should return true when a == nil && b == nil", func() {
So(StringSliceEqual(nil, nil), ShouldBeTrue)
})

Convey("should return false when a == nil && b != nil", func() {
a := []string(nil)
b := []string{}
So(StringSliceEqual(a, b), ShouldBeFalse)
})

Convey("should return false when a != nil && b != nil", func() {
a := []string{"hello", "world"}
b := []string{"hello", "goconvey"}
So(StringSliceEqual(a, b), ShouldBeFalse)
})
})
}

StringSliceEqual  is a function used to determine if the two slice is equal, equal to true , otherwise returns false .

Execution results of test case:

=== RUN   TestStringSliceEqual

TestStringSliceEqual
should return true when a != nil && b != nil ✔
should return true when a == nil && b == nil ✔
should return false when a == nil && b != nil ✔
should return false when a != nil && b != nil ✔


4 total assertions

--- PASS: TestStringSliceEqual (0.00s)
PASS
ok infra/alg 0.006s

GoConvey Guidelines

  • When importing the goconvey package, add a dot .  in front to reduce redundant code.
  • The name of the test function must start with Test , and the parameter type must be *testing.T .
  • Each test case must be wrapped with a Convey  function. It is recommended to use the nesting of Convey statements, that is, a business function has a test function, and two levels of Convey statements are nested in the test function. The first level of Convey statement corresponds to the test function, and the second level of Convey statement corresponds to the test case.
  • The third parameter of the Convey statement is customarily implemented in the form of a closure, in which the assertion is completed through the So statement (each case must use the assertion method to assert the result).

5.2.2、GoMock

When the dependencies of the function to be tested are complex, and some dependencies cannot be created directly, such as database connection or file I/O, this scenario is suitable for mock/stub testing.

Simply put, it is to use mock objects to simulate the behavior of dependencies, and gomock is design for it.

Let's look at a simple example, the following is the directory structure:

├── main.go
└── utils
├── mock_utils.go
├── utils.go
└── utils_test.go

Codes in utils/utils.go

package utils

//go:generate mockgen -destination mock_utils.go -package utils -source utils.go
type Utils interface {
Add(a, b int) int
}

func Add(o Utils, a, b int) int {
return o.Add(a, b)
}

Install mockgen by command GO111MODULE=on go get [github.com/golang/mock/[email protected]](http://github.com/golang/mock/[email protected]) .

Run the command go generate ./... in project root directory, it will generate mock_utils.go mock code file in the same directory of test file according to the go:generate code comment.

Coding utils/utils_test.go , for example:

import (
"github.com/golang/mock/gomock"
"testing"
)

func TestAdd(t *testing.T) {
a, b := 2, 1
mockCtl := gomock.NewController(t)
mockUtils := NewMockUtils(mockCtl)
mockUtils.EXPECT().Add(a, b).Return(3)
sum := mockUtils.Add(a, b)

if sum != 3 {
t.Fatalf("Get wrong sum %d", sum)
}
}

Run go test ./... and output is:

$ go test ./...
? mock [no test files]
ok mock/utils 0.386s

GoMock Guidelines

  • Dependencies need be mocked should provide the golang interface.
  • Use the mockgen tool to automatically generate mock code, and the generated mock code is in the same directory as the interface.
  • mockgen is provided as //go:generate  annotation, running go generate ./... in the root directory automatically generates mock code.

5.2.3、MiniRedis

MiniRedis is a go-based redis server that can be used to seamlessly replace the redis server in unit tests.

For example, we rely on redis in our code, and use the go-redis  library as the client, we can conduct simulation tests in the following ways:

import (
"fmt"
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis"
. "github.com/smartystreets/goconvey/convey"
"strconv"
"testing"
"time"
)

func TestRedis(t *testing.T) {
midiRedisServer, err := miniredis.Run()
if err != nil {
t.Fatal(err)
}
port, _ := strconv.Atoi(midiRedisServer.Port())

rdb := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%v:%v", midiRedisServer.Host(), port),
})

if err := rdb.Ping().Err(); err != nil {
t.Fatal(err)
}

Convey("test redis set and get", t, func() {
key := "a"
value := "abc"
err := rdb.Set(key, value, time.Minute).Err()
So(err, ShouldBeNil)

getRes, err := rdb.Get(key).Result()
So(err, ShouldBeNil)
So(getRes, ShouldEqual, value)
})
}

MiniRedis  implements most of the commonly used redis commands, but in the redis server started by MiniRedis , the time is frozen, so time.Sleep cannot be used in test cases related to TTL .

Need to use the following method instead:

Convey("test redis setNx and get", t, func() {
key := "a"
value := "abc"
err := rdb.Set(key, value, time.Second*3).Err()
So(err, ShouldBeNil)
midiRedisServer.FastForward(time.Second * 3)
err = rdb.Get(key).Err()
So(err, ShouldEqual, redis.Nil)
})

Besides, MiniRedis support lua script.

5.2.4、SqlMock

Most projects will rely on MySQL. If the project uses a hierarchical structure Controller/Service/DAO and isolates the operation of MySQL to the DAO layer, then when testing the service layer, you can use gomock to mock the DAO layer.

But since the actual DAO is replaced, the DAO layer cannot be covered when testing the service layer.

The community has given another solution, go-sqlmock can mock the MySQL driver, construct any execution sequence we want and return the result.

The following list comes from the official library Readme document.

Most projects do not use the native *sql.DB object directly, but use orm libraries such as gorm or sqlx . The *sql.DB returned by sqlmock can be converted to the db object that our project depends on by the following method.

db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}

//sqlx
sqlxDB := sqlx.NewDb(db, "mysql")

//gorm
gormDB,_ := gorm.Open("mysql",db)

//gorm 2.0
gormDB, err := gorm.Open(mysql.New(mysql.Config{
Conn: sqlDB,
SkipInitializeWithVersion: true,
}), &gorm.Config{})
if err != nil {
panic(err)
}

6、GitLab-CI Automated Testing

Unit testing need to be integrated to gitlab-ci to run test cases and generate unit test reports automatically during push.