Skip to main content

Gin TDD with Gin

Introduction

Test-Driven Development (TDD) is a software development approach that emphasizes writing tests before writing the actual code. In this guide, we'll explore how to apply TDD principles when building web applications using the popular Gin web framework for Go.

TDD follows a simple cycle:

  1. Write a failing test
  2. Implement the minimal code to make the test pass
  3. Refactor the code while keeping the tests passing

By following this approach, you'll create more robust APIs, catch bugs early, and build a comprehensive test suite that provides confidence when refactoring or adding new features.

Setting Up Your Testing Environment

Before diving into TDD with Gin, let's set up a proper testing environment.

Required Packages

go
import (
"net/http"
"net/http/httptest"
"testing"
"encoding/json"
"bytes"

"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)

Creating a Test Helper Function

Let's create a helper function that will make testing Gin handlers easier:

go
// setupRouter returns a gin router for testing
func setupRouter() *gin.Engine {
// Set Gin to test mode
gin.SetMode(gin.TestMode)

// Create a gin router with default middleware
r := gin.Default()

return r
}

Your First TDD Cycle with Gin

Let's implement a simple endpoint that returns user information using the TDD approach.

Step 1: Write the First Test - Getting a User

go
func TestGetUserEndpoint(t *testing.T) {
// Setup
router := setupRouter()

// Register the route we want to test
router.GET("/user/:id", GetUser)

// Create a test request
req, _ := http.NewRequest("GET", "/user/123", nil)
w := httptest.NewRecorder()

// Serve the request
router.ServeHTTP(w, req)

// Assert status code
assert.Equal(t, http.StatusOK, w.Code)

// Parse response body
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)

// Assert no error during JSON parsing
assert.Nil(t, err)

// Assert response data
assert.Equal(t, float64(123), response["id"])
assert.Equal(t, "Test User", response["name"])
}

If you run this test now, it will fail because we haven't implemented the GetUser handler yet.

Step 2: Implement the Minimal Code to Pass the Test

go
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}

func GetUser(c *gin.Context) {
// Extract the user ID from the URL parameter
idParam := c.Param("id")

// In a real app, we would fetch from a database
// For now, we'll just return a dummy user
userId := 123

// Return the user
c.JSON(http.StatusOK, User{
ID: userId,
Name: "Test User",
})
}

Now if you run the test again, it should pass!

Step 3: Refactor the Code

Let's improve the code by properly parsing the ID parameter:

go
func GetUser(c *gin.Context) {
// Extract the user ID from the URL parameter
idParam := c.Param("id")

// Convert string ID to int
userId, err := strconv.Atoi(idParam)

if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid user ID",
})
return
}

// In a real app, we would fetch from a database
// For now, we'll just return a dummy user matching the ID
c.JSON(http.StatusOK, User{
ID: userId,
Name: "Test User",
})
}

Step 4: Extend the Test to Cover Error Handling

Now we need to update our tests to cover the error case:

go
func TestGetUserInvalidID(t *testing.T) {
// Setup
router := setupRouter()
router.GET("/user/:id", GetUser)

// Create a test request with invalid ID
req, _ := http.NewRequest("GET", "/user/invalid", nil)
w := httptest.NewRecorder()

// Serve the request
router.ServeHTTP(w, req)

// Assert status code
assert.Equal(t, http.StatusBadRequest, w.Code)

// Parse response body
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)

// Assert no error during JSON parsing
assert.Nil(t, err)

// Assert error message
assert.Contains(t, response, "error")
}

Testing POST Requests with JSON Payloads

Let's continue our TDD journey by implementing a user creation endpoint.

Step 1: Write the Test for Creating a User

go
func TestCreateUser(t *testing.T) {
// Setup
router := setupRouter()
router.POST("/users", CreateUser)

// Create request payload
payload := `{"name": "New User", "email": "[email protected]"}`

// Create a test request
req, _ := http.NewRequest("POST", "/users", bytes.NewBufferString(payload))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()

// Serve the request
router.ServeHTTP(w, req)

// Assert status code
assert.Equal(t, http.StatusCreated, w.Code)

// Parse response body
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)

