Golabs-II,Deeper dive with Test Driven Development in Golang
Last updated: Apr 7, 2017
Introduction
Continuing our journey on the Go highway, we now take a pitstop at the second Golabs.
This entry is in continuation to Golabs-I,Introduction to Test Driven Development In Golang.
In the previous post, we briefly familiarized ourselves with the principles of TDD and how we can get started with it in Golang. I recommend you to go through it if you are new to TDD in Go.The more experienced and seasoned Gophers and those who have assimilated the gist of it can continue reading.
Recap
Lets start by recapitulating what we explored:
1. Go’s “testing” package provides us with all the help necessary to implement automated testing it Go.We explored it some and saw the usage of methods like Fail() Log() etc.
2. We also saw a trick to set up an array of test fixtures a.k.a Table Driven Tests and how to eliminate the boilerplate code for test setup.
3. We had a simple working calculator package which is pushed to a Github Repo
What will we do now?
Chances are that in the real world, no one will feel the need to write such a simple code that is to be deployed on production. We will now explore a TDD example which is very common piece of code in Webapps : HTTP API for User Signup .
Specs
Adhering to typical TDD, lets start out by working together with a business owner and lay out acceptance criteria/test suite on how the login should work.
In plain English:
- System should sign up a user only if all the validations pass and HTTP_OK code need to be returned.
- System should not sign up a user if he supplies a malformed email Id and a BAD_REQUEST error code needs to be returned.
- System should not sign up a user if he supplies an email id already in use and a CONFLICT error code needs to be returned.
- System should not sign up a user if he supplies a password which is less than 8 characters in length and a BAD_REQUEST code needs to be returned.
In Golang test specifications:
Listing 1
(File: api_test.go)
package api
var signUpTests = []struct {
request UserSignUpRequest
response UserSignUpResponse
statusCode int
message string
}{{
request: UserSignUpRequest{userName: "user1", email: "user1@gmail.com", password: "test1234"},
response: UserSignUpResponse{userName: "user1", userID: 1},
statusCode: 200,
message: "You have successfully signed up",
},
{
request: UserSignUpRequest{userName: "user1", email: "user1", password: "test1234"},
response: UserSignUpResponse{},
statusCode: 400,
message: "Please enter a valid email id",
},
{
request: UserSignUpRequest{userName: "user1", email: "user1@gmail.com", password: "test1234"},
response: UserSignUpResponse{},
statusCode: 409,
message: "This email has already been taken",
},
{
request: UserSignUpRequest{userName: "user1", email: "user1", password: "test12"},
response: UserSignUpResponse{},
statusCode: 409,
message: "Password must be at least 6 characters long",
},
}
If you observe we have written these specs to run in a table driven manner.
We create a new directory called “api” and a file called api.go and a test file called api_test.go. The code in Listing 1 goes in api_test.go
At this point we raise a PR for submitting a skeletal test suite
Now that we have a basic test structure, we need to add the basic functional code and make the tests fail.
We now create a basic API structure.
Listing 2
(File: api.go)
//SignUpUser exposes the signup API
func SignUpUser(w http.ResponseWriter, r *http.Request) {
}
//InitRouter initialises the mux router
func InitRouter() *mux.Router {
r := mux.NewRouter()
r.HandleFunc("/user/new", SignUpUser).Methods("POST")
return r
}
Highlights:
- We have used the widely used mux package which is a “A powerful URL router and dispatcher for
Golang.”
- Created a handler for the API
SignUpUser(w http.ResponseWriter, r *http.Request)(pretty standard Golang function) - Registered a route with mux at
/users/newthat will take a POST request to be served by theSignUpUserhandler.
We setup and execute the tests.
Listing 3
(File: api_test.go)
func TestUserSignup(t *testing.T) {
for _, fixture := range signUpTests {
t.Log("\n\n")
t.Log("Executing test", fixture.title)
server := httptest.NewServer(InitRouter())
body, _ := json.Marshal(fixture.request)
reader := strings.NewReader(string(body))
request, _ := http.NewRequest("POST", "/users/new", reader)
w := httptest.NewRecorder()
SignUpUser(w, request)
if w.Body.Len() == 0 {
t.Log("Empty Response found")
t.Fail()
}
server.Close()
}
}
Highlights:
- We have used the inbuilt httptest package.This package takes care of providing us all the utilities to test out a HTTP API.
1.
http.NewServer()will start a new test server.This needs to be closed by using the Close()2.
httptest.NewRecorder() will provide a way to capture the response of the API under test.It returns a * ResponseRecorder.(ResponseRecorder is an implementation of http.ResponseWriter that records its mutations for later inspection in tests.)
Both of the above methods ensure that we don’t actually have to test out with a production grade HTTP server.
Running go test the output will be
Listing 4
--- FAIL: TestUserSignup (0.00s)
api_test.go:50:
api_test.go:51: Executing test System should sign up a user and return a OK status when a unique email_id,proper password is supplied
api_test.go:59: Empty Response found
api_test.go:50:
api_test.go:51: Executing test System should throw a BAD_REQUEST when an invalid email_id is supplied
api_test.go:59: Empty Response found
api_test.go:50:
api_test.go:51: Executing test System should throw a CONFLICT error when an existing email is supplied
api_test.go:59: Empty Response found
api_test.go:50:
api_test.go:51: Executing test System should throw a BAD_REQUEST when a password with less than 8 characters in supplied
api_test.go:59: Empty Response found
FAIL
exit status 1
FAIL github.com/kaddiya/golabs-tdd/api 0.009s
Now we have the failing test cases and now we can begin to write the functional code.
At this point we raise a PR for submitting the failing test cases
Now that we have a variety of failing test cases we will try and make them pass and in the process make the Sign Up feature complete.
For the sake of simplicity of the API and have more focus on testing, we will perform the validations on the UserSignUpRequest and when they pass, create a user and append it to an in memory data store.
Following few listings show the important pieces of code.
We create an interface named UserDao which will encapsulate all the operations that are required to store the user.
Listing 5
(File: api.go)
//UserDao will be the interface for all user related functions
type UserDao interface {
isEmailIDUnique(email string) (bool, error)
saveUser(u *UserSignUpRequest)
}
Highlights:
-
isEmailIDUnique() will return true and nil error if the email parameter passed is unique and false and Error when the email id is already existing.-
saveUser() will append the newly created user to the in memory user store
Listing 6
(File: api.go)
var userDataStore = []user{}
var userIDValue = 0
type InMemoryUserDao struct {
}
//make InMemoryUserDao implement userDao
func (dao *InMemoryUserDao) isEmailIDUnique(email string) (bool, error) {
for _, user := range userDataStore {
if user.Email == email {
return false, errors.New("This Email Id has already been taken")
}
}
return true, nil
}
func (dao *InMemoryUserDao) saveUser(u *UserSignUpRequest) {
userIDValue = userIDValue + 1
newUser := user{UserID: userIDValue, UserName: u.UserName, Email: u.Email, Password: u.Password}
userDataStore = append(userDataStore, newUser)
}
Note: This is not the most thread safe of the codes.
Highlights:
- We create a struct
InMemoryUserDao which will implement the above methods and create an store the user in a in-memory array of User structs.- The
UserID is an auto generated value that will be assigned the incremented value of userIDValue.- Everything else is self explanatory implementation of the
UserDao interface method sets.The entire SignUpUser() definition.
Listing 7
(File: api.go)
func SignUpUser(w http.ResponseWriter, r *http.Request) {
reqBody := &UserSignUpRequest{}
parseRequestBody(r.Body, reqBody)
//check for valid email address
if !strings.Contains(reqBody.Email, "@") {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Please enter a valid email id"))
return
}
//check for the length of the password
if len(reqBody.Password) < 8 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Password must be at least 8 characters long"))
return
}
var dao UserDao = &InMemoryUserDao{}
_, uniqueViolationErr := dao.isEmailIDUnique(reqBody.Email)
if uniqueViolationErr != nil {
w.WriteHeader(http.StatusConflict)
w.Write([]byte("This email has already been taken"))
return
}
dao.saveUser(reqBody)
w.WriteHeader(http.StatusOK)
w.Write([]byte("You have successfully signed up"))
}
Highlights:
- We parse the request body into a struct ofUserSignUpRequest.- We perform all the validations in our test suite and return appropriate HTTP codes.
- We create an instance of
InMemoryUserDao to store the user after every validation passes.Once this code is place, running go test yields
PASS
ok github.com/kaddiya/golabs-tdd/api 0.009s
Our tests pass and the feature sign up is complete.We now raise a PR for feature completion of Sign Up functionality
Scope for Improvement
In this example we have seen how we can test out a HTTP API using the httptest package.
There are some kinks in the code that have to be ironed out though.
For Eg:
- Our Tests cant be run in parallel and depend on a particular sequence to run in.
- Our API is tightly coupled to the InMemoryUserDao.
- In testing out APIs that call 3rd party services, this tight coupling is undesirable.It makes us at the mercy of throttling rates(think of testing an API which provisions AWS resources!), undesirable side effects(think testing out card charges!) etc.
- To avoid this we need a way to decouple our fixtures and our Test Doubles/Collaborators.(In this example implementation of UserDao.)
Once we start having this thoughts, we can safely say we have started out exploring Dependency Inversion and Deterministic Tests.
- In the upcoming Golabs, we will explore how to separate such concerns and mock out the behavior of the UserDao which we can totally control.
- The eagle eyed will notice that our handled is registered to a URL path named /user/new,but our tests have the path /users/new hardcoded.As of now our tests don’t match the routes giving rise to a probability of our clients encountering lots of 404 error codes.
We need to be able to safeguard and write tests for our routes as well so that in a big application with plenty of routes, maintainence becomes easy.We will cover that in our upcoming Golabs as well.
Conclusion
We have taken a deeper dive into TDD in Golang with testing out a HTTP API.In the process we have seen the usage of utilities provided by httptest package and how they aid in advanced testing.
The complete code for this exercise is pushed to Github and tagged as a release .
I recommend readers to go through it for a more thorough understanding.
As always, do let me know what you thought of the above post in the contents below.
Cheers!