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

J.Kim
8 min readJan 25

--

It has been a bit more than 2 months since I started learning Golang. I found the language is easy to understand. However, I had no idea where to begin with respect to building an application using Test-first approach. In this article, a step-by-step approach to applying Test-Driven Development is be covered. This application is 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, each endpoint on Echo takes echo.Context as an input and returns error. Therefore, returning nil means there is no error which also means status code of the response is 200 Ok.

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 be handy. We want to invoke the repository from controller and return specific books that I can manipulate from test code as a response. To spy on the repository call and to stub the return values of the repository, we need an interface of this repository.

package repository

import "books-app/model"

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

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 MockRepository, it can be used in the test code. Notice how the return values can be stubbed on FindAll method. Also, AssertExpectations will ensure that FindAll method has been called when executing this test.

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)
})

In order to make it to compile, Repository needs to be added to the Controller struct because a specific instance of a repository with stubbed return values is being injected in the test code.

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)
}

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 GetAllBooks method.

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)
}

We finally succeeded spying on the invocation of Repository::FindAll. However, 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 created and 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.

The return value for MockRepository::FindAll needs to be changed to return one book with specific isbn, title, and author. These stub values and expected values in assertions should be the same in the test 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)
})

At this point, we want to map fetched books from the repository to a data transfer object (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:

--

--