DEV Community

Alireza Bashiri
Alireza Bashiri

Posted on

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)
}
Enter fullscreen mode Exit fullscreen mode

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 DatabaseError constant 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)
Enter fullscreen mode Exit fullscreen mode

It carries a message and a corresponding HTTP code to make dealing with errors easier. Now that everything is in a right place we should pass or tests 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) {
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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.go file to making database population 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
}
Enter fullscreen mode Exit fullscreen mode
// 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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

one cannot simply make tests pass

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)
}
Enter fullscreen mode Exit fullscreen mode

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, &param)
    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)
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Please add your comments down below and if you found this informative don't forget to drop a heart for me :D

Top comments (4)

Collapse
 
basebandit profile image
basebandit

Hi Alireza nice tutorial.I like how you have structured the api right from the server struct.Do you have a github repo for this?

Collapse
 
alirezabashiri profile image
Alireza Bashiri

Good to hear. Actually I've moved to this structure which is more mature and you can find the codebase here

github.com/azbshiri/hex-rest-api

Collapse
 
basebandit profile image
basebandit

Nice work ,Will check it out.

Collapse
 
yurigett profile image
Yuri Abramov

Very nice articles!!!
Easy to understand and well explained!
Thanks:)