DEV Community

Cover image for Golang Integration Test With Gin, Gorm, Testify, PostgreSQL
Truong Phung
Truong Phung

Posted on

Golang Integration Test With Gin, Gorm, Testify, PostgreSQL

Creating a comprehensive integration test setup in Golang with Gin, GORM, Testify, and PostgreSQL involves setting up a test database, writing tests for CRUD operations, and using Testify for assertions. Here’s a step-by-step guide to get you started:

Prerequisites

  • Go installed
  • Docker installed
  • Libraries: gin-gonic/gin, gorm.io/gorm, gorm.io/driver/postgres, testify, testcontainers-go

Project Structure

myapp/
|-- main.go
|-- models/
|   |-- models.go
|-- handlers/
|   |-- handlers.go
|-- tests/
|   |-- integration_test.go
|-- go.mod
|-- go.sum
Enter fullscreen mode Exit fullscreen mode

1. Setup the Models (models/models.go)

Define the models with GORM tags for database mapping.

package models

import (
    "time"
    "gorm.io/gorm"
)

type User struct {
    ID        uint           `gorm:"primaryKey"`
    Name      string         `gorm:"not null"`
    Email     string         `gorm:"unique;not null"`
    CreatedAt time.Time
}

type Book struct {
    ID            uint           `gorm:"primaryKey"`
    Title         string         `gorm:"not null"`
    Author        string         `gorm:"not null"`
    PublishedDate time.Time      `gorm:"not null"`
}

type BorrowLog struct {
    ID         uint           `gorm:"primaryKey"`
    UserID     uint           `gorm:"not null"`
    BookID     uint           `gorm:"not null"`
    BorrowedAt time.Time      `gorm:"default:CURRENT_TIMESTAMP"`
    ReturnedAt *time.Time
}
Enter fullscreen mode Exit fullscreen mode

2. Setup Handlers (handlers/handlers.go)

Define the routes and handlers for CRUD operations using Gin.

package handlers

import (
    "myapp/models"
    "net/http"

    "github.com/gin-gonic/gin"
    "gorm.io/gorm"
)

type Handler struct {
    DB *gorm.DB
}

func (h *Handler) CreateUser(c *gin.Context) {
    var user models.User
    if err := c.ShouldBindJSON(&user); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    if err := h.DB.Create(&user).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusCreated, user)
}

func (h *Handler) GetUser(c *gin.Context) {
    var user models.User
    if err := h.DB.First(&user, c.Param("id")).Error; err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
        return
    }

    c.JSON(http.StatusOK, user)
}

func (h *Handler) UpdateUser(c *gin.Context) {
    var user models.User
    if err := h.DB.First(&user, c.Param("id")).Error; err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
        return
    }

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

    if err := h.DB.Save(&user).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, user)
}

func (h *Handler) DeleteUser(c *gin.Context) {
    if err := h.DB.Delete(&models.User{}, c.Param("id")).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, gin.H{"message": "User deleted"})
}
Enter fullscreen mode Exit fullscreen mode

3. Main Application (main.go)

Set up the database connection and routes.

package main

import (
    "myapp/handlers"
    "myapp/models"
    "github.com/gin-gonic/gin"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
    "log"
    "os"
)

func main() {
    dsn := "host=localhost user=postgres password=yourpassword dbname=testdb port=5432 sslmode=disable"
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatalf("failed to connect to database: %v", err)
    }

    // Auto migrate the models
    db.AutoMigrate(&models.User{}, &models.Book{}, &models.BorrowLog{})

    h := handlers.Handler{DB: db}

    r := gin.Default()

    r.POST("/users", h.CreateUser)
    r.GET("/users/:id", h.GetUser)
    r.PUT("/users/:id", h.UpdateUser)
    r.DELETE("/users/:id", h.DeleteUser)

    r.Run(":8080")
}
Enter fullscreen mode Exit fullscreen mode

4. Integration Test (tests/integration_test.go)

Use Testify for setting up and asserting test results.

