SoFunction
Updated on 2025-03-04

Solve HTTP network dependency issues in Go language unit test

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 monitor8000Ports provide two HTTP interfaces:

POST /usersUsed to create a user.

GET /users/:idUsed to obtain user information corresponding to the specified ID.

In order to ensure the correctness of the business, we need toCreateUserHandlerandGetUserHandlerThese two Handlers perform unit testing.

Let's first look at the user creationCreateUserHandlerHow 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 dataUserin 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 tousersSlice 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 itCreateUserHandlerWhat 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 /usersThe interface sends an HTTP request, and then determines the HTTP response result of the program andusersData in variables to verifyCreateUserHandlerThe 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-into replace the dependency.

So, we should find a way to make itTest the stand-in

Let's start with the simplestusersStart the variable, find a way to replace it during the testusers

usersIt 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
	}
}

setupTestUserThe function is a global variableusersReassignment is performed and an anonymous function is returned, which can convertusersVariable value recovery.

This can be used during testing:

func TestCreateUserHandler(t *) {
	cleanup := setupTestUser()
	defer cleanup()
	...
}

Called at the beginning of the testsetupTestUserTo initialize the test data, usedeferStatement implementation recovery when the test function exitsusersdata.

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/httptestStandard 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 itwVariables 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)

useCreatedreqThe 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 fromhttprouterThis third-party package is provided,httprouterIt is a high-performance HTTP route, compatiblenet/httpStandard library.

It provides(*).ServeHTTPMethod, you can call the corresponding Handler function of the request. That is, you can use the request object*, automatically calledCreateUserHandlerfunction.

When calling the Handler function,httprouterThe 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 passedreqParameters, automatically call the matching Handler, that is, the function being testedCreateUserHandler

Next, what we have to do is judgeCreateUserHandlerIs 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.ifLet's make a judgment. More AbouttestifyUse of the package, you can view itOfficial Documentation

We first assert whether the response status code is201

Then the response header was assertedContent-TypeIs the field aapplication/json

Then determine whether the response content is empty.

Finally, byusersThe value in the value to determine whether the user information is saved correctly.

usego testTo 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 successfullyCreateUserHandlerThe function writes a unit test.

However, this unit test only covers normal logic.CreateUserHandlerMethod return400and500The 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 informationGetUserHandlerWrite a unit test.

Let's take a look firstGetUserHandlerFunction 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 written404Status code and returnnotfoundinformation.

With the previous textCreateUserHandlerHow to write and test experience in function, I guessGetUserHandlerYou 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.setupTestUserFunctions to initialize test data and usedeferto complete data recovery.

This time, in order to improve the test coverage, IGetUserHandlerThe normal response of the function and the return404All 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 toCreateUserHandlerThe unit test logic of the basics is basically the same, so I won’t introduce it more.

usego testTo 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.

monitorThere 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 calledsendFeishuFunction send message notification to the specified Feishuwebhookaddress.

monitorThe function willsendFeishuThe return result of the function is returned as is.

sendFeishuThe 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
}

sendFeishuFunctions can send passed in messages to specifiedwebhookaddress.

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.*ResultObject.

Now we need tomonitorFunctions are tested.

We also need to analyze it firstmonitorWhat is the external dependency of a function?

firstmonitorFunction parameterspidIt's oneintType, not difficult to construct.

monitorThe function is called internallysendFeishufunction, andsendFeishuThe return result is returned as is, sosendFeishuA function is an external dependency.

Also, pass it tosendFeishuFunctionalwebhookThe address is obtained from the environment variable, which is also considered an external dependency.

So testmonitorFunctions, we need to useTest the stand-into 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 dependencysendFeishufunction, it depends onwebhookThe HTTP Server corresponding to the address.

So we need to solve the dependency problem of HTTP Server.

Go standard library for HTTP Servernet/http/httptestCorresponding 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"}`)
		}
	}))
}

newTestServerThe function returns an HTTP Server object for testing.

existnewTestServerInside the function, two routes are defined/successand/error, respectively deal with two cases: successful response and failed response.

With the previous articlesetupTestUserLike 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 itsetupTestUserFunction 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 inTestMainCalled in a functionnewTestServerThe function istsVariable assignment.

Next execute()method.

Final call()Turn off HTTP Server.

TestMainThe function name is not taken at will, but a convention name in Go unit tests, which is equivalent tomainFunction, in usego testThe command will be executed first before executing all test cases.TestMainfunction.

existTestMainCalled in a function()(*).Run()The method executes all test cases.

The code will not be executed after all test cases are executed()

So, compared withsetupTestUserThe usage of a function that needs to be called once within each test function.TestMainFunctions are more labor-saving. However, this also determines that the two apply differently.TestMainThe function is larger in size and acts on all test cases.setupTestUserFunctions act only on a single test function.

Now, we've solved itmonitorDependency 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 casespidSet to a large value, which has exceeded the maximum allowed by Linux systempidvalue, so the detection result must be that the program has exited.

Because the detected program does not exit,monitorFunctions will be detected loop all the time, and the logic is relatively simple, so there is no test case for this logic.

usego testTo 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/httptestThe 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 usedgockTo simulate HTTP services.

gockIt 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 usegockInterceptsendFeishuFunction sent towebhookThe 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.

usegockThe 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, usedeferDelayed 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:8080This URL is mocked, sogockIt intercepts all HTTP requests sent to this address during the test.

()Support chain calls,.Post("/webhook")Indicates an intercepting pair/webhookPOST request for this URL.

.Reply(200)Indicates that for this request, return200Status code.

.JSON(...)That is the returned JSON format response content.

Next, we willwebhookSet the address tohttp://localhost:8080/webhook, in this way, invokingsendFeishuThe request sent during the function will be intercepted and will return to the previous step.JSON(...)content.

Then it's calledmonitorfunction and assert whether the test result is correct.

Finally, call(t, ())To verify that there is no pending mock.

usego testTo 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/httptestStandard library andgockA third-party library to implementTest the stand-inSolve external HTTP dependencies.

And the use was introduced separatelysetupTestUser + defer cleanup()as well asTestMainTwo 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!