feat: add golang-database skill with best practices and reference documentation
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user