Golang: Test-Driven Development(TDD) with Gin and MySQL

Unleashing the power of writing tests
Aug 16 2023 · 11 min read

Background

Test-Driven Development (TDD) is a software development technique in which You write tests for a piece of code before you write the code itself.

The tests are used to define the desired behavior of the code and to ensure that the code meets those requirements. The process of writing the tests first helps to ensure that the code is testable and that it meets the requirements of the user or client.

TDD helps to catch bugs early in the development process and to ensure that the code is maintainable and easy to understand. It also promotes a test-first mindset and encourages the developer to think about the requirements and the desired behavior of the code before writing it.

Throughout this blog, I’m assuming that you’re familiar with the basic workings of Golang like package importing and all.


Sponsored

We believe that everyone deserves the opportunity to succeed and thrive, and Justly is designed to provide the guidance and support needed to make that a reality.


How TDD works?

TDD is an iterative process that follows these steps.

  1. Write a test for a small piece of functionality that doesn’t exist yet.
  2. Run the test, which should fail because the code doesn’t exist yet.
  3. Write the minimum amount of code necessary to make the test pass.
  4. Rerun the test, which should now pass because the code has been written.
  5. Refactor the code if necessary, making sure that all tests still pass.
  6. Repeat the process for the next piece of functionality.

Let’s examine the above steps with a basic example of a sum function. Which calculates the sum of two values.

  1. Write a test for the function that asserts the expected output is valid for a given input.
func TestSum(t *testing.T) {
  if sum := Sum(2, 3); sum != 5 {
    t.Errorf("Sum(2, 3) = %d; want 5", sum)
  }
  if sum := Sum(0, 0); sum != 0 {
    t.Errorf("Sum(0, 0) = %d; want 0", sum)
  }
  if sum := Sum(-2, 2); sum != 0 {
    t.Errorf("Sum(-2, 2) = %d; want 0", sum)
  }
  if sum := Sum(-5, 5); sum != 0 {
    t.Errorf("Sum(-5, 5) = %d; want 0", sum)
  }
  if sum := Sum(0.5, 0.3); sum != 0.8 {
    t.Errorf("Sum(0.5, 0.3) = %f; want 0.8", sum)
  }
}

2. Run the test and you will see the test fails, as the Sum function has not been implemented yet.

3. Write the minimal amount of code to make the test pass. For example:

func Sum(a, b float64) float64 {
    return a + b
}

4. Run the test again and see that it passes.

5. Refactor the code if necessary, and write more test cases to ensure full coverage.

6. Repeat steps 2–5 as needed until you are satisfied that the function is correct and complete.

Golang does not have built-in support for floating-point comparison so you might use the assert package for that.

It’s easy to test simple/one-liners compared to testing complex functionalities. Let’s have a look at how to test APIs along with the Implementation.


Define API Routes, Struct, and functions

In Golang, the Gin framework is commonly used to create RESTful APIs and handle routes. Here we will see how you might define routes for CRUD operation APIs using Gin.

For example, Let’s say you want to create endpoints that perform CRUD operations for a user. Like,

  • Show all the user's information,
  • Take in a user ID and return the user’s information,
  • Create a user,
  • Take in a user ID and update user information,
  • Take in a user ID and delete a user.

Add the below routes as in main.go file.

package main

import (
  "github.com/gin-gonic/gin"
)

func main() {
  router := gin.Default()

  router.POST("/api/users", Create)
  router.GET("/api/users/:id", Get)
  router.PUT("/api/users/:id", Update)
  router.DELETE("/api/users/:id", Delete)

  router.Run()
}

In this example, four routes are defined respectively to fetch, create, update, and delete the user.

Define the struct and functions that are needed by API routes in the user.go file.

type User struct {
  Id    int    `json:"id"`
  Name  string `json:"name"`
  Email string `json:"email"`
}

// create user
func Create(c *gin.Context) {  
  c.JSON(http.StatusOK, gin.H{})
}

// get user
func Get(c *gin.Context) {
  c.JSON(http.StatusOK, gin.H{})
}

// update user
func Update(c *gin.Context) {
  c.JSON(http.StatusOK, gin.H{})
}

// delete user
func Delete(c *gin.Context) {
  c.JSON(http.StatusOK, gin.H{})
}

Create a test file

When creating automated tests in Golang, it’s common practice to organize tests into separate files for each package or module being tested. Here is an example of how you might initialize a test file for a package called user:

  1. Create a new file called user_test.go in the same directory as the user package.
  2. Import the necessary packages at the top of the file. For example, you will likely need to import the testingpackage.

