Go

Guide to Writing and Testing a Rest API (CRUD) | hackajob

May 24, 2021
hackajob Staff

If you're a Golang programmer, then you'll want to know how to do Rest API testing in Go. Companies such as Form3 are currently hiring via hackajob for engineers proficient in Go, so it's a good thing you came across this page! How do you do Rest API testing? And what is a CRUD API? We'll show you how step by step in this tech tutorial. We'll use gin for router and gorm as orm. There's no better way to understand than to get stuck in so let's take a deep dive into writing and testing CRUD!

First of all, we'll create a folder structure:

first-picture

Then we want to create a table named books in the database. For this, let's create the Book struct in models:

package models

import "github.com/jinzhu/gorm"

type Book struct {
	gorm.Model
	Author string
	Name string
	PageCount int
}

gorm.Model stores variables such as ID, CreatedAt, and you don't need to add them separately. It should look something like this:

second-picture

Here's a good place to double check that your code looks like the above, if it doesn't that's okay. When you feel comfortable with your code, let's move on to the database connection.

The Database Connection

package database

import (
	"apitest/models"
	"github.com/jinzhu/gorm"
	_ "github.com/jinzhu/gorm/dialects/postgres"
	"log"
)

/*DB is connected database object*/
var DB *gorm.DB

func Setup() {
	host := "host"
	port := "port"
	dbname := "dbname"
	user := "user"
	password := "password"

	db, err := gorm.Open("postgres",
			"host="+host+
				" port="+port+
				" user="+user+
				" dbname="+dbname+
				" sslmode=disable password="+password)

	if err != nil {
		log.Fatal(err)
	}

	db.LogMode(false)
	db.AutoMigrate([]models.Book{})
	DB = db
}

// GetDB helps you to get a connection
func GetDB() *gorm.DB {
	return DB
}

Setting the credentials to the gorm.Open function is enough to initiate the connection.

If you set db.LogMode to true, you can see the SQL queries that you've written, whilst db.AutoMigrate creates a table named "books" from the struct that we've defined.

Handlers

Let's get into our handlers. They're quite simple, but very useful to know. We have 5 handlers and they each do the following operations:

  • GetBooks ➞ fetches all records in the database

  • GetBook ➞ returns the record with the id entered as a parameter

  • CreateBook ➞ adds a new record

  • UpdateBook ➞ updates the record with the id entered as a parameter

  • DeleteBook ➞ deletes the record with the id entered as a parameter

Now that we've determined our handlers to write, we can write our database functions that we'll use in these handlers:

func GetBooks(db *gorm.DB) ([]models.Book, error) {
	books := []models.Book{}
	query := db.Select("books.*").
			Group("books.id")
	if err := query.Find(&books).Error; err != nil {
		return books, err
	}

	return books, nil
}

func GetBookByID(id string, db *gorm.DB) (models.Book, bool, error) {
	b := models.Book{}
	
	query := db.Select("books.*")
	query = query.Group("books.id")
	err := query.Where("books.id = ?", id).First(&b).Error
	if err != nil && !gorm.IsRecordNotFoundError(err) {
		return b, false, err
	}

	if gorm.IsRecordNotFoundError(err) {
		return b, false, nil
	}
	return b, true, nil
}

func DeleteBook(id string, db *gorm.DB) error {
	var b models.Book
	if err := db.Where("id = ? ", id).Delete(&b).Error; err != nil {
		return err
	}
	return nil
}

func UpdateBook(db *gorm.DB, b *models.Book) error {
	if err := db.Save(&b).Error; err != nil {
		return err
	}
	return nil
}

We return the record not found error by separating it from other errors in get operations because if the error is not found, the handler will return 500, if not found, it will return 404.

Great, now let's implement our handlers:

type APIEnv struct {
	DB *gorm.DB
}

func (a *APIEnv) GetBooks(c *gin.Context) {
	books, err := database.GetBooks(a.DB)
	if err != nil {
		fmt.Println(err)
		c.JSON(http.StatusInternalServerError, err.Error())
		return
	}

	c.JSON(http.StatusOK, books)
}

func (a *APIEnv) GetBook(c *gin.Context) {
	id := c.Params.ByName("id")
	book, exists, err := database.GetBookByID(id, a.DB)
	if err != nil {
		fmt.Println(err)
		c.JSON(http.StatusInternalServerError, err.Error())
		return
	}

	if !exists {
		c.JSON(http.StatusNotFound, "there is no book in db")
		return
	}

	c.JSON(http.StatusOK, book)
}

func (a *APIEnv) CreateBook(c *gin.Context) {
	book := models.Book{}
	err := c.BindJSON(&book)
	if err != nil {
		fmt.Println(err)
		c.JSON(http.StatusInternalServerError, err.Error())
		return
	}

	if err := a.DB.Create(&book).Error; err != nil {
		c.JSON(http.StatusInternalServerError,err.Error())
		return
	}

	c.JSON(http.StatusOK, book)
}