For database we can use a Dockerized PostgreSQL instance for testing purposes, which is isolated and can be quickly torn down after tests. Here’s how to set it up in Golang using testcontainers-go:

Install testcontainers-go:

go get github.com/testcontainers/testcontainers-go
Enter fullscreen mode Exit fullscreen mode

Following is the integration_test.go file that sets up a PostgreSQL container for testing:

package tests

import (
    "context"
    "fmt"
    "myapp/handlers"
    "myapp/models"
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/stretchr/testify/assert"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/wait"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

var db *gorm.DB
var h *handlers.Handler

func setupTestDB() {
    ctx := context.Background()

    // Create PostgreSQL container
    req := testcontainers.ContainerRequest{
        Image:        "postgres:latest",
        ExposedPorts: []string{"5432/tcp"},
        Env: map[string]string{
            "POSTGRES_PASSWORD": "password",
            "POSTGRES_DB":       "testdb",
        },
        WaitingFor: wait.ForListeningPort("5432/tcp").WithStartupTimeout(60 * time.Second),
    }

    postgresC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: req,
        Started:          true,
    })
    if err != nil {
        panic(err)
    }

    // Get the container's host and port
    host, _ := postgresC.Host(ctx)
    port, _ := postgresC.MappedPort(ctx, "5432")

    dsn := fmt.Sprintf("host=%s port=%s user=postgres password=password dbname=testdb sslmode=disable", host, port.Port())

    // Connect to the PostgreSQL database
    db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        panic("failed to connect to database")
    }

    // Migrate the schema
    db.AutoMigrate(&models.User{}, &models.Book{}, &models.BorrowLog{})

    // Initialize the handler
    h = &handlers.Handler{DB: db}

    // Clean up database before each test
    db.Exec("DELETE FROM users")

    // Tear down the container after tests
    defer postgresC.Terminate(ctx)
}

func TestCreateUser(t *testing.T) {
    setupTestDB()

    r := gin.Default()
    r.POST("/users", h.CreateUser)

    user := models.User{
        Name:  "Test User",
        Email: "testuser@example.com",
    }

    jsonData, _ := json.Marshal(user)
    req, _ := http.NewRequest(http.MethodPost, "/users", bytes.NewBuffer(jsonData))
    req.Header.Set("Content-Type", "application/json")

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

    assert.Equal(t, http.StatusCreated, w.Code)

    var createdUser models.User
    err := json.Unmarshal(w.Body.Bytes(), &createdUser)
    assert.Nil(t, err)
    assert.Equal(t, user.Name, createdUser.Name)
    assert.Equal(t, user.Email, createdUser.Email)
}

func TestGetUser(t *testing.T) {
    setupTestDB()

    // Create a user
    user := models.User{
        Name:  "Test User",
        Email: "testuser@example.com",
    }
    db.Create(&user)

    r := gin.Default()
    r.GET("/users/:id", h.GetUser)

    req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/users/%d", user.ID), nil)
    w := httptest.NewRecorder()
    r.ServeHTTP(w, req)

    assert.Equal(t, http.StatusOK, w.Code)

    var fetchedUser models.User
    err := json.Unmarshal(w.Body.Bytes(), &fetchedUser)
    assert.Nil(t, err)
    assert.Equal(t, user.Name, fetchedUser.Name)
    assert.Equal(t, user.Email, fetchedUser.Email)
}

func TestUpdateUser(t *testing.T) {
    setupTestDB()

    // Create a user to be updated.
    user := models.User{
        Name:  "Original User",
        Email: "original@example.com",
    }
    db.Create(&user)

    r := gin.Default()
    r.PUT("/users/:id", h.UpdateUser)

    updatedData := models.User{
        Name:  "Updated User",
        Email: "updated@example.com",
    }

    jsonData, _ := json.Marshal(updatedData)
    req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(jsonData))
    req.Header.Set("Content-Type", "application/json")

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

    assert.Equal(t, http.StatusOK, w.Code)

    var updatedUser models.User
    err := json.Unmarshal(w.Body.Bytes(), &updatedUser)
    assert.Nil(t, err)
    assert.Equal(t, updatedData.Name, updatedUser.Name)
    assert.Equal(t, updatedData.Email, updatedUser.Email)

    // Verify that the user is actually updated in the database.
    var userInDB models.User
    db.First(&userInDB, user.ID)
    assert.Equal(t, updatedData.Name, userInDB.Name)
    assert.Equal(t, updatedData.Email, userInDB.Email)
}