// Assert no error during JSON parsing
assert.Nil(t, err)

// Assert response contains the created user
assert.Contains(t, response, "id")
assert.Equal(t, "New User", response["name"])
assert.Equal(t, "[email protected]", response["email"])
}

Step 2: Implement the User Creation Handler

go
type CreateUserRequest struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
}

func CreateUser(c *gin.Context) {
var req CreateUserRequest

// Bind JSON payload to the request struct
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}

// In a real app, we would save to a database and get an ID
// For now, just create a fake ID
newUser := map[string]interface{}{
"id": 1,
"name": req.Name,
"email": req.Email,
}

c.JSON(http.StatusCreated, newUser)
}

Step 3: Add Test for Validation Errors

go
func TestCreateUserValidationError(t *testing.T) {
// Setup
router := setupRouter()
router.POST("/users", CreateUser)

// Create invalid request payload (missing email)
payload := `{"name": "New User"}`

// Create a test request
req, _ := http.NewRequest("POST", "/users", bytes.NewBufferString(payload))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()

// Serve the request
router.ServeHTTP(w, req)

// Assert status code
assert.Equal(t, http.StatusBadRequest, w.Code)

// Parse response body
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)

// Assert no error during JSON parsing
assert.Nil(t, err)

// Assert error message
assert.Contains(t, response, "error")
}

Real-World Example: Building a RESTful Todo API

Let's apply our TDD approach to build a more complete example: a Todo API. We'll create endpoints for listing, getting, creating, updating, and deleting todos.

Setting Up the Todo Model and Store

First, let's define our Todo model:

go
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
CreatedAt time.Time `json:"created_at"`
}

// In-memory store for todos
var (
todos = make(map[int]Todo)
todoID = 1
todoMutex = &sync.Mutex{}
)

Writing Tests for Todo Endpoints

Here's a test for getting all todos:

go
func TestListTodos(t *testing.T) {
// Setup
router := setupRouter()
router.GET("/todos", ListTodos)

// Clear todos and add test data
todoMutex.Lock()
todos = map[int]Todo{
1: {ID: 1, Title: "Learn Gin", Completed: false, CreatedAt: time.Now()},
2: {ID: 2, Title: "Learn TDD", Completed: true, CreatedAt: time.Now()},
}
todoMutex.Unlock()

// Create a test request
req, _ := http.NewRequest("GET", "/todos", nil)
w := httptest.NewRecorder()

// Serve the request
router.ServeHTTP(w, req)

// Assert status code
assert.Equal(t, http.StatusOK, w.Code)

// Parse response body
var response []Todo
err := json.Unmarshal(w.Body.Bytes(), &response)

// Assert no error during JSON parsing
assert.Nil(t, err)

// Assert we got 2 todos
assert.Equal(t, 2, len(response))
}

Implementing the Todo Handlers

Now let's implement the handlers to make the tests pass:

go
// ListTodos returns all todos
func ListTodos(c *gin.Context) {
todoMutex.Lock()
defer todoMutex.Unlock()

todoList := make([]Todo, 0, len(todos))
for _, todo := range todos {
todoList = append(todoList, todo)
}

c.JSON(http.StatusOK, todoList)
}

// GetTodo returns a specific todo by ID
func GetTodo(c *gin.Context) {
idParam := c.Param("id")
id, err := strconv.Atoi(idParam)

if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}

todoMutex.Lock()
todo, exists := todos[id]
todoMutex.Unlock()

if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"})
return
}

c.JSON(http.StatusOK, todo)
}

// CreateTodo creates a new todo
func CreateTodo(c *gin.Context) {
var req struct {
Title string `json:"title" binding:"required"`
}

if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

todoMutex.Lock()
defer todoMutex.Unlock()

newTodo := Todo{
ID: todoID,
Title: req.Title,
Completed: false,
CreatedAt: time.Now(),
}

todos[todoID] = newTodo
todoID++

c.JSON(http.StatusCreated, newTodo)
}

Setting Up Routes

Here's how you would set up all the routes in your application:

go
func setupTodoRoutes(r *gin.Engine) {
r.GET("/todos", ListTodos)
r.GET("/todos/:id", GetTodo)
r.POST("/todos", CreateTodo)
r.PUT("/todos/:id", UpdateTodo)
r.DELETE("/todos/:id", DeleteTodo)
}

func main() {
r := gin.Default()
setupTodoRoutes(r)
r.Run(":8080")
}

Testing Edge Cases

A robust test suite should also cover edge cases. Let's add some:

Testing Non-Existent Resources

go
func TestGetNonExistentTodo(t *testing.T) {
// Setup
router := setupRouter()
router.GET("/todos/:id", GetTodo)

// Create a test request for a non-existent todo
req, _ := http.NewRequest("GET", "/todos/999", nil)
w := httptest.NewRecorder()

// Serve the request
router.ServeHTTP(w, req)

// Assert status code
assert.Equal(t, http.StatusNotFound, w.Code)
}

Testing Malformed Request Data

go
func TestCreateTodoMalformedJSON(t *testing.T) {
// Setup
router := setupRouter()
router.POST("/todos", CreateTodo)

// Create a malformed JSON payload
payload := `{"title": "Test Todo` // Missing closing quote and brace

// Create a test request
req, _ := http.NewRequest("POST", "/todos", bytes.NewBufferString(payload))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()

// Serve the request
router.ServeHTTP(w, req)

// Assert status code
assert.Equal(t, http.StatusBadRequest, w.Code)
}

Testing with Test Tables

For more concise tests with multiple test cases, we can use table-driven tests:

go
func TestTodoEndpoints(t *testing.T) {
// Setup
router := setupRouter()
setupTodoRoutes(router)

// Clear todos and add test data
todoMutex.Lock()
todos = map[int]Todo{
1: {ID: 1, Title: "Test Todo", Completed: false, CreatedAt: time.Now()},
}
todoID = 2
todoMutex.Unlock()

tests := []struct {
name string
method string
url string
body string
wantStatus int
responseTest func(t *testing.T, body []byte)
}{
{
name: "Get all todos",
method: "GET",
url: "/todos",
wantStatus: http.StatusOK,
responseTest: func(t *testing.T, body []byte) {
var todos []Todo
err := json.Unmarshal(body, &todos)
assert.Nil(t, err)
assert.Equal(t, 1, len(todos))
},
},
{
name: "Get existing todo",
method: "GET",
url: "/todos/1",
wantStatus: http.StatusOK,
responseTest: func(t *testing.T, body []byte) {
var todo Todo
err := json.Unmarshal(body, &todo)
assert.Nil(t, err)
assert.Equal(t, 1, todo.ID)
assert.Equal(t, "Test Todo", todo.Title)
},
},
{
name: "Get non-existent todo",
method: "GET",
url: "/todos/999",
wantStatus: http.StatusNotFound,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var req *http.Request

if tt.body != "" {
req, _ = http.NewRequest(tt.method, tt.url, bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
} else {
req, _ = http.NewRequest(tt.method, tt.url, nil)
}

w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, tt.wantStatus, w.Code)

if tt.responseTest != nil {
tt.responseTest(t, w.Body.Bytes())
}
})
}
}

Summary

In this guide, we've explored how to apply Test-Driven Development principles to build robust APIs using the Gin framework. We covered:

  1. Setting up a testing environment for Gin applications
  2. Writing and running tests for GET and POST endpoints
  3. Implementing handlers based on tests
  4. Building a complete RESTful API using TDD
  5. Testing edge cases and error scenarios
  6. Using table-driven tests for more concise test code

TDD offers significant benefits when developing Gin applications:

  • It encourages better API design
  • It ensures all edge cases are properly handled
  • It provides a safety net for refactoring
  • It serves as documentation for how your API should behave

By adopting TDD practices in your Gin projects, you'll build more reliable and maintainable web applications.

Additional Resources

Exercises

  1. Add tests and implementations for the UpdateTodo and DeleteTodo handlers
  2. Implement filtering in the ListTodos endpoint (e.g., filter by completed status)
  3. Add pagination to the ListTodos endpoint with tests
  4. Create a middleware that logs all requests and write tests for it
  5. Build a simple authentication system with tests ensuring that certain routes are protected


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)