golang

With Go gaining more and more popularity, it is becoming more common to create a web application with it. This, however, can be a bit problematic when it comes to writing unit tests. Fortunately, in Golang it's easier than you may think.

Why Test Driven Development?

It is a very bad idea to ditch writing unit tests - it's rather a widespread opinion in software development community. Unfortunately, with looming deadlines, limited budgets and... our laziness prevent us from doing our job like solid craftsmen. Over time we find more and more sophisticated excuses, but most of them originate in the lack of commitment to testing int the beginning. And the perfect way to avoid that kind of problems is to write tests before implementation, ie. using TDD technique.

I'll show you how you can create a simple web application doing just that. Our web server will return a list of countries as a JSON object. Pretty simple, right? But if we want to write tests first? Ugh...

Step 0: Empty handler function

First of all, let's create an empty function that will perform all the business logic in the future:

// main.go
...
func GetCountries(rw http.ResponseWriter, req *http.Request) {}

Now, let's switch to main_test.go file and create an appropriate test.

Step 1: Your first test

When writing tests for a web server, we don't want to start the application itself, but only test every endpoint separately. In order to do that, we need to set up all the routing inside test file before all test cases:

// main_test.go
func init() {
    http.HandleFunc("/countries", GetCountries)
}

Then, we can start working on our first test:

// main_test.go
...
func TestGetCountries(t *testing.T) {}

In order to test our server, we need to perform three steps: create a request, create mocked response writer, and run them together. In order to create an object of *http.Request type, we need three parameters: HTTP method, URL address and request body. Since our GET request doesn't have body, we need to write:

// main_test.go
...
req := httptest.NewRequest(http.MethodGet, "/countries", nil)

Then, we need to create mocked http.ResponseWriter and run our request against it:

// main_test.go
...
rw := httptest.NewRecorder()
http.DefaultServeMux.ServeHTTP(rw, req)

Now here comes the part when we need to check if our handler does what it should. First, it should always respond with status code 200:

// main_test.go
...
if rw.Code != 200 {
    t.Fatalf("Expected 200 response code, but got: %v\n", rw.Code)
}

Then, it should respond with a JSON array of two countries: Poland and USA:

// main_test.go
...
var c []Country
err := json.NewDecoder(rw.Body).Decode(&c)
if err != nil {
    t.Fatal("Failed to decode countries list")
}

if len(c) != 2 {
    t.Fatalf("Expected length of countries list to equal 2, not: %v\n", len(c))
}
t.Log("Expected length of countries list to equal 2", len(c))

pl := Country{Code: "PL", Name: "Poland", Capital: "Warsaw"}
if c[0] != pl {
    t.Fatalf("Expected first country to be Poland, not: %v\n", c[0])
}
t.Log("Expected first country to be Poland")

usa := Country{Code: "USA", Name: "USA", Capital: "Washington"}
if c[1] != usa {
    t.Fatalf("Expected first country to be USA, not: %v\n", c[0])
}
t.Log("Expected first country to be USA")

Now, let's try to run the test:

$ go test  .
...
./main_test.go:24: undefined: Country
./main_test.go:30: undefined: Country
./main_test.go:31: undefined: Country
FAIL    github.com/slomek/playground/mongo [build failed]

Step 2: Make our code compile

We obviously failed, because we haven't defined Country struct. Let's do it then:

// models.go
...
type Country struct {
    Code, Name, Capital string
}

Running test again still fails, though:

$ go test  .
--- FAIL: TestGetCountries (0.00s)
    main_test.go:27: Failed to decode countries list
FAIL
FAIL    github.com/slomek/playground/mongo    0.002s

What now? It seems that our endpoint doesn't respond with a correct JSON because we failed to decode it. In fact, it doesn't respond with anything. Let's fix that.

Step 3: Implementing business logic

We start by taking things step by step and make our endpoint just return some correct JSON, in order to pass the error:

// main.go
func GetCountries(rw http.ResponseWriter, req *http.Request) {
    c := []Country{}
    json.NewEncoder(rw).Encode(&c)
}

Will our tests pass now? Of course not:

$ go test  .
--- FAIL: TestGetCountries (0.00s)
    main_test.go:22: Expected 200 response code.
    main_test.go:31: Expected length of countries list to equal 2, not: 0
FAIL
FAIL    github.com/slomek/playground/mongo    0.002s

So we need to return two countries:

// main.go
func GetCountries(rw http.ResponseWriter, req *http.Request) {
    c := []Country{
        Country{},
        Country{},
    }
    json.NewEncoder(rw).Encode(&c)
}  

It is still not good enough for our tests:

$ go test  .
--- FAIL: TestGetCountries (0.00s)
   main_test.go:22: Expected 200 response code.
   main_test.go:33: Expected length of countries list to equal 2 2
   main_test.go:37: Expected first country to be Poland, not: {  }
FAIL
FAIL    github.com/slomek/playground/mongo    0.002s

Now when we change empty countries into an actual, correct replacements, we can see our tests pass:

$ go test  .
ok      github.com/slomek/playground/mongo    0.002s

Summary

As you can see, writing test upfront required some extra effort, but we are certain that our code behaves the way we wanted. Additionally, we have a baseline ready for future tests, as we now know how to check for certain elements (like response code or JSON body).