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() { 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)
}
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.
- Creates a user and updates it using the
- 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
.
- Creates a user and deletes it using the
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 usinggorm
, 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
:
- Isolation: Each test run gets a fresh PostgreSQL instance, ensuring no data leakage between tests.
- Replicates Production Environment: Testing against a real PostgreSQL instance provides more reliable results than using an in-memory database.
- 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 theGin
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.