[Go] TDD with Go and Echo Framework Part 1 — Controller

It has been a bit more than 2 months since I started learning Golang. I found the language to be easy to understand. However, I had no idea where to begin on building an application along with Test-first approach. In this article, a step-by-step approach to applying Test-Driven Development will be covered. This application will be built with Echo Framework with GORM.

Installing Echo

First we need to create a new project and install Echo Go. Here is an installation guide. Following this guide, we need to run the below commands first. Feel free to replace myapp with the name of your choice.

$ mkdir myapp && cd myapp
$ go mod init myapp
$ go get github.com/labstack/echo/v4

Then create a file named server.go with below code. This will be enough to get us going.

package main

import "github.com/labstack/echo/v4"

func main() {
e := echo.New()

e.Logger.Fatal(e.Start(":8080"))
}

Now run the server. The application will start on port 8080.

Before moving to the next section, let us install another module testify which will be essential when writing test code. Some of the packages we can make use of from testify are:

  • assert: to write assertions in test code easily
  • suite: to group tests and writing repeated parts such as setup and teardown methods
  • mock: to use as test doubles in test code
$ go get github.com/stretchr/testify/assert

Implementation

Let’s begin with the first endpoint which is to fetch all books we have.

Testing Status Code

First create a file controller_test.go. The first test is to check if the endpoint returns status code of 200.

package controller

import (
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"net/http"
"net/http/httptest"
"testing"
)

func TestGetAllBooks(t *testing.T) {
t.Run("should return 200 status ok", func(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/books", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

controller := Controller{}
controller.GetAllBooks(c)

assert.Equal(t, http.StatusOK, rec.Code)
})
}

Of course, this test will fail because we don’t have a Controller struct or GetAllBooks method. In order to make the test code to compile, create controller.go with the following code:

package controller

import "github.com/labstack/echo/v4"

type Controller struct {
}

func (m *Controller) GetAllBooks(c echo.Context) error {
return nil
}

Interestingly, this test will pass. There are a couple of things we can learn from this test. First of all, each endpoint on Echo takes echo.Context as a parameter and returns error. Secondly, returning nil returns status code of 200.

Testing Response Body

Imagine a book should have properties such as isbn, title, author, and so on. For simplicity, I will just use isbn, title, and author. Let’s create a Book struct under dto package.

package dto

type Book struct {
Isbn string
Title string
Author string
}

This time, the response body needs to be captured inside the test code to make sure that a slice of Book struct is returned containing some values. Let us write a new test case.

t.Run("should return books", func(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/books", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

controller := Controller{}
controller.GetAllBooks(c)

var books []dto.Book
json.Unmarshal(rec.Body.Bytes(), &books)
assert.Equal(t, 1, len(books))
assert.Equal(t, "123", books[0].Isbn)
assert.Equal(t, "Learn Something", books[0].Title)
assert.Equal(t, "Jay", books[0].Author)
})

In order to make this test case pass, the controller implementation needs to be changed:

func (m *Controller) GetAllBooks(c echo.Context) error {
books := []dto.Book{
{
Isbn: "123",
Title: "Learn Something",
Author: "Jay",
},
}
return c.JSON(http.StatusOK, books)
}

Notice how http.StatusOk is used to make the first test case to pass. At this point, all of our tests should pass but returning a constant value from our endpoint is useless in reality. Now it is time to inject a repository into the controller.

Injecting Repository

As stated earlier, we will be using GORM for object relational mapping. First let us install GORM:

$ go get -u gorm.io/gorm

Because we are interested in testing and implementing the controller part, we won’t go any further into GORM. However, GORM is installed just to get us write a struct that repository interacts with. Let’s create another Book struct but inside model package this time.

package model

import "gorm.io/gorm"

type Book struct {
gorm.Model
Isbn string
Title string
Author string
}

This is where testify mock will come into place. We want to call the repository from controller and return fetched books as a response. To spy on the repository and to stub the return values of the repository, we need an interface of the repository.

package repository

import "books-app/model"

type Repository interface {
FindAll() ([]model.Book, error)
}

This repository needs to be added to the Controller struct as well.

package controller

import (
"books-app/dto"
"books-app/repository"
"github.com/labstack/echo/v4"
"net/http"
)

type Controller struct {
repository.Repository
}

func (m *Controller) GetAllBooks(c echo.Context) error {
books := []dto.Book{
{
Isbn: "123",
Title: "Learn Something",
Author: "Jay",
},
}
return c.JSON(http.StatusOK, books)
}

