Unit tests are a guarantee of code quality. This series of articles will show you how to do unit testing in Go step by step.
Go’s support for unit testing is fairly friendly. Unit testing is available in the standard package, so you need to understand the basic usage of the standard test package before you start reading in this department.
Now, let’s start with the basic ideas and principles of unit testing and take a look at how to do unit testing based on the standard test packages provided by Go.
The difficulties of unit testing
1. Master unit test granularity
Unit testing granularity is a major headache, especially for beginners to unit testing. If the test granularity is too fine, it will cost a lot of development and maintenance time. Every time you change a method, you have to change its corresponding test method. It’s a nightmare when it comes to refactoring (because all your unit tests have to be written again…). . If the granularity of unit test is too coarse and a test method tests n more methods, the unit test will be very bloated, separated from the original intention of unit test, and it is easy to write unit test as integration test.
2. Mock out external dependencies (Stub technique)
Unit tests generally do not allow any external dependencies (file dependencies, network dependencies, database dependencies, etc.). We do not connect to databases, call apis, etc. These external dependencies need to be mock/stub when performing tests. During testing, we used simulated objects to simulate various behaviors under real dependencies. Using mocks/stubs to simulate the real behavior of your system can be a stumbling block in unit testing. Don’t worry, this article will show you how to use mocks/stubs in Go for unit testing with examples.
Sometimes simulation is effective and convenient. But beware of excessive mocks/stubs, which can lead to unit tests that focus on mock objects rather than the actual system.
Costs and Benefits
While benefiting from the benefits of unit testing, it inevitably increases the amount of code and maintenance costs (unit testing code is also maintained). The cost/value quadrant chart below clearly illustrates the relationship between unit test cost and value in different natures of the system.
1. Simple code with few dependencies (bottom left)
For external dependencies less, the code is simple code. Naturally, its cost and value are relatively low. Take the Errors package in Go’s official library as an example. The entire package consists of two methods, New() and Error(). There are no external dependencies and the code is very simple, so it is quite easy to unit test.
2. More dependent but very simple code (bottom right)
The more dependencies you have, the more mocks and stubs you have, and the higher the cost of unit testing. But the code is so simple (as in the errors package example above) that the cost of writing unit tests now outweighs their value and it’s better not to write them at all.
3. Rely on less complex code (top left)
Code like this is most valuable for writing unit tests. For example, some independent complex algorithms (bank interest calculation, insurance rate calculation, TCP protocol parsing, etc.), such as this kind of code has few external dependencies, but it is prone to error, and without unit testing, there is little guarantee of code quality.
4. Too much dependence and complexity (upper right)
This kind of code is clearly a unit testing nightmare. Write unit tests, they’re expensive; Don’t write unit tests. It’s too risky. We try to design code like this into two parts: 1. Handle complex logic 2. Deal with the dependencies and then unit test part 1
The original reference: blog.stevensanderson.com/2009/11/04/…
Take the first step in unit testing
1. Identify dependencies and abstract them into interfaces
Identify external dependencies in your system. Generally speaking, the most common dependencies we encounter are the following:
- Network dependencies – Function execution depends on network requests, such as third-party HTTP-APIS, RPC services, message queues, and so on
- Database dependency
- I/O dependencies (files)
Of course, it is also possible to rely on functional modules that have not yet been developed. But the processing is pretty much the same — abstracted into interfaces and simulated through mocks and stubs.
2. Identify what needs to be measured
By the time we start hammering production code, we must have gone through the initial design and understood the external dependencies in the system and the complex parts of the business that should be prioritized for writing unit tests. As you write each method/structure, do you think the method/structure needs to be tested? How to test? In addition to the cost/value quadrants above for answers to what methods/constructs need to be tested and what not to do, you can also refer to the following answers to questions about how fine-grained unit testing should be:
My boss pays me for my code, not for my tests, so my value on this is that as little testing as possible, so little that you have a certain level of confidence in the quality of your code (which I think should be higher than industry standards, but which could also be arrogance). If I didn’t make this typical mistake in my coding career (e.g., setting the wrong value in a constructor), I wouldn’t test it. I tend to test for errors that make sense, so I tend to be extremely careful with more complex conditional logic. When working on a team, I’m very careful to test code that makes the team prone to errors. Coolshell. Cn/articles / 82…
How to Mock and Stub
Mocks and stubs are two common techniques used to simulate external dependency behavior during testing. Mocks and stubs not only free your test environment from external dependencies, but also simulate abnormal behavior, such as database service unavailability, no access to files, and so on.
Difference between a Mock and a Stub
In the Go language, mocks and stubs can be described as follows:
- Mock: Creates a structure in the test package that satisfies the interface of some external dependency
interface{}
- Stub: Creates a mock method in the test package that replaces the method in the generated code
It’s still a little abstract, so let’s do an example.
The Mock sample
Mock: Create a structure in the test package that satisfies some external dependency interface{}
Production Code:
// Auth. go // Suppose we have an authentication interface that relies on HTTP requeststype AuthService interface{
Login(username string,password string) (token string,e error)
Logout(token string) error
}
Copy the code
The mock code:
//auth_test.go
type authService struct {}
func (auth *authService) Login (username string,password string) (string,error){
return "token", nil
}
func (auth *authService) Logout(token string) error{
return nil
}
Copy the code
Here we implement the authService interface with authService so that testing Login and Logout no longer relies on network requests. And we can also simulate some error situations to test:
//auth_test.go // Failed to simulate logintypeAuthLoginErr struct {auth AuthService; Func (auth *authLoginErr) Login (username string,password string) (string,error) {return "", errors.New("Username and password error"} // Simulate API server outagetype authUnavailableErr struct {
}
func (auth *authUnavailableErr) Login (username string,password string) (string,error) {
return "", errors.New("API service unavailable")
}
func (auth *authUnavailableErr) Logout(token string) error{
return errors.New("API service unavailable")}Copy the code
Stub sample
Stub: Creates a mock method in the test package that replaces the method in the generated code. Here’s an example from the Go Language Bible (11.2.3) :
Var notifyUser = func(username, MSG string) {auth := smtp.plainauth ()"", sender, password, hostname)
err := smtp.SendMail(hostname+": 587", auth, sender,
[]string{username}, []byte(msg))
iferr ! = nil { log.Printf("smtp.SendEmail(%s) failed: %s", username, Func CheckQuota(username string) {Used := bytesInUse(username) const quota = 1000000000 // 1GB percent := 100 * used / quotaif percent < 90 {
return // OK
}
msg := fmt.Sprintf(template, used, percent) notifyUser(username, MSG)Copy the code
Obviously, while running unit tests, we definitely don’t actually email users. Stub tests are used in the book:
//storage_test.go func TestCheckQuotaNotifiesUser(t *testing.T) { var notifiedUser, NotifiedMsg string notifyUser = func(user, MSG string) {//<- NotifiedUser, notifiedMsg = user, MSG} //... simulate a 980MB-used condition... const user ="[email protected]"
CheckQuota(user)
if notifiedUser == "" && notifiedMsg == "" {
t.Fatalf("notifyUser not called")}ifnotifiedUser ! = user { t.Errorf("wrong user (%s) notified, want %s",
notifiedUser, user)
}
const wantSubstring = "98% of your quota"
if! strings.Contains(notifiedMsg, wantSubstring) { t.Errorf("unexpected notification message <<%s>>, "+
"want substring %q", notifiedMsg, wantSubstring)
}
}
Copy the code
As you can see, using stubs in Go would be intrusive, and the production code would have to be designed in such a way that it could be replaced with stub methods. The result of the above example is that a global variable notifyUser holds methods with external dependencies for testing purposes. However, in the Go language, which does not encourage the use of global variables, this is clearly inappropriate. Therefore, this Stub approach is not recommended.
Mock is combined with stubs
Since stubbing is not recommended, should it be discarded during Go testing? I thought so until I read this translation of Golang standard package layout, and while it talks about package layout, the test examples are well worth studying.
Myapp. Go package myapptypeUser struct {ID int Name string Address Addresstype UserService interface {
User(id int) (*User, error)
Users() ([]*User, error)
CreateUser(u *User) error
DeleteUser(id int) error
}
Copy the code
Regular Mock:
// Test code myapp_test.gotype userService struct{
}
func (u* userService) User(id int) (*User,error) {
return &User{Id:1,Name:"name",Address:"address"},nil } //.. Omit other implementation methods // Simulate user does not existtype userNotFound struct {
u UserService
}
func (u* userNotFound) User(id int) (*User,error) {
return nil,errors.New("not found"} // Other...Copy the code
In general, there are very few variables inside a mock structure, and the most politically correct way to simulate each scenario (such as one where the user doesn’t exist) is to create a new mock structure. This has two advantages:
- Mock structures are very simple, require no extra setup, and are error-proof.
- The mock structure has a single responsibility, which makes the test code self-explanatory and more readable.
But in the article just mentioned, here’s what he did:
// Test code // UserService represents a myapp.userService. The mock implementationtypeUserService struct { UserFn func(id int) (*myapp.User, error) UserInvoked bool UsersFn func() ([]*myapp.User, Error) UsersInvoked bool // Other interface method complemented.. } // User invokes the mock implementation and marks the method as invoked func (S *UserService) User(ID int) (*myapp.User, error) {s.invocation =true
return s.UserFn(id)
}
Copy the code
This not only implements the interface, but also by placing methods in the structure that match the signature of the interface method function (UserFnUsersFn…). And whether XxxInvoked invoked the identifier to track the invocation of the method. This essentially combines the mock with the stub by placing function variables inside the mock object that can be replaced by the test function (UserFn UsersFn…). . We can manually change function implementations in our test functions as needed for testing.
Func TestUserNotFound(t * testing.t) {userNotFound := &UserService{} userNotFound.UserFn = func(id int) (*myapp.User, error) {//<-- sets the expected return result of UserFnreturn nil,errors.New("not found"} // Follow up business test code...if! userNotFound.UserInvoked { t.Fatal("User() method not called"Mock func TestUserNotFound(t * testing.t) {userNotFound := &userNotFound{}Copy the code
By combining mocks with stubs, you can not only dynamically change the implementation in the test method, but also track method calls. In the above example, we only track whether the method is called or not. In practice, we can also track the number of times the method is called, and even the order in which the method is called, if necessary:
typeUserService struct { UserFn func(id int) (*myapp.User, Error) UserInvokedTime int //<-- Trace invoked times UsersFn func() ([]* myapp.user, Error) UsersInvoked bool // Other interface method complemented.. FnCallStack []string //< function name slice, Trace the invocation order} // User invokes the mock implementation and marks this method as invoked func (S *UserService) User(ID int) (*myapp.User, error) {s.invocation =trueS.usserinvokedtime ++ //<-- call times s.ncallStack = append(s.ncallstack,"User") // Call orderreturn s.UserFn(id)
}
Copy the code
At the same time, however, we will see our mock structures become more complex, increasing maintenance costs. Both mock styles have their advantages, but remember that software engineering doesn’t have silver bullets, so use the right method for the right scenario. But in general, the combination of mock and stub is a good way to test, especially if you need to track whether, how often, and in what order a function is called. Here’s an example:
// Cache dependencytypeCache interface{Get(id int) interface{} Put(id int,obj interface{}) // Database dependencytypeUserRepository interface{ //.... } //User structuretype User struct {
//...
}
//userservice
typeUserService interface{cache cache repository UserRepository} func (u *UserService) Get(id int) *User {// } func is not found in repositorymain() {userService := NewUserService(XXX) // inject some external dependencies user := userservice.get (2) // Get user with id = 2}Copy the code
Now test the behavior of the userService.get (id) method:
- Is the database still queried after a Cache hit? (Should stop checking)
- Is the database searched when the Cache does not match?
- .
This kind of test can be handy with a combination of mock+ stubs, but as an exercise, try implementing it yourself.
Use the dependency injection delivery interface
The interface needs to be injected into the structure as dependency injection to provide alternative interface implementations for testing. According to? Let’s look at a negative example. This is untestable:
type A interface {
Fun1()
}
func (f *Foo) Bar() { a := NewInstanceOfA(... // Generate some implementation of interface A. fun1 () // call interface method}Copy the code
When you mock out the A interface, there is no way to replace the mock object in the Bar() method. Here’s how to write it correctly:
type A interface {
Fun1()
}
typeFoo struct {a a // a interface} func (f *Foo)Bar() {f.a.fun1 () // call interface method} // NewFoo, via constructor, inject A interface into func NewFoo(A A) *Foo {return &Foo{a: A}
}
Copy the code
In this example we use the constructor parameter method to do dependency injection (you can also use setters to do it). At test time, we can pass our mock object to *Foo via the NewFoo() method.
Usually we do dependency injection in Main.go
To summarize
It’s a long list, but a brief summary of the key steps of unit testing:
- Identify dependencies (network, files, unfinished functionality, etc.)
- Abstracting dependencies into interfaces
- in
main.go
To inject interfaces using dependency injection
Now that we have a basic understanding of unit testing, if you can complete the exercises in this article, congratulations, you have understood how to do unit testing and have taken the first step. In the next article, we will introduce the Gomock testing framework to improve our testing efficiency.