Testing a Go and chi RESTful API - Route Handlers and Middleware (Part 2)

Disclaimer - If you are unfamiliar with writing a simple unit test for a route handler of a Go and chi RESTful API, then please read this blog post before proceeding on. Go's testing library package provides many utilities for automating the testing of Go code. To write robust tests for Go code, you must already understand how to write a basic Go test suite that contains several TestXxx functions.

Writing tests for code, especially within the context of test-driven development (TDD), prioritizes the code's correctness over the code's flexibility to adapt to new/updated requirements. The guarantee of less unexpected regressions offsets the upfront cost of spending more time to write tests alongside application code. In a fast-paced, high-pressure environment, it can be difficult to convince other team members and stakeholders of the value in testing code when time is an extremely limited resource.

Another factor that must be considered is the amount of code covered by the tests. If the tests cover only a small percentage of the application code (or a small subset of use cases), then the benefits of having these tests probably won't outweigh the benefits of adding new features or improving existing features. Plus, anytime you decide to refactor the application code, you will also have to update the corresponding tests to reflect these changes. When time is so valuable, the time spent on writing tests could have been spent elsewhere. Therefore, to fully benefit from tests, you must write enough tests such that they cover a large percentage of the application code.

If a RESTful API exposes multiple endpoints, then testing a single route handler won't bring much value for the time spent writing it. Testing RESTful APIs built with Go and chi requires testing not only all of the route handlers, but also, the chi/custom middleware handlers.

Let's write tests for...

  • The remaining routes registered on the chi router.

  • The chi Logger middleware.

  • The custom middleware for extracting the URL parameter id.

Installation and Setup#

Clone a copy of the Go and chi RESTful API (with a basic test for a single endpoint) from GitHub to you machine:

This RESTful API specifies five endpoints for performing operations on posts:

  • GET /posts - Retrieve a list of posts.

  • POST /posts - Creates a post.

  • GET /posts/{id} - Retrieve a single post identified by its id.

  • PUT /posts/{id} - Update a single post identified by its id.

  • DELETE /posts/{id} - Delete a single post identified by its id.

If you would like to learn how to build this RESTful API, then please visit this blog post.

Run the following command to install the project's dependencies:

Note: If you run into installation issues, then verify that the version of Go running on your machine is v1.16.

There is a single test within the routes/posts_test.go file: TestGetPostsHandler. It tests the route handler for the GET /posts endpoint, and it mocks out the GetPosts function called by the route handler to avoid sending a network request to the JSONPlaceholder API when executing tests.

Writing a Unit Test for a POST Request's Route Handler#

Compared to testing route handlers for GET requests, testing route handlers for other HTTP methods (e.g., POST and PUT) involves slightly more code to test requests that carry data in the form of an optional request body:

  1. Create dummy data to send as the payload of a POST/PUT request.

  2. Encode the dummy data (a Go struct) to JSON via the json.Marshal method.

  3. Pass this encoded data as the third argument, which is an optional request body, to the http.NewRequest method. This argument must be of type io.Reader, which is an interface that wraps a Read method: go type Reader interface { Read(p []byte) (n int, err error) } To match this argument type, the encoded data must first be passed to the byte.newBuffer method to obtain its byte slice equivalent.

  4. Set the request header Content-Type to application/json via the Header.Set method.

Let's write a test for the POST /posts endpoint.

Before starting, we must first review the route handler for this endpoint (Create) in the routes/posts.go file.

(routes/posts.go)

To test the route handler Create, we must first mock out the CreatePost function to avoid sending a network request to the JSONPlaceholder API when executing tests. CreatePost accepts a request body and returns an HTTP response and error (if encountered). Therefore, the mock function must follow the same function signature as CreatePost, like so:

CreatePost is defined on type JsonPlaceholderMock.

The request body must contain the following information:

  • The ID of the user creating the post.

  • A title for the post.

  • A body of text for the post.

Since this request body (JSON data) must be passed as type io.ReadCloser to CreatePost, it must be read into a buffer, converted into a byte slice and then decoded into a Go struct so the data can be accessed normally.

