0

🧪 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

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
}

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"})
}

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")
}

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

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() (func(), error){
    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)
        return nil, 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")
        return nil, err
    }

    // 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)
    cleanup := func() {
		postgresC.Terminate(ctx)
	}
    
    return cleanup, nil
}

func TestCreateUser(t *testing.T) {

    // Set up test database
	cleanup, err := setupTestDB()
	if err != nil {
		t.Fatalf("failed to set up test DB: %v", err)
	}
	defer cleanup()

    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)
}

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
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.

5. Integration Tests With Migrations. (extension part)

Using golang-migrate with Testcontainers and httptest in integration tests is a practical approach to manage your test database schema. Here's how you can set it up step by step:

1. Start a Test Database with Testcontainers

Use the testcontainers-go library to spin up a containerized database (e.g., PostgreSQL or MySQL) during your integration tests.

Example (for PostgreSQL):

package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"testing"
	"time"

	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"
	_ "github.com/lib/pq" // PostgreSQL driver
)

func setupTestDB() (*sql.DB, string, func(), error) {
	ctx := context.Background()

	// Create a PostgreSQL container
	req := testcontainers.ContainerRequest{
		Image:        "postgres:15-alpine",
		ExposedPorts: []string{"5432/tcp"},
		Env: map[string]string{
			"POSTGRES_USER":     "test",
			"POSTGRES_PASSWORD": "test",
			"POSTGRES_DB":       "testdb",
		},
		WaitingFor: wait.ForSQL("5432/tcp", "postgres", func(port nat.Port) string {
			return fmt.Sprintf("host=localhost port=%s user=test password=test dbname=testdb sslmode=disable", port.Port())
		}),
	}

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

	// Get the container's host and port
	host, err := container.Host(ctx)
	if err != nil {
		return nil, "", nil, err
	}

	port, err := container.MappedPort(ctx, "5432/tcp")
	if err != nil {
		return nil, "", nil, err
	}

	// Create a database connection string
	dsn := fmt.Sprintf("host=%s port=%s user=test password=test dbname=testdb sslmode=disable", host, port.Port())

	// Open the database connection
	db, err := sql.Open("postgres", dsn)
	if err != nil {
		return nil, "", nil, err
	}

	// Return a cleanup function to stop the container
	cleanup := func() {
		container.Terminate(ctx)
	}

	return db, dsn, cleanup, nil
}

2. Apply Migrations with golang-migrate

Once the test database is up, use golang-migrate to apply your migrations.

Install golang-migrate library:

go get -u github.com/golang-migrate/migrate/v4

Example (applying migrations):

import (
	"github.com/golang-migrate/migrate/v4"
	"github.com/golang-migrate/migrate/v4/database/postgres"
	"github.com/golang-migrate/migrate/v4/source/file"
)

func applyMigrations(dsn string) error {
	driver, err := postgres.WithInstance(db, &postgres.Config{})
	if err != nil {
		return err
	}

	m, err := migrate.NewWithDatabaseInstance(
		"file://path/to/migrations", // Migration files location
		"postgres",                 // Database name
		driver,
	)
	if err != nil {
		return err
	}

	err = m.Up() // Apply all migrations
	if err != nil && err != migrate.ErrNoChange {
		return err
	}

	return nil
}

3. Use httptest for Integration Tests

Use httptest to create an HTTP server for your integration tests.

Example:

import (
	"net/http"
	"net/http/httptest"
)

func TestIntegration(t *testing.T) {
	// Set up test database
	db, dsn, cleanup, err := setupTestDB()
	if err != nil {
		t.Fatalf("failed to set up test DB: %v", err)
	}
	defer cleanup()

	// Apply migrations
	if err := applyMigrations(dsn); err != nil {
		t.Fatalf("failed to apply migrations: %v", err)
	}

	// Initialize your application with the test database
	app := NewApp(db) // Assume NewApp initializes your app

	// Create an HTTP test server
	server := httptest.NewServer(app.Router) // Assume Router is your HTTP router
	defer server.Close()

	// Perform test requests
	resp, err := http.Get(server.URL + "/some-endpoint")
	if err != nil {
		t.Fatalf("failed to make GET request: %v", err)
	}
	defer resp.Body.Close()

	// Assert response
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected status OK, got %v", resp.StatusCode)
	}
}

4. Best Practices

  • Use Isolated Test Containers: Each test should spin up its own database container to avoid interference.

  • Clean Up After Tests: Ensure containers and test resources are properly terminated after tests.

  • Seed Test Data: Use migrations or direct SQL inserts to seed data needed for the tests.

  • Parallel Tests: If running tests in parallel, ensure separate containers or databases are used for isolation.

This approach provides a clean, reliable way to test your application with real database interactions and migrations while leveraging Testcontainers and httptest.

If you found this helpful, let me know by leaving a 👍 or a comment!, or if you think this post could help someone, feel free to share it! Thank you very much! 😃


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí