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 Interface | DAL | DAO | Cache | Points to Focus |
---|---|---|---|---|
Services | Use GoMock for API calls | Mock DAO interactions | Mock cache access | Logic, boundary exception |
DAL | Use GoMock to simulate DB | Use SQLMock for DB interactions | Use GoMock for DAL methods | Logic, boundary exception |
DAO | - | Use dedicated test database | Mock cache access or bypass | Logic and SQL correctness |
Cache | - | - | Use miniredis or mock Redis | Cache 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, runninggo 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.