Building a finance tracking REST API using Go with TDD - Part 2
Hola amigos! In this part we're going to complete budgets API, also I'm going to tell you in recent days I've come up with a great combination of tools to build REST APIs much more MVC-like than this that I'm going to write about in incoming posts.
I've just finished with just one single acceptance test for budgets API but here I going to declare various test cases should cover almost 100% of budgets API behaviors. So let's get started:
// budgets_test.go package main import ( "net/http" "os" "testing" "github.com/azbshiri/common/test" "github.com/go-pg/pg" "github.com/go-pg/pg/orm" "github.com/gorilla/mux" "github.com/pquerna/ffjson/ffjson" "github.com/stretchr/testify/assert") var testServer *server var badServer *server func TestMain(m *testing.M) { testServer = newServer( pg.Connect(&pg.Options{ User: "alireza", Password: "alireza", Database: "alireza_test", }), mux.NewRouter(), ) badServer = newServer( pg.Connect(&pg.Options{ User: "not_found", Password: "alireza", Database: "alireza", }), mux.NewRouter(), ) // Here we create a temporary table to store each test case // data and follow isolation which would be dropped after. testServer.db.CreateTable(&budget{}, &orm.CreateTableOptions{ Temp: true, }) os.Exit(m.Run())} func TestGetBudgets_EmptyResponse(t *testing.T) { var body []budget res, err := test.DoRequest(testServer, "GET", BudgetPath, nil) ffjson.NewDecoder().DecodeReader(res.Body, &body) assert.NoError(t, err) assert.Len(t, body, 0) assert.Equal(t, res.Code, http.StatusOK)} func TestGetBudgets_NormalResponse(t *testing.T) { var body []budget budgets, err := CreateBudgetListFactory(testServer.db, 10) assert.NoError(t, err) res, err := test.DoRequest(testServer, "GET", BudgetPath, nil) assert.Equal(t, http.StatusOK, res.Code) assert.NoError(t, err) ffjson.NewDecoder().DecodeReader(res.Body, &body) assert.Len(t, body, 10) assert.Equal(t, budgets, &body)} func TestGetBudgets_DatabaseError(t *testing.T) { var body Error res, err := test.DoRequest(badServer, "GET", BudgetPath, nil) ffjson.NewDecoder().DecodeReader(res.Body, &body) assert.NoError(t, err) assert.Equal(t, DatabaseError, &body) assert.Equal(t, http.StatusInternalServerError, res.Code)}
I've defined three test cases normal response, empty response and when there's a database error that we've simulated using a non-existing database username to make database unavailable. Also as you noticed there's a DatabaseErrorconstant which assert to make sure errors are readable. I've created a errors.go file in which there are a new error type called Error as below:
package main import "net/http" type Error struct { Message string `json:"message"` Status int `json:"status"`} func Err(message string, status int) *Error { return &Error{message, status}} var DatabaseError = Err("Your request failed due to a database error.", http.StatusInternalServerError)
It carries a message and a corresponding HTTP code to make dealing with errors easier. Now that everything is in the right place we should pass or test which are failing in the current state.
So first we go by creating an HTTP handler function in budgets.go file:
// budgets.go package main import ( "net/http" "github.com/azbshiri/common/db") type budget struct { db.Model Amount float64 `json:"amount"`} func (s *server) getBudgets(w http.ResponseWriter, r *http.Request) {}
Then registering the HTTP handle in routes.go:
// routes.go package main const BudgetPath = "/api/budgets" func (s *server) routes() { s.mux.HandleFunc(BudgetPath, s.getBudgets).Methods("GET")}
Note: I defined BudgetPath constant to be able to use budgets API path in various places including tests. Tests are still failing so we should get budgets from the database and return an acceptable response to consumers. Also, I created a CreateBudgetListFactory factory in factories.gofile to making database populations easier.
// factories.go package main import ( "math/rand" "time" "github.com/go-pg/pg") func CreateBudgetListFactory(db *pg.DB, length int) (*[]budget, error) { budgets := make([]budget, length) for _, budget := range budgets { budget.Amount = rand.Float64() budget.CreatedAt = time.Now() } err := db.Insert(&budgets) if err != nil { return nil, err } return &budgets, nil}// budgets.go package main import ( "encoding/json" "net/http" "github.com/azbshiri/common/db" "github.com/pquerna/ffjson/ffjson") type budget struct { db.Model Amount float64 `json:"amount"`} func (s *server) getBudgets(w http.ResponseWriter, r *http.Request) { var budgets []*budget err := s.db.Model(&budgets).Select() if err != nil { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(DatabaseError) return } ffjson.NewEncoder(w).Encode(budgets)}
Now we're green:
=== RUN TestGetBudgets_EmptyResponse--- PASS: TestGetBudgets_EmptyResponse (0.00s)=== RUN TestGetBudgets_NormalResponse--- PASS: TestGetBudgets_NormalResponse (0.00s)=== RUN TestGetBudgets_DatabaseError--- PASS: TestGetBudgets_DatabaseError (0.00s) PASS ok github.com/azbshiri/budget-api 0.040s
Okay, now we repeating the same steps for creating a new budget API:
// budgets_test.go package main import ( "bytes" "net/http" "os" "testing" "github.com/azbshiri/common/test" "github.com/go-pg/pg" "github.com/go-pg/pg/orm" "github.com/gorilla/mux" "github.com/pquerna/ffjson/ffjson" "github.com/stretchr/testify/assert") var testServer *server var badServer *server func TestMain(m *testing.M) { testServer = newServer( pg.Connect(&pg.Options{ User: "alireza", Password: "alireza", Database: "alireza_test", }), mux.NewRouter(), ) badServer = newServer( pg.Connect(&pg.Options{ User: "not_found", Password: "alireza", Database: "alireza", }), mux.NewRouter(), ) testServer.db.CreateTable(&budget{}, &orm.CreateTableOptions{ Temp: true, }) os.Exit(m.Run())} func TestGetBudgets_EmptyResponse(t *testing.T) { var body []budget res, err := test.DoRequest(testServer, "GET", BudgetPath, nil) ffjson.NewDecoder().DecodeReader(res.Body, &body) assert.NoError(t, err) assert.Len(t, body, 0) assert.Equal(t, res.Code, http.StatusOK)} func TestGetBudgets_NormalResponse(t *testing.T) { var body []budget budgets, err := CreateBudgetListFactory(testServer.db, 10) assert.NoError(t, err) res, err := test.DoRequest(testServer, "GET", BudgetPath, nil) assert.Equal(t, http.StatusOK, res.Code) assert.NoError(t, err) ffjson.NewDecoder().DecodeReader(res.Body, &body) assert.Len(t, body, 10) assert.Equal(t, budgets, &body)} func TestGetBudgets_DatabaseError(t *testing.T) { var body Error res, err := test.DoRequest(badServer, "GET", BudgetPath, nil) ffjson.NewDecoder().DecodeReader(res.Body, &body) assert.NoError(t, err) assert.Equal(t, DatabaseError, &body) assert.Equal(t, http.StatusInternalServerError, res.Code)} func TestCreateBudget(t *testing.T) { var body budget byt, err := ffjson.Marshal(&budget{Amount: 1000.4}) rdr := bytes.NewReader(byt) res, err := test.DoRequest(testServer, "POST", BudgetPath, rdr) ffjson.NewDecoder().DecodeReader(res.Body, &body) assert.NoError(t, err) assert.Equal(t, 1000.4, body.Amount) assert.Equal(t, http.StatusOK, res.Code)} func TestCreateBudget_BadParamError(t *testing.T) { var body Error res, err := test.DoRequest(testServer, "POST", BudgetPath, bytes.NewReader([]byte{})) ffjson.NewDecoder().DecodeReader(res.Body, &body) assert.NoError(t, err) assert.Equal(t, BadParamError, &body) assert.Equal(t, http.StatusBadRequest, res.Code)} func TestCreateBudget_DatabaseError(t *testing.T) { var body Error byt, err := ffjson.Marshal(&budget{Amount: 1000.4}) rdr := bytes.NewReader(byt) res, err := test.DoRequest(badServer, "POST", BudgetPath, rdr) ffjson.NewDecoder().DecodeReader(res.Body, &body) assert.NoError(t, err) assert.Equal(t, DatabaseError, &body) assert.Equal(t, http.StatusInternalServerError, res.Code)}
Creating a new HTTP handle function for new API:
// budgets.go package main import ( "encoding/json" "net/http" "github.com/azbshiri/common/db" "github.com/pquerna/ffjson/ffjson") type budget struct { db.Model Amount float64 `json:"amount"`} func (s *server) getBudgets(w http.ResponseWriter, r *http.Request) { var budgets []*budget err := s.db.Model(&budgets).Select() if err != nil { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(DatabaseError) return } ffjson.NewEncoder(w).Encode(budgets)} func (s *server) createBudget(w http.ResponseWriter, r *http.Request) { var param struct { Amount float64 `json:"amount"` } err := ffjson.NewDecoder().DecodeReader(r.Body, ¶m) if err != nil { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(BadParamError) return } budget := budget{Amount: param.Amount} err = s.db.Insert(&budget) if err != nil { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(DatabaseError) return } ffjson.NewEncoder(w).Encode(budget)}
Note: I used DTO concept to decode parameters that sent to budget creation API, which is a great way to do so.
And we're done:
=== RUN TestGetBudgets_EmptyResponse--- PASS: TestGetBudgets_EmptyResponse (0.01s)=== RUN TestGetBudgets_NormalResponse--- PASS: TestGetBudgets_NormalResponse (0.00s)=== RUN TestGetBudgets_DatabaseError--- PASS: TestGetBudgets_DatabaseError (0.00s)=== RUN TestCreateBudget--- PASS: TestCreateBudget (0.00s)=== RUN TestCreateBudget_BadParamError--- PASS: TestCreateBudget_BadParamError (0.00s)=== RUN TestCreateBudget_DatabaseError--- PASS: TestCreateBudget_DatabaseError (0.00s) PASS ok github.com/azbshiri/budget-api (cached)