Note: A test file name must have a suffix _test.go , in order to be recognized by the test command.

When a database is involved in code, we have to use a test database to interact with our tests rather than the actual database we work with.

Initialize the test with database initialization, in the user_test.go file:

package user

import (
  "testing"

  "github.com/gin-gonic/gin"
  "github.com/jmoiron/sqlx"
)

var testDb *sqlx.DB
var err error

func TestInit(t *testing.T) {
  testDb, err = TestDB() // connection of your test database
  if err != nil {
    t.Errorf("Error in initializing test DB: %v", err)
  }
}

Now, Let’s add functions into user_test.go that manages test database operations to operate dummy data while testing the APIs.

// create users table
func CreateUsersTable(Db *sqlx.DB) {
  Db.MustExec(`CREATE TABLE IF NOT EXISTS users 
  (id int(11) NOT NULL AUTO_INCREMENT,
  name varchar(195) default null,
  email varchar(195) default null
  primary key (id));`)
}

// insert user
func InsertIntoUsersTable(Db *sqlx.DB) {
  Db.MustExec("INSERT INTO users(name, email) VALUES('John Doe', 'john@example.com');")
}

// drop users table
func DropUsersTable(Db *sqlx.DB) {
  Db.MustExec(`DROP TABLE IF EXISTS users`)
}

// make json data structure
func GotData(w *httptest.ResponseRecorder, t *testing.T) map[string]interface{} {
  var got map[string]interface{}
  if len(w.Body.Bytes()) != 0 {
  err := json.Unmarshal(w.Body.Bytes(), &got)
    if err != nil {
      t.Fatal(err)
    }
  }
  return got
}

Create a user using TDD

Create User — Bad Request Test (case 1):

Let’s say you want to test that if a request body is missing or has invalid data, then the API should return a 400(Bad Request)status code.

Your test might look something like this:

func TestCreateUserBadRequest(t *testing.T) {

  // required user table operations
  DropUsersTable(testDb)
  CreateUsersTable(testDb)

  router := gin.Default()
  router.POST("/api/users", Create)

  engine := gin.New()
  
  req, err := http.NewRequest("POST", "/api/users", bytes.NewBuffer([]byte(`{"email": 123}`)))
  if err != nil {
    t.Errorf("Error in creating request: %v", err)
  }
  
  req.Header.Set("Content-Type", "application/json")
  
  w := httptest.NewRecorder()
  engine.ServeHTTP(w, req)
  
  assert.EqualValues(t, http.StatusBadRequest, w.Code)
}

Modify the Create() function like the below in user.go file.

func Create(c *gin.Context) {  
  input := User{}
  err = c.ShouldBindWith(&input, binding.JSON)
  if err != nil {
    c.AbortWithStatus(http.StatusBadRequest)
    return
  }
}

Create User — Success test (case 2):

Let’s test if a request body is sufficient and the API runs successfully adding users as a result. The API should return a 200(OK) status.

Your test might look something like this:

func TestCreateUserSuccess(t *testing.T) {

  DropUsersTable(testDb)
  CreateUsersTable(testDb)

  router := gin.Default()
  router.POST("/api/users", Create)

  engine := gin.New()
  
  req, err := http.NewRequest("POST", "/api/users", bytes.NewBuffer([]byte(`{"name":"John Doe","email":"john@example.com"}`)))
  if err != nil {
    t.Errorf("Error in creating request: %v", err)
  }
  
  req.Header.Set("Content-Type", "application/json")
  
  w := httptest.NewRecorder()
  engine.ServeHTTP(w, req)
  
  assert.EqualValues(t, http.StatusOK, w.Code)
}

Modify the Create() function like below.

func Create(c *gin.Context) {
  input := User{}
  err = c.ShouldBindWith(&input, binding.JSON)
  if err != nil {
    c.AbortWithStatus(http.StatusBadRequest)
    return
  }
  
  _, err = db.Exec("INSERT INTO users (name, email) VALUES (?, ?)", input.Name, input.Email)
  if err != nil {
    c.AbortWithStatus(http.StatusInternalServerError)
    return
  }
  
  c.JSON(http.StatusOK, gin.H{})
}

Run the test using go test -v user_test.go , you will see the tests passing.


Get User using TDD

Get User — Bad Request Test (case 1):

When the endpoint doesn’t contain valid params or no params at all, the Get user API should return a 400(Bad Request) code.

Your test might look like this:

func TestGetUserBadRequest(t *testing.T) {

  DropUsersTable(testDb)
  CreateUsersTable(testDb)

  router := gin.Default()
  router.GET("/api/users/:id", Get)

  engine := gin.New()
  
  req, err := http.NewRequest("GET", "/api/users/", nil)
  if err != nil {
    t.Errorf("Error in creating request: %v", err)
  }
  
  req.Header.Set("Content-Type", "application/json")
  
  w := httptest.NewRecorder()
  engine.ServeHTTP(w, req)
  
  assert.EqualValues(t, http.StatusBadRequest, w.Code)
}

Modify Get() function like the below, in the user.go file.

func Get(c *gin.Context) {
  id, err := strconv.Atoi(c.Param("id"))
  if err != nil {
    c.AbortWithStatus(http.StatusBadRequest)
    return
  }
}

Get User — Not Found Test (case 2):

Let’s say you want to test a request to non-existing user information and return a 404(Not Found) status code and an empty JSON response. Your test might look something like this:

Test:

func TestGetUserNotFound(t *testing.T) {

  DropUsersTable(testDb)
  CreateUsersTable(testDb)

  router := gin.Default()
  router.GET("/api/users/:id", Get)

  engine := gin.New()
  
  req, err := http.NewRequest("GET", "/api/users/1", nil)
  if err != nil {
    t.Errorf("Error in creating request: %v", err)
  }
  
  req.Header.Set("Content-Type", "application/json")
  
  w := httptest.NewRecorder()
  engine.ServeHTTP(w, req)
  
  assert.EqualValues(t, http.StatusNotFound, w.Code)
}

Modify the Get() function like the below.

func Get(c *gin.Context) {
  id, err := strconv.Atoi(c.Param("id"))
  if err != nil {
    c.AbortWithStatus(http.StatusInternalServerError)
    return
  }
  
  var user User
  row := db.QueryRow("SELECT id, name, email FROM users WHERE id = ?", id)
  
  err = row.Scan(&user.Id, &user.Name, &user.Email)
  if err != nil {
    if err == sql.ErrNoRows {
      c.AbortWithStatus(http.StatusNotFound)
      return
    }
    c.AbortWithStatus(http.StatusInternalServerError)
    return
  }
}

In the test, we haven’t added any user to the table, but we’re requesting a user with id=1 and so it will fail with the Not Found(404) status.

Get User — Success test (case 3):

Let’s test the function when we’ve added a user and we’re receiving it with a valid id. The Get User API should return a user with an OK(200) status code and the user object.

Test:

func TestGetUserSuccess(t *testing.T) {

  DropUsersTable(testDb)
  CreateUsersTable(testDb)
  InsertIntoUsersTable(testDb)

  router := gin.Default()
  router.GET("/api/users/:id", Get)

  engine := gin.New()
  
  req, err := http.NewRequest("GET", "/api/users/1", nil)
  if err != nil {
    t.Errorf("Error in creating request: %v", err)
  }
  
  req.Header.Set("Content-Type", "application/json")
  
  w := httptest.NewRecorder()
  engine.ServeHTTP(w, req)
  
  assert.EqualValues(t, http.StatusOK, w.Code)
  
  got := GotData(w, t)
  expected := `{"id":1, "name":"John Doe"}`

  assert.Equal(t, expected, got)
}

Modify the Get() function like the one below, in the user.go file.

func Get(c *gin.Context) {
  id, err := strconv.Atoi(c.Param("id"))
  if err != nil {
    c.AbortWithStatus(http.StatusInternalServerError)
    return
  }
  
  var user User
  row := db.QueryRow("SELECT id, name, email FROM users WHERE id = ?", id)
  
  err = row.Scan(&user.Id, &user.Name, &user.Email)
  if err != nil {
    if err == sql.ErrNoRows {
      c.AbortWithStatus(http.StatusNotFound)
      return
    }
    c.AbortWithStatus(http.StatusInternalServerError)
    return
  }

  c.JSON(http.StatusOK, user)
}

Run the test using go test -v user_test.go


Update User using TDD

Update User — Bad Request Test (case 1):

Let’s say you want to test that a request with missing or invalid data returns a 400(Bad Request) status code and an empty JSON response.

Your test might look something like this:

func TestUpdateUserBadRequest(t *testing.T) {

  DropUsersTable(testDb)
  CreateUsersTable(testDb)

  router := gin.Default()
  router.PUT("/api/users/:id", Update)

  engine := gin.New()
  
  req, err := http.NewRequest("PUT", "/api/users/1", bytes.NewBuffer([]byte(`{"email": 123}`)))
  if err != nil {
    t.Errorf("Error in creating request: %v", err)
  }
  
  req.Header.Set("Content-Type", "application/json")
  
  w := httptest.NewRecorder()
  engine.ServeHTTP(w, req)
  
  assert.EqualValues(t, http.StatusBadRequest, w.Code)
}

