So we’ve built a working API server, but the only way we’ve tested the endpoints is by hammering them with curl
, but why not use Go’s built in testing mechanism, go test
?
Test setup
We will start by creating our main_test.go
file with a function for executing our request. This function will take an http.Request
and a user database and respond with an httptest.ResponseRecorder
. It will setup our server similar to the way our main function does, creating a new app
instance, setting up the Router, and initializing the database.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| package main
import (
"net/http"
"net/http/httptest"
"github.com/gorilla/mux"
)
func executeRequest(req *http.Request, db map[int]user) *httptest.ResponseRecorder {
rr := httptest.NewRecorder()
a := app{}
a.Router = mux.NewRouter()
a.UserDB = db
a.Router.ServeHTTP(rr, req)
return rr
}
|
Adding Tests
We’ll also need to write some tests. Let’s start with testing our app.GetUser
function. I’m a big fan of table driven testing. We’ll setup a list of tests, and expected results and let Go iterate over the table checking our input and comparing it to our expected outcomes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| func Test_app_GetUser(t *testing.T) {
tests := []struct {
database map[int]user
method string
request string
expectedCode int
}{
{
database: map[int]user{
1: user{
ID: 1,
Name: "User 1",
Role: "Admin",
},
},
method: "GET",
request: "/user/1",
expectedCode: 200,
},
}
for _, tt := range tests {
req, _ := http.NewRequest(tt.method, tt.request, nil)
response := executeRequest(req, tt.database)
if tt.expectedCode != response.Code {
t.Errorf("Expected response code %d. Got %d.\n", tt.expectedCode, response.Code)
}
}
}
|
Here we see we’ve setup a database with 1 user named “User 1” with a role of “Admin”, and we are going to issue a GET request to our server to /user/1. We expect to get a 200 back. We tell the package to range over the tests
variable and create a new http.Request
using our tests.method
and tests.request
strings and run the request through our test method defined above with the tests.database
user database. The response from that request is then compared to our tests.expectedCode
and the test fails if it does not match.
So let’s go ahead and run our tests:
1
2
3
4
5
6
| $ go test
--- FAIL: Test_app_GetUser (0.00s)
main_test.go:45: Expected response code 200. Got 404.
FAIL
exit status 1
FAIL github.com/{username}/go-rest-api 0.004s
|
Debugging Tests
Hmm… A 404 error. Why didn’t we get the 200 response we expected? Let’s look at our code. In our main.go
we have the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| func main() {
a := app{}
a.Router = mux.NewRouter()
a.UserDB = map[int]user{
1: user{
ID: 1,
Name: "User 1",
Role: "Admin",
},
}
a.Run(":3001")
}
func (a *app) Run(addr string) (err error) {
log.Printf("Listening on %v", addr)
a.Router.HandleFunc("/user/{id:[0-9]+}", a.GetUser).Methods("GET")
a.Router.HandleFunc("/user", a.CreateUser).Methods("POST")
a.Router.HandleFunc("/user/{id:[0-9]+}", a.DeleteUser).Methods("DELETE")
a.Router.HandleFunc("/user/{id:[0-9]+}", a.ReplaceUser).Methods("PUT")
a.Router.HandleFunc("/user/{id:[0-9]+}", a.UpdateUser).Methods("PATCH")
return http.ListenAndServe(addr, a.Router)
}
|
Our endpoint handles are defined in the Run function, but in our main_test.go
we don’t add those because the Run function actually spins the server up with the http.ListenAndServe
call. Let’s move our Router.HandleFunc
definitions to a new function we can add to our executeRequest
function in main_test.go
. Here is our updated main.go
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| func main() {
a := app{}
a.Router = mux.NewRouter()
a.UserDB = map[int]user{
1: user{
ID: 1,
Name: "User 1",
Role: "Admin",
},
}
a.AddHandles()
a.Run(":3001")
}
func (a *app) AddHandles() {
a.Router.HandleFunc("/user/{id:[0-9]+}", a.GetUser).Methods("GET")
a.Router.HandleFunc("/user", a.CreateUser).Methods("POST")
a.Router.HandleFunc("/user/{id:[0-9]+}", a.DeleteUser).Methods("DELETE")
a.Router.HandleFunc("/user/{id:[0-9]+}", a.ReplaceUser).Methods("PUT")
a.Router.HandleFunc("/user/{id:[0-9]+}", a.UpdateUser).Methods("PATCH")
}
func (a *app) Run(addr string) (err error) {
log.Printf("Listening on %v", addr)
return http.ListenAndServe(addr, a.Router)
}
|
And here’s the new version of our executeRequest
function:
1
2
3
4
5
6
7
8
9
| func executeRequest(req *http.Request, db map[int]user) *httptest.ResponseRecorder {
rr := httptest.NewRecorder()
a := app{}
a.Router = mux.NewRouter()
a.UserDB = map[int]user{}
a.AddHandles()
a.Router.ServeHTTP(rr, req)
return rr
}
|
Now let’s re-run our tests and see if we get a pass:
1
2
3
| $ go test
PASS
ok github.com/{username}/go-rest-api 0.004s
|
Awesome. Our single test is passing. Now let’s add some more tests.
Testing unsuccessful requests
We’ll write some more tests now for some of the ways requests might fail as well. What if we request a user that doesn’t exist? It should respond with a 404:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| ...
{
database: map[int]user{
1: user{
ID: 1,
Name: "User 1",
Role: "Admin",
},
},
method: "GET",
request: "/user/2",
expectedCode: 404,
},
{
database: map[int]user{},
method: "GET",
request: "/user/1",
expectedCode: 404,
},
}
...
|
This adds tests for getting a 2nd user when only a 1 exists in the database, as well as getting user ID 1 when the database is empty. Another test we can write is what if an ID is provided in the URL that is too large to be represented as an integer? This should fail with a 500 error when strcov.Atoi()
returns an error. We should probably test that as well. Let’s add at test as follows:
1
2
3
4
5
6
7
8
| ...
{
database: map[int]user{},
method: "GET",
request: "/user/99999999999999999999",
expectedCode: 500,
},
...
|
Great, the only failure mode we haven’t caught is if json.Marshal
is unable to marshal our user struct, which should never happen since it’s made up of basic data types like strings and integers.
Tests, Tests, and More Tests
Now we can continue through replicating our Test_app_GetUser
function for our other endpoint functions. First we’ll start with app.DeleteUser
. We’ll do similar tests to the ones we wrote before. One test to delete an existing user, returning a 200. One test to delete a non-existent user, returning a 404. And finally, a test deleting a user with an overflowing ID, which will fail with a 500.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| func Test_app_DeleteUser(t *testing.T) {
tests := []struct {
database map[int]user
method string
request string
expectedCode int
}{
{
database: map[int]user{
1: user{
ID: 1,
Name: "User 1",
Role: "Admin",
},
},
method: "DELETE",
request: "/user/1",
expectedCode: 200,
},
{
database: map[int]user{},
method: "DELETE",
request: "/user/1",
expectedCode: 404,
},
{
database: map[int]user{},
method: "DELETE",
request: "/user/99999999999999999999",
expectedCode: 500,
},
}
for _, tt := range tests {
req, _ := http.NewRequest(tt.method, tt.request, nil)
response := executeRequest(req, tt.database)
if tt.expectedCode != response.Code {
t.Errorf("Expected response code %d. Got %d.\n", tt.expectedCode, response.Code)
}
}
}
|
For our other endpoints we’ll have to add a few other options to our tests. Since app.CreateUser
, app.UpdateUser
and app.ReplaceUser
all require data from the request body we’ll have to provide that in our test as well.
Tests with Bodies
We’ll start with replicating our first curl test in a go test. For reference, our first curl request (from part 2) was curl -i -X POST http://127.0.0.1:3001/user -d'{"name": "User 2","role":"User"}'
We’ll add a body field to our tests
table, and assign it the value from our curl -d flag above. Then we can use that in our http.NewRequest
in the third argument, which has been nil
up until now. The problem is the http.NewRequest
third argument takes an io.Reader
not a string, so, we’ll have to convert our body to an io.Reader
using string.NewReader
which takes a string and returns an io.Reader
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| func Test_app_CreateUser(t *testing.T) {
tests := []struct {
database map[int]user
method string
request string
body string
expectedCode int
}{
{
database: map[int]user{
1: user{
ID: 1,
Name: "User 1",
Role: "Admin",
},
},
method: "POST",
request: "/user",
body: `{"name": "User 2","role":"User"}`,
expectedCode: 200,
},
}
for _, tt := range tests {
body := strings.NewReader(tt.body)
req, _ := http.NewRequest(tt.method, tt.request, body)
response := executeRequest(req, tt.database)
fmt.Printf("%s %s: %+v\n", tt.method, tt.request, response.Body.String())
if tt.expectedCode != response.Code {
t.Errorf("Expected response code %d. Got %d.\n", tt.expectedCode, response.Code)
}
}
}
|
With this test we can also try to pass some invalid JSON input in the body. We’ll add another test with some malformed JSON and we should get back a 500 server error, because json.Unmarshal
should return an error.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| ...
{
database: map[int]user{
1: user{
ID: 1,
Name: "User 1",
Role: "Admin",
},
},
method: "POST",
request: "/user",
body: `Not a JSON string`,
expectedCode: 500,
},
...
|
Now if we run our tests:
1
2
3
| $ go test
PASS
ok github.com/AarynSmith/go-rest-api 0.005s
|
Awesome. Only thing left to test is Replacing and Updating users.
Replacing user tests
Again very similar to our create user tests we’ll create the tests for app.ReplaceUser
, changing our method to PUT and the request to point to “/user/1”.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| func Test_app_ReplaceUser(t *testing.T) {
tests := []struct {
database map[int]user
method string
request string
body string
expectedCode int
}{
{
database: map[int]user{
1: user{
ID: 1,
Name: "User 1",
Role: "Admin",
},
},
method: "PUT",
request: "/user/1",
body: `{"name": "User 2","role":"User"}`,
expectedCode: 200,
},
{
database: map[int]user{
1: user{
ID: 1,
Name: "User 1",
Role: "Admin",
},
},
method: "PUT",
request: "/user/1",
body: `Not a JSON string`,
expectedCode: 500,
},
}
for _, tt := range tests {
body := strings.NewReader(tt.body)
req, _ := http.NewRequest(tt.method, tt.request, body)
response := executeRequest(req, tt.database)
if tt.expectedCode != response.Code {
t.Errorf("Expected response code %d. Got %d.\n", tt.expectedCode, response.Code)
}
}
}
|
We’ll also want to copy our tests from before with overflowing IDs, as well as writing a test for an ID that doesn’t exist.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| {
database: map[int]user{},
method: "PUT",
request: "/user/1",
body: `{"name": "User 2","role":"User"}`,
expectedCode: 404,
},
{
database: map[int]user{},
method: "PUT",
request: "/user/99999999999999999999",
body: `{"name": "User 2","role":"User"}`,
expectedCode: 500,
},
|
And replicate the same tests for our the PATCH method, however we’ll split the first test into two, one updating the name, and the second updating the role.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
| func Test_app_UpdateUser(t *testing.T) {
tests := []struct {
database map[int]user
method string
request string
body string
expectedCode int
}{
{
database: map[int]user{
1: user{
ID: 1,
Name: "User 1",
Role: "Admin",
},
},
method: "PATCH",
request: "/user/1",
body: `{"name": "User 2"}`,
expectedCode: 200,
},
{
database: map[int]user{
1: user{
ID: 1,
Name: "User 1",
Role: "Admin",
},
},
method: "PATCH",
request: "/user/1",
body: `{"role": "User"}`,
expectedCode: 200,
},
{
database: map[int]user{},
method: "PATCH",
request: "/user/1",
body: `Not a JSON string`,
expectedCode: 500,
},
{
database: map[int]user{},
method: "PATCH",
request: "/user/1",
body: `{"name": "User 2","role":"User"}`,
expectedCode: 404,
},
{
database: map[int]user{},
method: "PATCH",
request: "/user/99999999999999999999",
body: `{"name": "User 2","role":"User"}`,
expectedCode: 500,
},
}
for _, tt := range tests {
body := strings.NewReader(tt.body)
req, _ := http.NewRequest(tt.method, tt.request, body)
response := executeRequest(req, tt.database)
if tt.expectedCode != response.Code {
t.Errorf("Expected response code %d. Got %d.\n", tt.expectedCode, response.Code)
}
}
}
|
Conclusion
At this point we can now run all the tests for our project and see what kind of code coverage we have.
1
2
3
4
| $ go test --cover
PASS
coverage: 90.6% of statements
ok github.com/{username}/go-rest-api 0.007s
|
Fantastic,we’re covering 90% of our code. So we’ve built a REST API server in Go, setup endpoints and tested those endpoints. Have I missed anything? Feel free to send me a message from the links below.
Here is a gist of our main.go and main_test.go from this post.