Now we have enough to proceed with test code. Of course it is advised to write test code before any setup we have done so far but I imagine it will take a long time to get used to the tech stacks in order to predict how the structure of the code will change just by writing the test code.

In order to use testify mock, we need to create a test double in the controller test file. This test double needs to have mock.Mock and it needs to implement the repository interface we created earlier.

package controller

import (
"books-app/dto"
"books-app/model"
"encoding/json"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"net/http"
"net/http/httptest"
"testing"
)

type MockRepository struct {
mock.Mock
}

func (m *MockRepository) FindAll() ([]model.Book, error) {
args := m.Called()
return args[0].([]model.Book), args.Error(1)
}

func TestGetAllBooks(t *testing.T) {
// ... test code (same as before)
}

After creating the test double, it can be used in action.

t.Run("should call repository to fetch books", func(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/books", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

mockRepository := MockRepository{}
mockRepository.On("FindAll").Return([]model.Book{}, nil)

controller := Controller{&mockRepository}
controller.GetAllBooks(c)

mockRepository.AssertExpectations(t)
})

Notice how the return values can be stubbed on FindAll method. Also, AssertExpectations will ensure that FindAll method has been called during the test. As of right now, this test will fail with an error message The code you are testing needs to make 1 more call(s). This means the repository is not being called in the implementation.

To fix this error, it is enough to call FindAll method inside the controller.

package controller

import (
"books-app/dto"
"books-app/repository"
"github.com/labstack/echo/v4"
"net/http"
)

type Controller struct {
repository.Repository
}

func (m *Controller) GetAllBooks(c echo.Context) error {
m.Repository.FindAll()
books := []dto.Book{
{
Isbn: "123",
Title: "Learn Something",
Author: "Jay",
},
}
return c.JSON(http.StatusOK, books)
}

At this point, the first two test cases will fail because they don’t have the repository injected. It is time to put everything together.

Fixing Failing Tests

In order to fix the failing tests, two changes are required.

  • MockRepository needs to be injected to the controller in all test cases
  • The return value of MockRepository::FindAll needs to be stubbed

Now the entire test code should look like this:

package controller

import (
"books-app/dto"
"books-app/model"
"encoding/json"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"net/http"
"net/http/httptest"
"testing"
)

type MockRepository struct {
mock.Mock
}

func (m *MockRepository) FindAll() ([]model.Book, error) {
args := m.Called()
return args[0].([]model.Book), args.Error(1)
}

func TestGetAllBooks(t *testing.T) {
t.Run("should return 200 status ok", func(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/books", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

mockRepository := MockRepository{}
mockRepository.On("FindAll").Return([]model.Book{}, nil)

controller := Controller{&mockRepository}
controller.GetAllBooks(c)

assert.Equal(t, http.StatusOK, rec.Code)
})

t.Run("should return books", func(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/books", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

mockRepository := MockRepository{}
mockRepository.On("FindAll").Return([]model.Book{}, nil)

controller := Controller{&mockRepository}
controller.GetAllBooks(c)

var books []dto.Book
json.Unmarshal(rec.Body.Bytes(), &books)
assert.Equal(t, 1, len(books))
assert.Equal(t, "123", books[0].Isbn)
assert.Equal(t, "Learn Something", books[0].Title)
assert.Equal(t, "Jay", books[0].Author)
})

t.Run("should call repository to fetch books", func(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/books", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

mockRepository := MockRepository{}
mockRepository.On("FindAll").Return([]model.Book{}, nil)

controller := Controller{&mockRepository}
controller.GetAllBooks(c)

mockRepository.AssertExpectations(t)
})
}

Though it seems like we are done, the controller implementation still returns constant values. If any value of the isbn or title or author of the expected value is changed in the test code, the test will fail. Let us look at the second test case.

t.Run("should return books", func(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/books", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

mockRepository := MockRepository{}
mockRepository.On("FindAll").Return([]model.Book{
{
Model: gorm.Model{ID: 1},
Isbn: "999",
Title: "Learn Something",
Author: "Jay",
},
}, nil)

controller := Controller{&mockRepository}
controller.GetAllBooks(c)

var books []dto.Book
json.Unmarshal(rec.Body.Bytes(), &books)
assert.Equal(t, 1, len(books))
assert.Equal(t, "999", books[0].Isbn)
assert.Equal(t, "Learn Something", books[0].Title)
assert.Equal(t, "Jay", books[0].Author)
})

