Testing Guide¶
Comprehensive guide to testing in the Go REST API Boilerplate (GRAB) project.
๐ Testing Philosophy¶
GRAB follows a pragmatic testing approach that balances thoroughness with maintainability:
- โ Integration tests for critical API flows
- โ Unit tests for complex business logic
- โ Fast execution using in-memory databases
- โ No external dependencies for CI/CD
- โ Table-driven tests for multiple scenarios
๐ฏ Types of Tests¶
1. Integration Tests¶
Location: tests/
directory
Purpose: Test the complete request/response cycle including handlers, services, and repositories.
When to use: - Testing API endpoints end-to-end - Verifying authentication flows - Testing CRUD operations - Validating error responses
Example:
func TestRegisterUser(t *testing.T) {
db := setupTestDB(t)
router := server.SetupRouter(db)
req := httptest.NewRequest("POST", "/api/v1/auth/register", body)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
2. Unit Tests¶
Location: Alongside the code (e.g., internal/user/service_test.go
)
Purpose: Test individual functions and methods in isolation.
When to use: - Testing business logic - Testing utility functions - Testing validation logic - Testing error handling
Example:
// internal/user/service_test.go
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
wantErr bool
}{
{"valid", "user@example.com", false},
{"invalid", "not-an-email", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateEmail(tt.email)
if (err != nil) != tt.wantErr {
t.Errorf("got error %v, wantErr %v", err, tt.wantErr)
}
})
}
}
3. End-to-End Tests (Future)¶
Location: e2e/
directory (not yet implemented)
Purpose: Test the entire system including database, external services, etc.
Planned for: v1.1.0+
โ Currently Implemented Tests¶
Integration Tests (tests/handler_test.go
)¶
Authentication Tests¶
TestRegisterUser - โ Successful user registration - โ Duplicate email handling - โ Invalid input validation - โ Password hashing verification - โ JWT token generation
TestLoginUser - โ Successful login with correct credentials - โ Failed login with wrong password - โ Failed login with non-existent user - โ JWT token validation
User Management Tests¶
TestGetUser - โ Get user by ID with authentication - โ Get non-existent user (404) - โ Unauthorized access
TestUpdateUser - โ Update user name and email - โ Unauthorized update attempt - โ Invalid data handling
TestDeleteUser - โ Delete user with authentication - โ Delete non-existent user - โ Unauthorized deletion
Test Helpers¶
setupTestDB(t) - Creates in-memory SQLite database - Runs migrations automatically - Returns configured GORM instance
createTestUser(t, db) - Creates a test user with hashed password - Returns user object - Used for authentication tests
getAuthToken(t, db) - Creates test user - Generates valid JWT token - Returns token string for authenticated requests
๐ Running Tests¶
Quick Commands¶
# Run all tests
make test
# Run all tests with verbose output
go test -v ./...
# Run tests with coverage
go test -cover ./...
# Run tests with coverage report
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
# Run specific test
go test -v ./tests -run TestRegisterUser
# Run tests in specific directory
go test ./tests/...
go test ./internal/user/...
# Run tests with race detection
go test -race ./...
CI/CD¶
Tests run automatically on: - โ
Push to main
branch - โ
Push to develop
branch - โ
Pull requests
GitHub Actions Workflow: .github/workflows/ci.yml
- name: Run tests
run: go test -v ./...
- name: Run linter
run: golangci-lint run
- name: Check go vet
run: go vet ./...
๐ Writing New Tests¶
Step 1: Determine Test Type¶
Integration Test (tests/) - Testing API endpoints - Testing complete flows - Multiple layers involved
Unit Test (internal/) - Testing single function - Testing business logic - Isolated component
Step 2: Create Test File¶
# Integration test
touch tests/my_feature_test.go
# Unit test (alongside code)
touch internal/mypackage/service_test.go
Step 3: Write Test¶
Integration Test Template¶
package tests
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/vahiiiid/go-rest-api-boilerplate/internal/server"
)
func TestMyFeature(t *testing.T) {
// Setup
db := setupTestDB(t)
router := server.SetupRouter(db)
// Prepare request
payload := map[string]string{
"field": "value",
}
body, _ := json.Marshal(payload)
// Make request
req := httptest.NewRequest("POST", "/api/v1/endpoint", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert response
if w.Code != http.StatusOK {
t.Errorf("Expected 200, got %d", w.Code)
}
// Parse response
var response map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
// Validate response data
if response["field"] != "expected" {
t.Errorf("Expected 'expected', got '%v'", response["field"])
}
}
Unit Test Template¶
package mypackage
import "testing"
func TestMyFunction(t *testing.T) {
tests := []struct {
name string
input string
expected string
wantErr bool
}{
{
name: "valid input",
input: "test",
expected: "TEST",
wantErr: false,
},
{
name: "empty input",
input: "",
expected: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := MyFunction(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("MyFunction() error = %v, wantErr %v", err, tt.wantErr)
return
}
if result != tt.expected {
t.Errorf("MyFunction() = %v, want %v", result, tt.expected)
}
})
}
}
Step 4: Test with Authentication¶
For protected endpoints, use the getAuthToken
helper:
func TestProtectedEndpoint(t *testing.T) {
db := setupTestDB(t)
router := server.SetupRouter(db)
// Get auth token
token := getAuthToken(t, db)
// Make authenticated request
req := httptest.NewRequest("GET", "/api/v1/users/1", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected 200, got %d", w.Code)
}
}
๐จ Testing Best Practices¶
1. Test Independence¶
Each test should be completely independent:
func TestFeatureA(t *testing.T) {
db := setupTestDB(t) // Fresh database for each test
// ... test logic
}
func TestFeatureB(t *testing.T) {
db := setupTestDB(t) // Another fresh database
// ... test logic
}
2. Use Subtests¶
Group related tests with t.Run()
:
func TestUserValidation(t *testing.T) {
t.Run("valid email", func(t *testing.T) {
// test valid email
})
t.Run("invalid email", func(t *testing.T) {
// test invalid email
})
t.Run("empty email", func(t *testing.T) {
// test empty email
})
}
3. Test Error Cases¶
Don't just test happy paths:
func TestCreateUser(t *testing.T) {
t.Run("success", func(t *testing.T) {
// test successful creation
})
t.Run("duplicate email", func(t *testing.T) {
// test duplicate email error
})
t.Run("invalid input", func(t *testing.T) {
// test validation errors
})
t.Run("database error", func(t *testing.T) {
// test database failure handling
})
}
4. Use Table-Driven Tests¶
For testing multiple scenarios:
func TestPasswordValidation(t *testing.T) {
tests := []struct {
name string
password string
wantErr bool
errMsg string
}{
{"valid password", "SecurePass123!", false, ""},
{"too short", "abc", true, "password too short"},
{"no numbers", "SecurePass!", true, "must contain number"},
{"no special chars", "SecurePass123", true, "must contain special character"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validatePassword(tt.password)
if (err != nil) != tt.wantErr {
t.Errorf("validatePassword() error = %v, wantErr %v", err, tt.wantErr)
}
if err != nil && err.Error() != tt.errMsg {
t.Errorf("error message = %v, want %v", err.Error(), tt.errMsg)
}
})
}
}
5. Clean Test Data¶
Use descriptive test data:
// Good
testUser := &user.User{
Name: "Test User",
Email: "test@example.com",
}
// Bad
testUser := &user.User{
Name: "aaa",
Email: "a@a.com",
}
6. Use Assertions¶
Consider using a testing library for cleaner assertions:
// Without library
if result != expected {
t.Errorf("got %v, want %v", result, expected)
}
// With testify/assert
assert.Equal(t, expected, result)
assert.NoError(t, err)
assert.NotNil(t, user)
๐๏ธ Test Database¶
SQLite In-Memory¶
GRAB uses SQLite in-memory database for tests:
Advantages: - โ Fast: No disk I/O - โ Isolated: Each test gets fresh database - โ No setup: No external database required - โ CI-friendly: Works in GitHub Actions
Setup:
func setupTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("Failed to connect to test database: %v", err)
}
// Run migrations
if err := db.AutoMigrate(&user.User{}); err != nil {
t.Fatalf("Failed to migrate test database: %v", err)
}
return db
}
PostgreSQL for E2E Tests (Future)¶
For end-to-end tests, use Docker PostgreSQL:
# Start test database
docker run -d --name test-postgres \
-e POSTGRES_PASSWORD=test \
-e POSTGRES_DB=test_db \
-p 5433:5432 \
postgres:15-alpine
# Run E2E tests
TEST_DB_PORT=5433 go test ./e2e/...
# Cleanup
docker rm -f test-postgres
๐ Test Coverage¶
Current Coverage¶
Run coverage report:
Target Coverage: 80%+
Coverage by Package¶
# Check coverage per package
go test -cover ./internal/user
go test -cover ./internal/auth
go test -cover ./tests
Improving Coverage¶
High Priority: - โ All API handlers - โ Authentication logic - โ User CRUD operations
Medium Priority: - โ ๏ธ Middleware functions - โ ๏ธ Validation logic - โ ๏ธ Error handling
Low Priority: - โน๏ธ Configuration loading - โน๏ธ Database connection - โน๏ธ Main function
๐ง Testing Tools¶
Installed Tools¶
Go Testing: Built-in testing framework
httptest: HTTP testing utilities
GORM SQLite: In-memory database
Recommended Tools (Optional)¶
testify/assert: Better assertions
gomock: Mocking framework
go-sqlmock: Database mocking
๐ Debugging Tests¶
Verbose Output¶
# See detailed test output
go test -v ./...
# See what tests are running
go test -v ./tests -run TestRegisterUser
Print Debugging¶
func TestMyFeature(t *testing.T) {
// Print request body
t.Logf("Request body: %s", body)
// Print response
t.Logf("Response: %s", w.Body.String())
// Print status code
t.Logf("Status code: %d", w.Code)
}
Failed Test Output¶
๐ Testing Roadmap¶
v1.1.0 (Current)¶
- โ Integration tests for all endpoints
- โ Authentication flow tests
- โ Request logging middleware tests
- โ Configuration management tests
- โ CRUD operation tests
- โ Error handling tests
v1.1.0 (Planned)¶
- โณ Unit tests for services
- โณ Middleware tests
- โณ Validation tests
- โณ 80%+ code coverage
v1.2.0 (Future)¶
- ๐ E2E tests with PostgreSQL
- ๐ Performance tests
- ๐ Load tests
- ๐ Security tests
v2.0.0 (Future)¶
- ๐ Contract tests
- ๐ Mutation tests
- ๐ Property-based tests
- ๐ Chaos engineering tests
๐ Resources¶
Official Documentation¶
Testing Libraries¶
- testify - Assertions and mocking
- gomock - Mocking framework
- httpexpect - HTTP testing
Articles¶
๐ค Contributing Tests¶
When contributing, please:
- โ Write tests for new features
- โ Update existing tests if behavior changes
- โ Ensure all tests pass before submitting PR
- โ Aim for 80%+ coverage on new code
- โ Follow existing test patterns
- โ Add comments for complex test logic
Test Checklist: - [ ] Tests pass locally (make test
) - [ ] Tests pass in CI - [ ] New features have tests - [ ] Edge cases are covered - [ ] Error cases are tested - [ ] Documentation is updated
๐ก Need Help?¶
- ๐ Check tests/README.md for quick reference
- ๐ Look at existing tests in
tests/handler_test.go
for examples - ๐ Open an issue if you find bugs
- ๐ฌ Start a discussion for questions
Happy Testing! ๐งช