func TestDeleteUser(t *testing.T) {
    setupTestDB()

    // Create a user to be deleted.
    user := models.User{
        Name:  "Delete User",
        Email: "delete@example.com",
    }
    db.Create(&user)

    r := gin.Default()
    r.DELETE("/users/:id", h.DeleteUser)

    req, _ := http.NewRequest(http.MethodDelete, fmt.Sprintf("/users/%d", user.ID), nil)
    w := httptest.NewRecorder()
    r.ServeHTTP(w, req)

    assert.Equal(t, http.StatusOK, w.Code)

    // Verify the response message.
    var response map[string]string
    err := json.Unmarshal(w.Body.Bytes(), &response)
    assert.Nil(t, err)
    assert.Equal(t, "User deleted", response["message"])

    // Verify that the user is actually deleted from the database.
    var userInDB models.User
    result := db.First(&userInDB, user.ID)
    assert.Error(t, result.Error)
    assert.Equal(t, gorm.ErrRecordNotFound, result.Error)
}

Enter fullscreen mode Exit fullscreen mode

Explanation

  • SetupTestDB: Sets up a PostgreSQL database connection using GORM for testing.
  • TestCreateUser: Sends a POST request to create a new user and asserts the response.
  • TestGetUser: Retrieves a user by ID and checks that the data matches what was inserted.
  • TestUpdateUser:
    • Creates a user and updates it using the PUT /users/:id endpoint.
    • Asserts that the response status is 200 OK.
    • Verifies that the user's details are updated in the response.
    • Fetches the user from the database and confirms that the changes are persisted.
  • TestDeleteUser:
    • Creates a user and deletes it using the DELETE /users/:id endpoint.
    • Asserts that the response status is 200 OK and checks for a success message.
    • Attempts to fetch the deleted user from the database to ensure the user no longer exists, asserting an error of gorm.ErrRecordNotFound.
  • testcontainers-go: This library allows you to spin up Docker containers directly from your Go code. It's ideal for creating a temporary PostgreSQL instance for integration tests.
  • setupTestDB: This function starts a PostgreSQL Docker container, connects to it using gorm, and sets up the database schema. It also ensures that the container is cleaned up after the tests are finished.
  • defer postgresC.Terminate(ctx): Ensures that the PostgreSQL container is terminated after tests are done, simulating an in-memory approach.
  • Dynamic Host and Port: Uses the container's dynamically allocated host and port for connecting to the database.

Running the Tests

Run the tests using:

go test ./tests -v
Enter fullscreen mode Exit fullscreen mode
Benefits of Using testcontainers-go:
  1. Isolation: Each test run gets a fresh PostgreSQL instance, ensuring no data leakage between tests.
  2. Replicates Production Environment: Testing against a real PostgreSQL instance provides more reliable results than using an in-memory database.
  3. Automation: Automatically starts and stops the PostgreSQL container, making it easy to use in CI/CD pipelines.

Key Points

  • Using a Test Database: It's a good practice to use a separate PostgreSQL database (ex: containerized ones) for testing to avoid affecting production data.
  • Setup and Cleanup: Ensure to clean up the database between tests to maintain consistency.
  • Testify: Provides powerful assertion methods for validating the results.
  • Gin's Test Server: Uses httptest for simulating HTTP requests against the Gin server.

With this setup, you can test CRUD operations for a User model, ensuring the API works as expected with PostgreSQL. You can expand the tests similarly for Book and BorrowLog models.

Top comments (0)