At this point, we want to map fetched books from the repository to a DTO and return. This implementation will be enough to make the test pass.

package controller

import (
"books-app/dto"
"books-app/repository"
"github.com/labstack/echo/v4"
"net/http"
)

type Controller struct {
repository.Repository
}

func (m *Controller) GetAllBooks(c echo.Context) error {
fetchedBooks, _ := m.Repository.FindAll()
var books []dto.Book
for _, fetchedBook := range fetchedBooks {
book := dto.Book{
Isbn: fetchedBook.Isbn,
Title: fetchedBook.Title,
Author: fetchedBook.Author,
}
books = append(books, book)
}

return c.JSON(http.StatusOK, books)
}

Note that Repository::FindAll can return an error. It is ignored for now but writing a test for it should not be too hard using testify mock.

Finishing Touches

We don’t have a database yet, so let’s add an implementation of a repository that returns a book from memory in repository.go.

package repository

import (
"books-app/model"
"gorm.io/gorm"
)

type Repository interface {
FindAll() ([]model.Book, error)
}

type DefaultRepository struct {
}

func (m *DefaultRepository) FindAll() ([]model.Book, error) {
return []model.Book{{
Model: gorm.Model{ID: 1},
Isbn: "9780321278654",
Title: "Extreme Programming Explained: Embrace Change",
Author: "Kent Beck, Cynthia Andres",
}}, nil
}

Lastly, we need to register our API endpoint and map the handler function in server.go.

package main

import (
"books-app/controller"
"books-app/repository"
"github.com/labstack/echo/v4"
)

func main() {
e := echo.New()
g := e.Group("/api")

controller := controller.Controller{Repository: &repository.DefaultRepository{}}
g.GET("/books", controller.GetAllBooks)

e.Logger.Fatal(e.Start(":8080"))
}

Now run the application and go to http://localhost:8080/api/books from browser or a REST client tool. There should be a book returned.

Completed Controller & Test code

package controller

import (
"books-app/dto"
"books-app/model"
"encoding/json"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"gorm.io/gorm"
"net/http"
"net/http/httptest"
"testing"
)

type MockRepository struct {
mock.Mock
}

func (m *MockRepository) FindAll() ([]model.Book, error) {
args := m.Called()
return args[0].([]model.Book), args.Error(1)
}

func TestGetAllBooks(t *testing.T) {
t.Run("should return 200 status ok", func(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/books", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

mockRepository := MockRepository{}
mockRepository.On("FindAll").Return([]model.Book{}, nil)

controller := Controller{&mockRepository}
controller.GetAllBooks(c)

assert.Equal(t, http.StatusOK, rec.Code)
})

t.Run("should return books", func(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/books", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

mockRepository := MockRepository{}
mockRepository.On("FindAll").Return([]model.Book{
{
Model: gorm.Model{ID: 1},
Isbn: "999",
Title: "Learn Something",
Author: "Jay",
},
}, nil)

controller := Controller{&mockRepository}
controller.GetAllBooks(c)

var books []dto.Book
json.Unmarshal(rec.Body.Bytes(), &books)
assert.Equal(t, 1, len(books))
assert.Equal(t, "999", books[0].Isbn)
assert.Equal(t, "Learn Something", books[0].Title)
assert.Equal(t, "Jay", books[0].Author)
})

t.Run("should call repository to fetch books", func(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/books", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

mockRepository := MockRepository{}
mockRepository.On("FindAll").Return([]model.Book{}, nil)

controller := Controller{&mockRepository}
controller.GetAllBooks(c)

mockRepository.AssertExpectations(t)
})
}
package controller

import (
"books-app/dto"
"books-app/repository"
"github.com/labstack/echo/v4"
"net/http"
)

type Controller struct {
repository.Repository
}

func (m *Controller) GetAllBooks(c echo.Context) error {
fetchedBooks, _ := m.Repository.FindAll()
var books []dto.Book
for _, fetchedBook := range fetchedBooks {
book := dto.Book{
Isbn: fetchedBook.Isbn,
Title: fetchedBook.Title,
Author: fetchedBook.Author,
}
books = append(books, book)
}

return c.JSON(http.StatusOK, books)
}

Full source code can be found at:

--

--

https://www.linkedin.com/in/jaesik-kim-706b4a84

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store