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.
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.
TDD is an iterative process that follows these steps.
Let’s examine the above steps with a basic example of a sum function. Which calculates the sum of two values.
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.
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,
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{})
}
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
:
user_test.go
in the same directory as the user
package.testing
package.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', '[email protected]');")
}
// 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
}
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
}
}
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":"[email protected]"}`)))
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.
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
}
}
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.
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
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
}
}
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":"[email protected]"}`)))
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
}
}
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":"[email protected]"}`)))
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
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
}
}
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.
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!! 👋