When developing web applications, ensuring the correctness of HTTP functionality is critical. However, since web applications often involve interactions with external dependencies, writing effective testing of HTTP requests and responses becomes challenging. When performing unit testing, we must think about how to solve the external dependency problem of the tested program.
HTTP Server Testing
First, let’s take a look at how to write application test code from the perspective of HTTP Server.
Suppose we have an HTTP Server that provides services to the outside world, the code is as follows:
package main import ( "encoding/json" "fmt" "io" "net/http" "strconv" "/julienschmidt/httprouter" ) type User struct { ID int `json:"id"` Name string `json:"name"` } var users = []User{ {ID: 1, Name: "user1"}, } func CreateUserHandler(w , r *, _ ) { ... } func GetUserHandler(w , r *, ps ) { ... } func setupRouter() * { router := () ("/users", CreateUserHandler) ("/users/:id", GetUserHandler) return router } func main() { router := setupRouter() _ = (":8000", router) }
This service monitor8000
Ports provide two HTTP interfaces:
POST /users
Used to create a user.
GET /users/:id
Used to obtain user information corresponding to the specified ID.
In order to ensure the correctness of the business, we need toCreateUserHandler
andGetUserHandler
These two Handlers perform unit testing.
Let's first look at the user creationCreateUserHandler
How is the function defined:
func CreateUserHandler(w , r *, _ ) { ().Set("Content-Type", "application/json") body, err := () if err != nil { () _, _ = (w, `{"msg":"%s"}`, ()) return } defer func() { _ = () }() u := User{} if err := (body, &u); err != nil { () _, _ = (w, `{"msg":"%s"}`, ()) return } = users[len(users)-1].ID + 1 users = append(users, u) () }
In this Handler, first write the response headerContent-Type: application/json
, means that the response content of the created user is in JSON format.
Then from the request bodyRead the user information submitted by the client.
If the request body is read, the response status code is written400
, indicates that the user information submitted by the client is incorrect and returns a JSON error response.
Next, useJSON decode the request body and fill in the data
User
in the structure.
If JSON decoding fails, the response status code is written500
, indicates that an error occurred on the server and returns a JSON error response.
Finally, save the newly created user information tousers
Slice and write the response status code201
, indicating that the user has been created successfully. Note that according to the RESTful specification, there is no need to return the response body here.
Next, let’s analyze how to write unit test code for this Handler function.
First, let's think about itCreateUserHandler
What are the external dependencies of this function?
From the perspective of function parameters, we need a HTTP response, a used to represent HTTP requests
*
, and a logging the routing parameters of HTTP requests。
Inside the function, global variables are relied onusers
。
Knowing these external dependencies, how can we write unit tests to solve these external dependencies?
The most direct way is to start this Web Server and then use it in the unit test codePOST /users
The interface sends an HTTP request, and then determines the HTTP response result of the program andusers
Data in variables to verifyCreateUserHandler
The correctness of the function.
But this approach obviously goes beyond the scope of unit testing, and is more like doing integration testing. One of the main features of unit testing is to isolate external dependencies and useTest the stand-in
to replace the dependency.
So, we should find a way to make itTest the stand-in
。
Let's start with the simplestusers
Start the variable, find a way to replace it during the testusers
。
users
It is just a slice variable to save user data. We can write a function to replace its content with test data. The code is as follows:
func setupTestUser() func() { defaultUsers := users users = []User{ {ID: 1, Name: "test-user1"}, } return func() { users = defaultUsers } }
setupTestUser
The function is a global variableusers
Reassignment is performed and an anonymous function is returned, which can convertusers
Variable value recovery.
This can be used during testing:
func TestCreateUserHandler(t *) { cleanup := setupTestUser() defer cleanup() ... }
Called at the beginning of the testsetupTestUser
To initialize the test data, usedefer
Statement implementation recovery when the test function exitsusers
data.
Next, we need to construct a HTTP response。
Fortunately, this does not take much effort. Go language official has long thought of this request and provides us withnet/http/httptest
Standard library, this library implements some practical tools specifically used for network testing.
Constructing a test-used HTTP response object can be completed in just one line of code:
w := ()
Get itw
Variables are implementedInterfaces can be directly passed to Handler functions.
To construct a HTTP request*
Object, also very simple:
body := (`{"name": "user2"}`) req := ("POST", "/users", body)
useCreated
req
The variable is exactly*
Type, it contains the request method, path, and request body.
Now, we only have one less to record the HTTP request routing parametersType objects are not constructed.
Is it from
httprouter
This third-party package is provided,httprouter
It is a high-performance HTTP route, compatiblenet/http
Standard library.
It provides(*).ServeHTTP
Method, you can call the corresponding Handler function of the request. That is, you can use the request object*
, automatically calledCreateUserHandler
function.
When calling the Handler function,httprouter
The routing parameters in the request will be saved inand pass it to the Handler, so this object does not need to be constructed manually.
Now, the logic of the unit test function is clear:
func TestCreateUserHandler(t *) { cleanup := setupTestUser() defer cleanup() w := () body := (`{"name": "user2"}`) req := ("POST", "/users", body) router := setupRouter() (w, req) }
According to the previous explanation, we construct the dependencies required for unit testing.
setupRouter()
return*
Object, when the code is executed(w, req)
When it is passedreq
Parameters, automatically call the matching Handler, that is, the function being testedCreateUserHandler
。
Next, what we have to do is judgeCreateUserHandler
Is the result after the function is executed correct?
The complete unit test code is as follows:
package main import ( "encoding/json" "net/http/httptest" "strings" "testing" "/stretchr/testify/assert" ) func TestCreateUserHandler(t *) { cleanup := setupTestUser() defer cleanup() w := () body := (`{"name": "user2"}`) req := ("POST", "/users", body) router := setupRouter() (w, req) (t, 201, ) (t, "application/json", ().Get("Content-Type")) (t, "", ()) (t, 2, len(users)) u2, _ := (users[1]) (t, `{"id":2,"name":"user2"}`, string(u2)) }
A third-party package has been introduced heretestifyUsed to perform assertion operations,Be able to tell whether two objects are equal, which simplifies the code and no longer needs to be used.
if
Let's make a judgment. More Abouttestify
Use of the package, you can view itOfficial Documentation。
We first assert whether the response status code is201
。
Then the response header was assertedContent-Type
Is the field aapplication/json
。
Then determine whether the response content is empty.
Finally, byusers
The value in the value to determine whether the user information is saved correctly.
usego test
To execute the test function:
$ go test -v -run="TestCreateUserHandler" . === RUN TestCreateUserHandler --- PASS: TestCreateUserHandler (0.00s) PASS ok /jianghushinian/blog-go-example/test/http/server 0.544s
The test passed.
At this point, we have successfullyCreateUserHandler
The function writes a unit test.
However, this unit test only covers normal logic.CreateUserHandler
Method return400
and500
The logic of the two status codes has not been covered by the test, so let’s do the homework for you to complete the two scenarios by yourself.
Next, we will get the function of user informationGetUserHandler
Write a unit test.
Let's take a look firstGetUserHandler
Function definition:
func GetUserHandler(w , r *, ps ) { userID, _ := (ps[0].Value) ().Set("Content-Type", "application/json") for _, u := range users { if == userID { user, _ := (u) _, _ = (user) return } } () _, _ = ([]byte(`{"msg":"notfound"}`)) }
The logic of obtaining user information is relatively simple.
First, get the user ID from the path parameters of the HTTP request.
Then determine whether the user information corresponding to this ID exists, and if it exists, return the user information.
If it does not exist, it will be written404
Status code and returnnotfound
information.
With the previous textCreateUserHandler
How to write and test experience in function, I guessGetUserHandler
You are already familiar with the function testing.
Here is the test code I wrote for it:
func TestGetUserHandler(t *) { cleanup := setupTestUser() defer cleanup() type want struct { code int body string } tests := []struct { name string args int want want }{ { name: "get test-user1", args: 1, want: want{ code: 200, body: `{"id":1,"name":"test-user1"}`, }, }, { name: "get user not found", args: 2, want: want{ code: 404, body: `{"msg":"notfound"}`, }, }, } router := setupRouter() for _, tt := range tests { (, func(t *) { req := ("GET", ("/users/%d", ), nil) w := () (w, req) (t, , ) (t, , ()) }) } }
The unit test code that obtains user information is used when the test execution begins.setupTestUser
Functions to initialize test data and usedefer
to complete data recovery.
This time, in order to improve the test coverage, IGetUserHandler
The normal response of the function and the return404
All exception response scenarios for status codes have been tested.
Table testing is used here.
In addition to using table test form, other test logic is related toCreateUserHandler
The unit test logic of the basics is basically the same, so I won’t introduce it more.
usego test
To execute the test function:
$ go test -v -run="TestGetUserHandler" . === RUN TestGetUserHandler === RUN TestGetUserHandler/get_test-user1 === RUN TestGetUserHandler/get_user_not_found --- PASS: TestGetUserHandler (0.00s) --- PASS: TestGetUserHandler/get_test-user1 (0.00s) --- PASS: TestGetUserHandler/get_user_not_found (0.00s) PASS ok /jianghushinian/blog-go-example/test/http/server 0.516s
Both use cases of table testing passed the test.
HTTP Client Testing
Next, let’s take a look at how to write application test code from the perspective of the HTTP Client.
Suppose we have a process monitoring program that can detect whether a process is executing. If the process exits, we will send a message to Feishu Group.
The code is as follows:
package main import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os" "strconv" "syscall" "time" ) func monitor(pid int) (*Result, error) { for { // Check whether the process exists err := (pid, 0) if err != nil { ("Process %d exited\n", pid) webhook := ("WEBHOOK") return sendFeishu(("Process %d exited", pid), webhook) } ("Process %d is running\n", pid) (1 * ) } } func main() { if len() != 2 { ("Usage: ./monitor <pid>") return } pid, err := ([1]) if err != nil { ("Invalid pid: %s\n", [1]) return } result, err := monitor(pid) if err != nil { (err) } (result) }
This program can be passed./monitor <pid>
Form start.
monitor
There is a loop inside the function that will continuously detect whether the corresponding process exists based on the process PID passed in.
If it does not exist, the process has stopped and then calledsendFeishu
Function send message notification to the specified Feishuwebhook
address.
monitor
The function willsendFeishu
The return result of the function is returned as is.
sendFeishu
The function implementation is as follows:
type Message struct { Content struct { Text string `json:"text"` } `json:"content"` MsgType string `json:"msg_type"` } type Result struct { StatusCode int `json:"StatusCode"` StatusMessage string `json:"StatusMessage"` Code int `json:"code"` Data any `json:"data"` Msg string `json:"msg"` } func sendFeishu(content, webhook string) (*Result, error) { msg := Message{ Content: struct { Text string `json:"text"` }{ Text: content, }, MsgType: "text", } body, _ := (msg) resp, err := (webhook, "application/json", (body)) if err != nil { return nil, err } defer func() { _ = () }() result := new(Result) if err := ().Decode(result); err != nil { return nil, err } if != 0 { return nil, ("code: %d, error: %s", , ) } return result, nil }
sendFeishu
Functions can send passed in messages to specifiedwebhook
address.
As for the internal specific logic, we don't need to care about it, just use it as a third-party package, just knowing that it will return in the end.*Result
Object.
Now we need tomonitor
Functions are tested.
We also need to analyze it firstmonitor
What is the external dependency of a function?
firstmonitor
Function parameterspid
It's oneint
Type, not difficult to construct.
monitor
The function is called internallysendFeishu
function, andsendFeishu
The return result is returned as is, sosendFeishu
A function is an external dependency.
Also, pass it tosendFeishu
Functionalwebhook
The address is obtained from the environment variable, which is also considered an external dependency.
So testmonitor
Functions, we need to useTest the stand-in
to resolve these two external dependencies.
The dependency of environment variables is easy to solve, Go providesThe value of environment variables can be set dynamically in the program.
For another dependencysendFeishu
function, it depends onwebhook
The HTTP Server corresponding to the address.
So we need to solve the dependency problem of HTTP Server.
Go standard library for HTTP Servernet/http/httptest
Corresponding tools are also provided.
We can useCreate a test HTTP Server:
func newTestServer() * { return ((func(w , r *) { ().Set("Content-Type", "application/json") switch { case "/success": _, _ = (w, `{"StatusCode":0,"StatusMessage":"success","code":0,"data":{},"msg":"success"}`) case "/error": _, _ = (w, `{"code":19001,"data":{},"msg":"param invalid: incoming webhook access token invalid"}`) } })) }
newTestServer
The function returns an HTTP Server object for testing.
existnewTestServer
Inside the function, two routes are defined/success
and/error
, respectively deal with two cases: successful response and failed response.
With the previous articlesetupTestUser
Like functions, we need to prepare test data when the test program starts executing, that is, start the HTTP Server for this test, clean up the data after the test program is executed, that is, close the HTTP Server.
However, this time we will no longer use itsetupTestUser
Function combinationdefer cleanup()
The way to implement it in another way:
var ts * func TestMain(m *) { ts = newTestServer() () () }
First we define a global variablets
, used to save the HTTP Server object for testing.
Then inTestMain
Called in a functionnewTestServer
The function ists
Variable assignment.
Next execute()
method.
Final call()
Turn off HTTP Server.
TestMain
The function name is not taken at will, but a convention name in Go unit tests, which is equivalent tomain
Function, in usego test
The command will be executed first before executing all test cases.TestMain
function.
existTestMain
Called in a function()
,(*).Run()
The method executes all test cases.
The code will not be executed after all test cases are executed()
。
So, compared withsetupTestUser
The usage of a function that needs to be called once within each test function.TestMain
Functions are more labor-saving. However, this also determines that the two apply differently.TestMain
The function is larger in size and acts on all test cases.setupTestUser
Functions act only on a single test function.
Now, we've solved itmonitor
Dependency problem of function.
The unit tests written for it are as follows:
func Test_monitor(t *) { type args struct { pid int webhook string } tests := []struct { name string args args want *Result wantErr error }{ { name: "process exited and send feishu success", args: args{ pid: 10000000, webhook: + "/success", }, want: &Result{ StatusCode: 0, StatusMessage: "success", Code: 0, Data: make(map[string]interface{}), Msg: "success", }, }, { name: "process exited and send feishu error", args: args{ pid: 20000000, webhook: + "/error", }, wantErr: ("code: 19001, error: param invalid: incoming webhook access token invalid"), }, } for _, tt := range tests { (, func(t *) { _ = ("WEBHOOK", ) got, err := monitor() if err != nil { if == nil || () != () { ("monitor() error = %v, wantErr %v", err, ) return } } if !(got, ) { ("monitor() got = %v, want %v", got, ) } }) } }
The same table test method is used here. There are two test cases, one is used to test the successful sending of Feishu messages after the detected program exits, and the other is used to test the failure to send Feishu messages after the detected program exits.
In test casespid
Set to a large value, which has exceeded the maximum allowed by Linux systempid
value, so the detection result must be that the program has exited.
Because the detected program does not exit,monitor
Functions will be detected loop all the time, and the logic is relatively simple, so there is no test case for this logic.
usego test
To execute the test function:
$ go test -v -run="^Test_monitor$" . === RUN Test_monitor === RUN Test_monitor/process_exited_and_send_feishu_success 2023/07/15 13:27:46 Process 10000000 exited === RUN Test_monitor/process_exited_and_send_feishu_error 2023/07/15 13:27:46 Process 20000000 exited --- PASS: Test_monitor (0.00s) --- PASS: Test_monitor/process_exited_and_send_feishu_success (0.00s) --- PASS: Test_monitor/process_exited_and_send_feishu_error (0.00s) PASS ok /jianghushinian/blog-go-example/test/http/client 0.166s
The test passed.
Above, we passednet/http/httptest
The provided test tool starts a test HTTP Server locally to solve the problem that the tested code depends on external HTTP services.
Sometimes, we don't want to actually start an HTTP Server locally, or we can't do that.
So, we have another solution to solve this problem, which can be usedgock
To simulate HTTP services.
gock
It is a third-party package in the Go community. Although it does not start an HTTP Server locally, it can intercept all mocked HTTP requests. So, we can usegock
InterceptsendFeishu
Function sent towebhook
The request for the address and then return the mock data. In this way, you can use mock to solve the problem of relying on external HTTP services.
usegock
The unit test code written is as follows:
package main import ( "os" "testing" "/h2non/gock" "/stretchr/testify/assert" ) func Test_monitor_by_gock(t *) { defer () // Flush pending mocks after test execution ("http://localhost:8080"). Post("/webhook"). Reply(200). JSON(map[string]interface{}{ "StatusCode": 0, "StatusMessage": "success", "Code": 0, "Data": make(map[string]interface{}), "Msg": "success", }) _ = ("WEBHOOK", "http://localhost:8080/webhook") got, err := monitor(30000000) (t, err) (t, &Result{ StatusCode: 0, StatusMessage: "success", Code: 0, Data: make(map[string]interface{}), Msg: "success", }, got) (t, ()) }
First, at the beginning of the test function, usedefer
Delayed call()
, it can ensure that the pending mock is refreshed after the test is completed, that is, the initial state of the mocked object is restored.
Then, we use()
righthttp://localhost:8080
This URL is mocked, sogock
It intercepts all HTTP requests sent to this address during the test.
()
Support chain calls,.Post("/webhook")
Indicates an intercepting pair/webhook
POST request for this URL.
.Reply(200)
Indicates that for this request, return200
Status code.
.JSON(...)
That is the returned JSON format response content.
Next, we willwebhook
Set the address tohttp://localhost:8080/webhook
, in this way, invokingsendFeishu
The request sent during the function will be intercepted and will return to the previous step.JSON(...)
content.
Then it's calledmonitor
function and assert whether the test result is correct.
Finally, call(t, ())
To verify that there is no pending mock.
usego test
To execute the test function:
$ go test -v -run="^Test_monitor_by_gock$" . === RUN Test_monitor_by_gock 2023/07/15 13:28:22 Process 30000000 exited --- PASS: Test_monitor_by_gock (0.00s) PASS ok /jianghushinian/blog-go-example/test/http/client 0.574s
Unit test execution passes.
Summarize
This article introduces you how to solve the problem of external HTTP dependencies when writing unit tests in Go.
We use the HTTP server and HTTP client respectivelynet/http/httptest
Standard library andgock
A third-party library to implementTest the stand-in
Solve external HTTP dependencies.
And the use was introduced separatelysetupTestUser
+ defer cleanup()
as well asTestMain
Two forms: to prepare and clean up the test. The two act on different granularities and need to be selected according to the test needs.
The complete code example of this article:blog-go-example/test/http at main · jianghushinian/blog-go-example · GitHub
The above is the detailed content of solving the HTTP network dependency problem in Go language unit test. For more information about Go HTTP network dependencies, please follow my other related articles!