Modify Update() function like the one below, in user.go file.

func Update(c *gin.Context) {
  id, err := strconv.Atoi(c.Param("id"))
  if err != nil {
    c.AbortWithStatus(http.StatusBadRequest)
    return
  }

  input := User{}
  err = c.ShouldBindWith(&input, binding.JSON)
  if err != nil {
    c.AbortWithStatus(http.StatusBadRequest)
    return
  }
}

Update User —Not Found Test (case 2):

Let’s say you want to test the case when request data is valid but the user we want to update doesn’t exist at all. The API should return a 404(Not Found) status code and an empty JSON response.

Your test might look something like this:

func TestUpdateUserNotFound(t *testing.T) {

  DropUsersTable(testDb)
  CreateUsersTable(testDb)
  InsertIntoUsersTable(testDb)

  router := gin.Default()
  router.PUT("/api/users/:id", Update)

  engine := gin.New()
  
  req, err := http.NewRequest("PUT", "/api/users/5", bytes.NewBuffer([]byte(`{"name":"John Doe","email":"john@example.com"}`)))
  if err != nil {
    t.Errorf("Error in creating request: %v", err)
  }
  
  req.Header.Set("Content-Type", "application/json")
  
  w := httptest.NewRecorder()
  engine.ServeHTTP(w, req)
  
  assert.EqualValues(t, http.StatusNotFound, w.Code)
}

Modify the Update() function like the below.

func Update(c *gin.Context) {
  id, err := strconv.Atoi(c.Param("id"))
  if err != nil {
    c.AbortWithStatus(http.StatusInternalServerError)
    return
  }

  input := User{}
  err = c.ShouldBindWith(&input, binding.JSON)
  if err != nil {
    c.AbortWithStatus(http.StatusBadRequest)
    return
  }

  var user User
  row := db.QueryRow("SELECT id, name, email FROM users WHERE id = ?", id)
  err = row.Scan(&user.Id, &user.Name, &user.Email)
  if err != nil {
    if err == sql.ErrNoRows {
      c.AbortWithStatus(http.StatusNotFound)
      return
    }
    c.AbortWithStatus(http.StatusInternalServerError)
    return
  }
}

Update User — Success Test (case 3):

Let’s test the successful use case of updating user API, where the API returns an OK(200) status code with the updated user. Your test might look something like this:

Test:

func TestUpdateUserSuccess(t *testing.T) {

  DropUsersTable(testDb)
  CreateUsersTable(testDb)
  InsertIntoUsersTable(testDb)

  router := gin.Default()
  router.PUT("/api/users/:id", Update)

  engine := gin.New()
  
  req, err := http.NewRequest("PUT", "/api/users/1", bytes.NewBuffer([]byte(`{"name":"John Doe","email":"john@example.com"}`)))
  if err != nil {
    t.Errorf("Error in creating request: %v", err)
  }
  
  req.Header.Set("Content-Type", "application/json")
  
  w := httptest.NewRecorder()
  engine.ServeHTTP(w, req)
  
  assert.EqualValues(t, http.StatusOK, w.Code)
  
  got := GotData(w, t)
  assert.Empty(t, got)
}

Modify the Update() function like below.

func Update(c *gin.Context) {
  id, err := strconv.Atoi(c.Param("id"))
  if err != nil {
    c.AbortWithStatus(http.StatusInternalServerError)
    return
  }

  input := User{}
  err = c.ShouldBindWith(&input, binding.JSON)
  if err != nil {
    c.AbortWithStatus(http.StatusBadRequest)
    return
  }

  var user User
  row := db.QueryRow("SELECT id, name, email FROM users WHERE id = ?", id)
  err = row.Scan(&user.Id, &user.Name, &user.Email)
  if err != nil {
    if err == sql.ErrNoRows {
      c.AbortWithStatus(http.StatusNotFound)
      return
    }
    c.AbortWithStatus(http.StatusInternalServerError)
    return
  }

  _, err = db.Exec("UPDATE users SET name = ?, email = ? WHERE id = ?", input.Name, input.Email, id)
  if err != nil {
    c.AbortWithStatus(http.StatusInternalServerError)
    return
  }

  c.JSON(http.StatusOK, gin.H{})
}

Run the test using go test -v user_test.go


Delete user with TDD

Delete User — Not Found Test (case 1):