func (a *APIEnv)DeleteBook(c *gin.Context) {
	id := c.Params.ByName("id")
	_, exists, err := database.GetBookByID(id,a.DB)
	if err != nil {
		fmt.Println(err)
		c.JSON(http.StatusInternalServerError, err.Error())
		return
	}

	if !exists {
		c.JSON(http.StatusNotFound, "record not exists")
		return
	}

	err = database.DeleteBook(id,a.DB)
	if err != nil {
		fmt.Println(err)
		c.JSON(http.StatusInternalServerError, err.Error())
		return
	}

	c.JSON(http.StatusOK, "record deleted successfully")
}

func (a *APIEnv) UpdateBook(c *gin.Context) {
	id := c.Params.ByName("id")
	_, exists, err := database.GetBookByID(id, a.DB)
	if err != nil {
		fmt.Println(err)
		c.JSON(http.StatusInternalServerError, err.Error())
		return
	}

	if !exists {
		c.JSON(http.StatusNotFound, "record not exists")
		return
	}

	updatedBook := models.Book{}
	err = c.BindJSON(&updatedBook)
	if err != nil {
		fmt.Println(err)
		c.JSON(http.StatusInternalServerError, err.Error())
		return	
	}

	if err := database.UpdateBook(a.DB, &updatedBook); err != nil {
		fmt.Println(err)
		c.JSON(http.StatusInternalServerError, err.Error())
		return
	}

	a.GetBook(c)
}

Here, we've defined a struct named APIEnv and we've derived our handlers from this struct. By doing this, our goal is to remove the dependency on the DB object by setting the DB object from the outside. If your tests are running in docker on the test pipe in CI/CD, you don't need to do this because your real database will not conflict with your test database, but if you want to test in the same environment, you should set the test DB object to the APIEnv object in your tests by writing a second Setup() function for the test database.

Let's set our routers:

func Setup() *gin.Engine {
	r := gin.Default()
	api := &handlers.APIEnv{
		DB: database.GetDB(),
	}

	r.GET("", api.GetBooks)
	r.GET("/:id", api.GetBook)
	r.POST("", api.CreateBook)
	r.PUT("/:id", api.UpdateBook)
	r.DELETE("/:id", api.DeleteBook)

	return r
}

And let's call in main:

func main() {
	database.Setup()
	r := routers.Setup()
	if err := r.Run("127.0.0.1:8080"); err != nil {
		log.Fatal(err)
	}
}

Amazing! Our code is now ready so it's time to write our tests. First, let's write a test for GetBooks. Let's test if there is no record in the database in this test. In this case, we expect the empty Book object to be returned. We'll use testify package for assert in tests.

func Test_GetBooks_EmptyResult(t *testing.T) {
	database.Setup()
	db := database.GetDB()
	req, w := setGetBooksRouter(db)
	defer db.Close()

	a := assert.New(t)
	a.Equal(http.MethodGet, req.Method, "HTTP request method error")
	a.Equal(http.StatusOK, w.Code, "HTTP request status code error")

	body, err := ioutil.ReadAll(w.Body)
	if err != nil {
		a.Error(err)
	}

	actual := models.Book{}
	if err := json.Unmarshal(body, &actual); err != nil {
		a.Error(err)
	}

	expected := models.Book{}
	a.Equal(expected, actual)
	database.ClearTable()
}

func setGetBooksRouter(db *gorm.DB) (*http.Request, *httptest.ResponseRecorder) {
	r := gin.New()
	api := &APIEnv{DB: db}
	r.GET("/", api.GetBooks)
	req, err := http.NewRequest(http.MethodGet, "/", nil)
	if err != nil {
		panic(err)
	}

	req.Header.Set("Content-Type", "application/json")
	
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)
	return req, w
}

We set the Books router with setGetBooksRouter, and we can use this function in all the tests we'll write about GetBooks. In our test function, we'll launch our database and routers and implement our test logic. We can use assert to check if the method, code, and returned object are correct. At the end of our tests, we'll call the ClearTable function so that there are no conflicts while our tests are running:

func ClearTable() {
	DB.Exec("DELETE FROM books")
	DB.Exec("ALTER SEQUENCE books_id_seq RESTART WITH 1")
}

Now, let's test our GetBook api:

func Test_GetBook_OK(t *testing.T) {
	a := assert.New(t)
	database.Setup()
	db := database.GetDB()

	book, err := insertTestBook(db)
	if err != nil {
		a.Error(err)
	}

	req, w := setGetBookRouter(db,"/1")
	defer db.Close()

	a.Equal(http.MethodGet, req.Method, "HTTP request method error")
	a.Equal(http.StatusOK, w.Code, "HTTP request status code error")

	body, err := ioutil.ReadAll(w.Body)
	if err != nil {
		a.Error(err)
	}

	actual := models.Book{}
	if err := json.Unmarshal(body, &actual); err != nil {
		a.Error(err)
	}

	actual.Model = gorm.Model{}
	expected := book
	expected.Model = gorm.Model{}
	a.Equal(expected, actual)
	database.ClearTable()
}

