Go’s standard library provides httptest for handler and client checks. Choose the boundary first: call a handler directly to inspect its response, or start a local server to exercise a real HTTP client. Neither choice automatically replaces the handler’s database or other dependencies.
The examples below test a greeting handler, an HTTP client, and an HTTPS client. Each test controls the relevant inputs and asserts the returned result without calling a public API.
What Is httptest?
The httptest package provides request builders, response recorders, and local HTTP/HTTPS servers. A recorder lets you invoke a handler without opening a listener. A server lets your real client exercise transport, routing, headers, and response parsing against a controlled handler.
How httptest Works
Start with the handler interface and the request and response values it receives.
HTTP Handler Package
The standard library http package net/http has a client and a server interface. The server is basically a group of handlers. When you send a request to an endpoint or a server path, the handler intercepts this request, and based on the request, it returns a specific response.
Handler Interface
Below is a simple interface of a handler (http.Handler):
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
The ServeHTTP takes in ResponseWriter and Request. The Request object holds the incoming HTTP request from the client, and the ResponseWriter can be used to create a response.
Example Handler
Here is an example of a simple handler:
// With no ServeHTTP
func handler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, World!"))
}
// With ServeHTTP
type home struct {}
func (h *home) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, World!"))
}
The first method is more common than the second. It’s less confusing and clearer to declare your handler as a function. When the handler takes a HTTP request, you can compose a HTTP response after carrying out whatever needs to be done using the ResponseWriter interface. This process can either be reading from the database, third-party services, static server data, or processing a file. In the above code, the line w.Write([]byte(“Hello, World!”)) sends the response.
Test Golang HTTP Handlers with httptest
You can unit test a handler directly. Construct its request with httptest.NewRequest and a ResponseWriter with httptest.NewRecorder, then invoke the handler and assert the result. This first example prints the status; save it in a package main file if running it separately.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func handler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, World!"))
}
func main() {
req := httptest.NewRequest("GET", "/greet", nil)
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
fmt.Println(resp.StatusCode)
}
Line by line breakdown
The line below allows you to create a new mock request for your server. You are passing it to your handler as your request object. Details like the request method or query parameters are specified here:
req := httptest.NewRequest("GET", "/greet", nil)
The next line is your ResponseWriter interface and records all the responses from the handler:
w := httptest.NewRecorder()
You can now use these variables to call the handler function:
handler(w, req)
Once the request has been fulfilled, these lines let you see the results and the HTTP response details of the request:
resp := w.Result()
fmt.Println(resp.StatusCode)
Full Example
Let’s now build a complete example. First, create a new Go project and create a file named server.go:
package main
import (
"fmt"
"log"
"net/http"
"net/url"
)
func RequestHandler(w http.ResponseWriter, r *http.Request) {
query, err := url.ParseQuery(r.URL.RawQuery)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "Bad request")
return
}
name := query.Get("name")
if len(name) == 0 {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "You must supply a name")
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "Hello %s", name)
}
func main() {
http.HandleFunc("/greet", RequestHandler)
log.Fatal(http.ListenAndServe(":3030", nil))
}
The handler reads the name query parameter and returns either a greeting or a bad-request response. Its main function is an optional manual demonstration; the tests below invoke the handler directly and need no external service.
Testing our example
To test this server, create a file server_test.go and add the following code:
package main
import (
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestRequestHandler(t *testing.T) {
cases := []struct {
name, target, body string
status int
}{
{"greeting", "/greet?name=john", "Hello john", http.StatusOK},
{"missing name", "/greet", "You must supply a name", http.StatusBadRequest},
{"invalid query", "/greet?name=%zz", "Bad request", http.StatusBadRequest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.target, nil)
recorder := httptest.NewRecorder()
RequestHandler(recorder, req)
res := recorder.Result()
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
if res.StatusCode != tc.status || string(body) != tc.body {
t.Fatalf("got status=%d body=%q, want status=%d body=%q",
res.StatusCode, body, tc.status, tc.body)
}
})
}
}
Each table row supplies a request target, expected status, and expected body. NewRequest constructs the test request, and the recorder captures the handler’s response. Run go test to check all three cases.
Test Golang HTTP Clients with httptest
Use a local server to exercise an HTTP client against controlled responses. The test can vary status, headers, and body without depending on a third-party API. Configure the client’s base URL and HTTP transport so both plain HTTP and TLS scenarios can use the same application code.
Testing clients with NewServer
NewServer starts a real HTTP listener on a local port. Its handler supplies controlled responses while your client uses the real HTTP stack. This tests the client/server boundary in scope, not the actual third-party provider or the entire deployed application.
Installing dependencies
Create a new Go project and install the required dependencies:
mkdir httptest-client
cd httptest-client
go mod init example/user/httptest
Create a file called client.go. Here, you’ll define the client:
package main
import (
"fmt"
"io"
"net/http"
"time"
)
type Client struct {
url string
httpClient *http.Client
}
func NewClient(url string, httpClient *http.Client) Client {
return Client{url: url, httpClient: httpClient}
}
func (c Client) MakeRequest() (string, error) {
res, err := c.httpClient.Get(c.url + "/users")
if err != nil {
return "", fmt.Errorf("make request: %w", err)
}
defer res.Body.Close()
out, err := io.ReadAll(res.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
return string(out), nil
}
func main() {
client := NewClient("https://gorest.co.in/public/v2", &http.Client{Timeout: 5 * time.Second})
resp, err := client.MakeRequest()
if err != nil {
panic(err)
}
fmt.Println(resp)
}
The optional main function calls a public example API. The test does not run main: it injects the local server URL and client. Save the following as client_test.go in the client project.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestClientResponse(t *testing.T) {
expected := `{"data":"dummy"}`
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, expected)
}))
defer svr.Close()
c := NewClient(svr.URL, svr.Client())
res, err := c.MakeRequest()
if err != nil {
t.Fatalf("expected err to be nil, got %v", err)
}
res = strings.TrimSpace(res)
if res != expected {
t.Errorf("expected res to be %s, got %s", expected, res)
}
}
Explanation
Here, the expected variable holds the expected result that the client must return. In this case, the client returns whatever it gets from the new server intact, but you might have some processing done before returning the data. The next line creates the mock server with a handler that returns the expected result:
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, expected) }))
You can change the HTTP response value here to test the client against different responses. Remember to change the expected value as well. Finally, a client is constructed against this mock server, and the request is made:
c := NewClient(svr.URL, svr.Client())
res, err := c.MakeRequest()
Run the test with go test to see the results.
Test the HTTPS path
Append this function to client_test.go. NewTLSServer starts an HTTPS server; its Client trusts that server’s test certificate. Inject that client instead of disabling certificate verification globally.
func TestClientTLS(t *testing.T) {
expected := "secure response"
svr := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS == nil || r.URL.Path != "/users" {
t.Errorf("expected HTTPS /users")
}
fmt.Fprint(w, expected)
}))
defer svr.Close()
client := NewClient(svr.URL, svr.Client())
got, err := client.MakeRequest()
if err != nil || got != expected {
t.Fatalf("got %q, err %v", got, err)
}
}
This verifies the selected HTTPS client path. It does not exercise production trust roots, certificate renewal, mutual TLS, or a reverse proxy. Configure those separately when they are part of the requirement.
Writing Effective Tests
Keep each test focused on an observable handler or client behavior:
- Keep Tests Concise and Focused: Each test should focus on a specific piece of functionality. This makes it easier to identify issues and understand what the test is verifying.
- Use Descriptive Names: Descriptive test names help you quickly understand what each test is checking. This is especially important when you have a large suite of tests.
- Mock Requests and Responses: Use the httptest package to mock requests and responses. This allows you to test your code in isolation, without relying on external services. For example, you can use httptest.NewRequest to create a mock request and httptest.NewRecorder to capture the response.
- Test Happy Paths and Error Scenarios: Ensure that your tests cover both successful outcomes and error conditions. This helps verify that your code handles unexpected situations correctly, such as invalid input or server errors.
These checks describe the behavior exercised by the test. They do not establish that excluded dependencies or deployment settings work.
Best Practices
Populate a Context for Tests
For a handler unit test, attach only the context values the handler needs using request.WithContext. Use a package-specific key type to avoid collisions, as described in context.WithValue. Setting a user value directly skips authentication middleware, so keep a separate test that exercises the real authentication path.
Don’t Forget to Mock Database Calls
Inject a small repository interface when the test needs to isolate handler decisions from database behavior. Configure success, not-found, and failure outcomes with a fake or GoMock. Keep a disposable real database for SQL, constraints, transactions, and migration checks. A response recorder and a local test server both execute the handler’s actual dependency calls unless you replace or reroute them.
Conclusion
Choose a recorder for direct handler assertions and a local server for real client exchanges. Inject dependencies and test credentials deliberately, then keep integration checks for the behavior those doubles exclude. Recorded service mocks can complement these tests when maintaining representative response fixtures becomes the next problem.
Frequently asked questions
When should I use ResponseRecorder versus NewServer?
Use ResponseRecorder to call a handler directly and inspect its status, headers, and body without opening a listener. Use NewServer when the test needs an actual client/server HTTP exchange, including transport or routing through the handler you register. Both execute the handler’s configured dependencies; neither automatically replaces its database calls or proves the production deployment works.
When is NewTLSServer appropriate?
Use NewTLSServer for a controlled HTTPS client path and inject the server’s Client, which trusts its test certificate. Keep production trust settings intact. This does not validate production certificate renewal, mutual TLS, or proxy configuration. If HTTP/2 is the subject, configure NewUnstartedServer with EnableHTTP2 before StartTLS rather than assuming that enabling TLS alone selects HTTP/2.
How do I isolate database calls behind an HTTP handler?
Inject a narrow repository interface and supply a fake or generated mock for handler-level decisions. Exercise success, not-found, and database-error responses while checking the expected repository interaction. Run separate integration tests against a disposable database for SQL, constraints, transactions, and migrations. Replacing the HTTP peer alone does not isolate a database used by the application.
Sources: ResponseRecorder and local test servers, HTTP handlers and clients, GoMock controller and expectation API, Real database transactions.