Let’s say you want to delete the user which doesn’t exist at all. In that case, an API should return a Not Found(404) status code.

Your test might look something like this:

func TestDeleteUserNotFound(t *testing.T) {

  DropUsersTable(testDb)
  CreateUsersTable(testDb)

  router := gin.Default()
  router.DELETE("/api/users/:id", Delete)

  engine := gin.New()
  
  req, err := http.NewRequest("DELETE", "/api/users/5", nil)
  if err != nil {
    t.Errorf("Error in creating request: %v", err)
  }
  
  req.Header.Set("Content-Type", "application/json")
  
  w := httptest.NewRecorder()
  engine.ServeHTTP(w, req)
  
  assert.EqualValues(t, http.StatusNotFound, w.Code)
}

Modify the Delete() function like below, in user.go file.

func Delete(c *gin.Context) {
  id, err := strconv.Atoi(c.Param("id"))
  if err != nil {
    c.AbortWithStatus(http.StatusBadRequest)
    return
  }
  
  var user User
  row := db.QueryRow("SELECT id, name, email FROM users WHERE id = ?", id)
  err = row.Scan(&user.Id, &user.Name, &user.Email)
  if err != nil {
    if err == sql.ErrNoRows {
      c.AbortWithStatus(http.StatusNotFound)
      return
    }
    c.AbortWithStatus(http.StatusInternalServerError)
    return
  }
}

Delete User — Success Test (case 2):

Let’s test the delete user's successful attempt. An API will return a OK(200) status code.

Test:

func TestDeleteUserSuccess(t *testing.T) {

  DropUsersTable(testDb)
  CreateUsersTable(testDb)
  InsertIntoUsersTable(testDb)

  router := gin.Default()
  router.DELETE("/api/users/:id", Delete)

  engine := gin.New()
  
  req, err := http.NewRequest("DELETE", "/api/users/1", nil)
  if err != nil {
    t.Errorf("Error in creating request: %v", err)
  }
  
  req.Header.Set("Content-Type", "application/json")
  
  w := httptest.NewRecorder()
  engine.ServeHTTP(w, req)
  
  assert.EqualValues(t, http.StatusOK, w.Code)
}

Modify the Delete() function like below.

func Delete(c *gin.Context) {
  id, err := strconv.Atoi(c.Param("id"))
  if err != nil {
    c.AbortWithStatus(http.StatusInternalServerError)
    return
  }
  
  var user User
  row := db.QueryRow("SELECT id, name, email FROM users WHERE id = ?", id)
  err = row.Scan(&user.Id, &user.Name, &user.Email)
  if err != nil {
    if err == sql.ErrNoRows {
      c.AbortWithStatus(http.StatusNotFound)
      return
    }
    c.AbortWithStatus(http.StatusInternalServerError)
    return
  }
  
  db.Exec("DELETE FROM users WHERE id = ?", id)
  
  c.JSON(http.StatusOK, gin.H{})
}

Run the test using go test -v user_test.go

Run tests of particular files using the command, like the go test -v "test_file_name” command, in our case it’s go test -v user_test.go .
Also, you can run multiple test suits using the commandgo test or go test -v (if you many) on the root directory of the package.

Repeat all the steps for any additional functionality you want to add to the API.

It is important to keep in mind that in TDD you should write the minimum code necessary to pass the test. This helps you to focus on the requirements of the code and not on unnecessary features.

Please note that this is just a basic example and the real-world implementation can be more complex with different types of test cases and more robust testing frameworks.

Find the complete source code at — Golang: TDD with Gin and MySQL.


Final Thoughts

In this blog post, we’ve covered the implementation of crud operation with Test-Driven Development (TDD).

TDD is a software development approach in which tests are written before the implementation of the code. The goal of TDD is to ensure that the code meets the requirements and behaves as expected by constantly testing the code during the development process.

This can help to catch bugs early on, improve code quality, and increase confidence in the software.

TDD is an iterative process that begins with writing a test, running it to confirm it fails, then writing the minimum amount of code to make the test pass and repeating this process until the feature is complete.

Thanks for reading!! 👋


Similar articles


dharti.r image
Dharti Ramoliya
Web developer at canopas | Eager to Elevate Your Web Experience


dharti.r image
Dharti Ramoliya
Web developer at canopas | Eager to Elevate Your Web Experience


Talk to an expert
get intouch
Our team is happy to answer your questions. Fill out the form and we’ll get back to you as soon as possible
footer
Subscribe Here!
Follow us on
2024 Canopas Software LLP. All rights reserved.