func setGetBookRouter(db *gorm.DB, url string) (*http.Request, *httptest.ResponseRecorder) {
	r := gin.New()
	api := &APIEnv{DB: db}
	r.GET("/:id", api.GetBook)

	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		panic(err)
	}

	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)
	return req, w
}

func insertTestBook(db *gorm.DB) (models.Book,error){
	b := models.Book{
			Author: "test",
			Name: "test",
			PageCount: 10,
		}

	if err := db.Create(&b).Error; err != nil {
		return b, err
	}

	return b, nil
}

So we've set our GetBook router with setGetBookRouter. Then with insertTestBook, we'll add a record to the database for testing. When we query as id 1 in our test, we expect this record we added to the database to return. Since the times such as Created and Updated are kept in the model here, we set both to nil to compare the object returned to us with the object we expect.

Seems like you're really getting the hang of it! Let's write a test for CreateBook and then end by understanding the suite concept.

func Test_CreateBook_OK(t *testing.T) {
	a := assert.New(t)
	database.Setup()
	db := database.GetDB()
	book := models.Book{
			Author: "test",
			Name: "test",
			PageCount: 10,
	}

	reqBody, err := json.Marshal(book)
	if err != nil {
		a.Error(err)
	}

	req, w, err := setCreateBookRouter(db, bytes.NewBuffer(reqBody))
	if err != nil {
		a.Error(err)
	}

	a.Equal(http.MethodPost, req.Method, "HTTP request method error")
	a.Equal(http.StatusOK, w.Code, "HTTP request status code error")

	body, err := ioutil.ReadAll(w.Body)
	if err != nil {
		a.Error(err)
	}

	actual := models.Book{}
	if err := json.Unmarshal(body, &actual); err != nil {
		a.Error(err)
	}

	actual.Model = gorm.Model{}
	expected := book
	a.Equal(expected, actual)
	database.ClearTable()
}

func setCreateBookRouter(db *gorm.DB,
body *bytes.Buffer) (*http.Request, *httptest.ResponseRecorder, error) {
	r := gin.New()
	api := &APIEnv{DB: db}
	r.POST("/", api.CreateBook)
	req, err := http.NewRequest(http.MethodPost, "/", body)
	if err != nil {
		return req, httptest.NewRecorder(), err
	}

	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)
	return req, w, nil
}

The Test Suite

Above is how we write our API tests, but these tests also need a refactor. As you can see, the Setup() , GetDB() and ClearTable() functions are repeated in each test. To eliminate this, we can create a test suite and by using suite functions, we can manage our functions that we recall in each test from one place. Testify package has suite feature, so we let's use it.

First, let's create a struct named TestSuiteEnv and write the suite functions that derive from it like below:

type TestSuiteEnv struct {
	suite.Suite
	db *gorm.DB
}

// Tests are run before they start
func (suite *TestSuiteEnv) SetupSuite() {
	database.Setup()
	suite.db = database.GetDB()
}

// Running after each test
func (suite *TestSuiteEnv) TearDownTest() {
	database.ClearTable()
}

// Running after all tests are completed
func (suite *TestSuiteEnv) TearDownSuite() {
	suite.db.Close()	
}

// This gets run automatically by `go test` so we call `suite.Run` inside it
func TestSuite(t *testing.T) {
	// This is what actually runs our suite
	suite.Run(t, new(TestSuiteEnv))
}

Then we can add the suite object to the TestSuiteEnv struct and the DB object we use in each test. Next we'll implement the functions supported by the suite package. Let's look at them in order :

  • SetupSuite() ➞ The codes we write here are run before the tests start.

  • TearDownTest() ➞ The codes we write here are run after each test.

  • TearDownSuite() ➞ Here, the codes work after all tests are run and finished.

With TestSuite at the end, we ensure that all tests derived from TestSuiteEnv are run by saying suite.Run. Now we can derive our test functions from the TestSuiteEnv object and delete the duplicate codes :

func (suite *TestSuiteEnv)Test_GetBooks_EmptyResult() {
	req, w := setGetBooksRouter(suite.db)
	a := suite.Assert()
	a.Equal(http.MethodGet, req.Method, "HTTP request method error")
	a.Equal(http.StatusOK, w.Code, "HTTP request status code error")
	body, err := ioutil.ReadAll(w.Body)
		if err != nil {
		a.Error(err)
	}

	actual := models.Book{}
	if err := json.Unmarshal(body, &actual); err != nil {
		a.Error(err)
	}

	expected := models.Book{}
	a.Equal(expected, actual)
}

Et voila, we've gotten rid of repetitive codes by using the suite. And that's it! We hope you enjoyed this Go tutorial.

Like what you've read or want more like this? Let us know! Email us here or DM us: Twitter, LinkedIn, Facebook, we'd love to hear from you.