When a POST /posts request is sent to the JSONPlaceholder API, it returns a response that contains the newly created post. This post contains the exact same information as the request body with an additional Id field. This Id field is set to 101 to imply that a new post was added since there is a total of 100 posts, and each post has an Id field set to 1, 2, 3, etc., up to 100. The JSONPlaceholder API doesn't actually create this new resource, but rather, fakes it. So anytime you send a request to POST /posts, the response returned will have a newly created post with an Id field set to 101. In our mock function, let's create this dummy data to send back in the response.

Encode this struct to JSON via the json.Marshal method and return it within the body of a HTTP response. This HTTP response should return with a 200 status code to indicate a successful request.

Note: This HTTP response returned must adhere to the http.Response struct, which accepts an integer value for its StatusCode field and a value of type io.ReadCloser for its Body field. The ioutil.NopCloser method returns a ReadCloser that wraps the Reader (in this case, bytes.NewBuffer(respBody), which prepares a buffer to read respBody) with a no-op Close method, which allows the Reader to adhere to the ReadCloser interface.

Putting it altogether...

With the mock function now implemented, let's write the test for the route handler Create for POST /posts. Start by naming the unit test TestCreatePostHandler, like so...

Then, write this test similar to the TestGetPostsHandler test, but with several adjustments to account for it testing a POST route handler:

  1. Set the CreatePosts package-scoped variable to the mock function (&JsonPlaceholderMock{}).CreatePost.

  1. Initialize postWithoutId to a PostWithoutId struct. This creates the dummy data that will be sent as a payload of a POST request.

  1. Encode the postWithoutId struct to JSON via the json.Marshal method, which converts it to a byte slice of the encoded data. To preview this encoding as a string, cast it as a string and display that output: fmt.Println(string(reqBody)).

  1. Create a new POST request to send to /posts via the http package's NewRequest method. Include the encoded dummy data as the body of this request. Because NewRequest accepts a body of type io.Reader, we must convert reqBody, which is currently a byte slice, to a type that is compatible with the Reader interface, which implements a single method, Read. Fortunately, the bytes.NewBuffer method creates and initializes a new Buffer using its byte slice argument as its initial contents. Besides being a variable-sized buffer of bytes, the Buffer type also has Read and Write methods, which matches the methods of the io.Reader interface. Therefore, to pass reqBody to http.NewRequest as the POST request's body, we must first pass it to bytes.NewBuffer, and then pass the returned buffer as the request's body. This request will be passed to the route handler.

  1. Once the request is successfully created, set its header Content-Type to application/json, which specifies the format of the body as JSON.

  1. Like TestGetPostsHandler, set up a response recorder via the httptest.NewRecorder method, which implements http.ResponseWriter and captures all of the ResponseWriter's mutations. Because it implements http.ResponseWriter, the response recorder can be passed as an argument wherever the argument type is http.ResponseWriter. For example, the ServeHTTP method of the Handler type calls a route handler function with a request and writes the resulting response to a ResponseWriter, which in this case, will be substituted with a response recorder. Then, adapt the PostsResource{}.Create method, which is currently an ordinary function, to an HTTP route handler via the http.HandlerFunc method. Call this handler function with the previously created POST request and response recorder to simulate a network request to the POST /posts endpoint.

  1. Decode the body of the response (a JSON-encoded value) and store the result at the memory address of the post variable. Passing a pointer address (&) allows a function to modify the value of the variable being pointed at by first dereferencing the pointer from its memory address, followed by changing its value at the referenced address. After extracting the response body, check if the body's Id matches the expected ID value of 101. If it does, then the test will pass. Otherwise, the test will fail and print an error message with the expected and actual values.

Putting it altogether...

Testing chi Logger#

To test the chi logger middleware handler (middleware.Logger), we must first understand the implementation details of this handler function:

(go-chi/chi/middleware/logger.go)

The middleware.Logger function only calls a package-scoped function, DefaultLogger, which logs information for each incoming request.

(go-chi/chi/middleware/logger.go)

This package-scoped function accepts a handler function and returns a handler function. Therefore, we can write a test that involves passing a simple route handler to middleware.DefaultLogger and calling middleware.DefaultLogger's ServeHTTP method with a response recorder and created request:

Next Steps#

Try adding more endpoints and writing tests for those endpoints' route handlers.

Sources#