210 lines
6.2 KiB
Markdown
210 lines
6.2 KiB
Markdown
# Testing Database Code
|
|
|
|
## Unit Tests with Mocks
|
|
|
|
Define a repository interface so business logic can be tested without a database. Mock the interface with `testify/mock`:
|
|
|
|
```go
|
|
// Repository interface — the contract
|
|
type UserRepository interface {
|
|
GetByID(ctx context.Context, id int64) (*User, bool, error)
|
|
Create(ctx context.Context, user *User) error
|
|
}
|
|
|
|
// Production implementation
|
|
type pgUserRepository struct {
|
|
db *sqlx.DB
|
|
}
|
|
|
|
func (r *pgUserRepository) GetByID(ctx context.Context, id int64) (*User, bool, error) {
|
|
var user User
|
|
err := r.db.GetContext(ctx, &user, "SELECT id, name, email FROM users WHERE id = $1", id)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, false, nil
|
|
}
|
|
return nil, false, fmt.Errorf("querying user %d: %w", id, err)
|
|
}
|
|
return &user, true, nil
|
|
}
|
|
```
|
|
|
|
### Mock for service-layer tests
|
|
|
|
```go
|
|
type mockUserRepo struct {
|
|
mock.Mock
|
|
}
|
|
|
|
func (m *mockUserRepo) GetByID(ctx context.Context, id int64) (*User, error) {
|
|
args := m.Called(ctx, id)
|
|
if args.Get(0) == nil {
|
|
return nil, args.Error(1)
|
|
}
|
|
return args.Get(0).(*User), args.Error(1)
|
|
}
|
|
|
|
func TestUserService_GetUser(t *testing.T) {
|
|
repo := new(mockUserRepo)
|
|
svc := NewUserService(repo)
|
|
|
|
expected := &User{ID: 1, Name: "Alice", Email: "alice@example.com"}
|
|
repo.On("GetByID", mock.Anything, int64(1)).Return(expected, nil)
|
|
|
|
user, err := svc.GetUser(context.Background(), 1)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, expected, user)
|
|
repo.AssertExpectations(t)
|
|
}
|
|
|
|
func TestUserService_GetUser_NotFound(t *testing.T) {
|
|
repo := new(mockUserRepo)
|
|
svc := NewUserService(repo)
|
|
|
|
repo.On("GetByID", mock.Anything, int64(999)).Return(nil, ErrUserNotFound)
|
|
|
|
user, err := svc.GetUser(context.Background(), 999)
|
|
assert.Nil(t, user)
|
|
assert.ErrorIs(t, err, ErrUserNotFound)
|
|
}
|
|
```
|
|
|
|
Unit tests verify business logic, not SQL correctness. They run fast and without external dependencies.
|
|
|
|
## sqlmock for Query-Level Testing
|
|
|
|
When you need to verify exact SQL without a real database, use [DATA-DOG/go-sqlmock](https://github.com/DATA-DOG/go-sqlmock):
|
|
|
|
```go
|
|
func TestGetByID_sqlmock(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
require.NoError(t, err)
|
|
defer db.Close()
|
|
|
|
sqlxDB := sqlx.NewDb(db, "postgres")
|
|
repo := &pgUserRepository{db: sqlxDB}
|
|
|
|
rows := sqlmock.NewRows([]string{"id", "name", "email"}).
|
|
AddRow(1, "Alice", "alice@example.com")
|
|
mock.ExpectQuery("SELECT id, name, email FROM users WHERE id = \\$1").
|
|
WithArgs(1).
|
|
WillReturnRows(rows)
|
|
|
|
user, err := repo.GetByID(context.Background(), 1)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Alice", user.Name)
|
|
assert.NoError(t, mock.ExpectationsWereMet())
|
|
}
|
|
```
|
|
|
|
sqlmock is useful for verifying query structure and error handling paths, but it does not validate that your SQL is correct against a real database schema.
|
|
|
|
## Integration Tests
|
|
|
|
Integration tests run against a real database. Gate them with build tags so `go test ./...` skips them by default:
|
|
|
|
```go
|
|
//go:build integration
|
|
|
|
package repository_test
|
|
|
|
import (
|
|
"testing"
|
|
"github.com/stretchr/testify/suite"
|
|
)
|
|
|
|
type UserRepoSuite struct {
|
|
suite.Suite
|
|
db *sqlx.DB
|
|
tx *sqlx.Tx
|
|
}
|
|
|
|
func (s *UserRepoSuite) SetupSuite() {
|
|
dsn := os.Getenv("TEST_DATABASE_URL") // e.g., postgres://test:test@localhost:5432/testdb?sslmode=disable
|
|
db, err := sqlx.Connect("postgres", dsn)
|
|
s.Require().NoError(err)
|
|
s.db = db
|
|
// Run migrations here if needed
|
|
}
|
|
|
|
func (s *UserRepoSuite) TearDownSuite() {
|
|
s.db.Close()
|
|
}
|
|
|
|
func (s *UserRepoSuite) SetupTest() {
|
|
tx, err := s.db.Beginx()
|
|
s.Require().NoError(err)
|
|
s.tx = tx
|
|
}
|
|
|
|
func (s *UserRepoSuite) TearDownTest() {
|
|
s.tx.Rollback() // rolls back all changes — each test starts clean
|
|
}
|
|
|
|
func (s *UserRepoSuite) TestCreateAndGet() {
|
|
repo := NewUserRepository(s.tx)
|
|
user := &User{Name: "Alice", Email: "alice@example.com"}
|
|
|
|
err := repo.Create(context.Background(), user)
|
|
s.Require().NoError(err)
|
|
s.NotZero(user.ID)
|
|
|
|
got, err := repo.GetByID(context.Background(), user.ID)
|
|
s.Require().NoError(err)
|
|
s.Equal("Alice", got.Name)
|
|
}
|
|
|
|
func TestUserRepoSuite(t *testing.T) {
|
|
suite.Run(t, new(UserRepoSuite))
|
|
}
|
|
```
|
|
|
|
Run integration tests:
|
|
|
|
```bash
|
|
go test -tags=integration -v ./internal/repository/...
|
|
```
|
|
|
|
### Test database with testcontainers-go
|
|
|
|
For CI environments without a pre-existing database:
|
|
|
|
```go
|
|
func (s *UserRepoSuite) SetupSuite() {
|
|
ctx := context.Background()
|
|
container, err := postgres.Run(ctx, "postgres:16-alpine",
|
|
postgres.WithDatabase("testdb"),
|
|
postgres.WithUsername("test"),
|
|
postgres.WithPassword("test"),
|
|
testcontainers.WithWaitStrategy(
|
|
wait.ForLog("database system is ready to accept connections").
|
|
WithOccurrence(2).
|
|
WithStartupTimeout(30*time.Second),
|
|
),
|
|
)
|
|
s.Require().NoError(err)
|
|
s.container = container
|
|
|
|
connStr, err := container.ConnectionString(ctx, "sslmode=disable")
|
|
s.Require().NoError(err)
|
|
s.db, err = sqlx.Connect("postgres", connStr)
|
|
s.Require().NoError(err)
|
|
}
|
|
```
|
|
|
|
## What to Test
|
|
|
|
| What | Unit test (mock) | Integration test |
|
|
| ------------------------- | :--------------: | :--------------: |
|
|
| Business logic | ✓ | |
|
|
| SQL correctness | | ✓ |
|
|
| Error paths (not found) | ✓ | ✓ |
|
|
| Transaction boundaries | | ✓ |
|
|
| NULL handling round-trips | | ✓ |
|
|
| Constraint violations | | ✓ |
|
|
| Query performance | | ✓ (with EXPLAIN) |
|
|
|
|
Unit tests MUST use mocks (interface mocks or sqlmock) — no real database connections. Integration tests MUST use build tags (`//go:build integration`) to separate from unit tests. Integration tests SHOULD use testcontainers-go for reproducible database environments in CI. NEVER test against production databases.
|
|
|
|
→ See `samber/cc-skills-golang@golang-testing` skill for general test patterns and CI configuration.
|