Testing
This guide covers testing strategies for applications using grub.
Testing Approaches
| Approach | Speed | Fidelity | Use Case |
|---|---|---|---|
| Mock provider | Fast | Low | Unit tests |
| Embedded DB | Medium | Medium | Integration tests |
| Real service | Slow | High | E2E tests |
Unit Testing with Mocks
Simple Mock Provider
type MockStore struct {
data map[string][]byte
mu sync.RWMutex
}
func NewMockStore() *MockStore {
return &MockStore{data: make(map[string][]byte)}
}
func (m *MockStore) Get(ctx context.Context, key string) ([]byte, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if v, ok := m.data[key]; ok {
return v, nil
}
return nil, grub.ErrNotFound
}
func (m *MockStore) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
m.mu.Lock()
defer m.mu.Unlock()
m.data[key] = value
return nil
}
func (m *MockStore) Delete(ctx context.Context, key string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.data[key]; !ok {
return grub.ErrNotFound
}
delete(m.data, key)
return nil
}
func (m *MockStore) Exists(ctx context.Context, key string) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
_, ok := m.data[key]
return ok, nil
}
func (m *MockStore) List(ctx context.Context, prefix string, limit int) ([]string, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var keys []string
for k := range m.data {
if strings.HasPrefix(k, prefix) {
keys = append(keys, k)
if limit > 0 && len(keys) >= limit {
break
}
}
}
return keys, nil
}
func (m *MockStore) GetBatch(ctx context.Context, keys []string) (map[string][]byte, error) {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string][]byte)
for _, k := range keys {
if v, ok := m.data[k]; ok {
result[k] = v
}
}
return result, nil
}
func (m *MockStore) SetBatch(ctx context.Context, items map[string][]byte, ttl time.Duration) error {
m.mu.Lock()
defer m.mu.Unlock()
for k, v := range items {
m.data[k] = v
}
return nil
}
Using the Mock
func TestUserService_CreateUser(t *testing.T) {
store := grub.NewStore[User](NewMockStore())
service := NewUserService(store)
user, err := service.CreateUser(context.Background(), "alice@example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Email != "alice@example.com" {
t.Errorf("got email %q, want %q", user.Email, "alice@example.com")
}
}
Integration Testing with Embedded DBs
BadgerDB (In-Memory)
func setupTestStore(t *testing.T) *grub.Store[TestData] {
opts := badgerdb.DefaultOptions("").WithInMemory(true)
db, err := badgerdb.Open(opts)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
return grub.NewStore[TestData](badger.New(db))
}
func TestStore_SetGet(t *testing.T) {
store := setupTestStore(t)
ctx := context.Background()
data := &TestData{Value: "test"}
if err := store.Set(ctx, "key", data, 0); err != nil {
t.Fatal(err)
}
got, err := store.Get(ctx, "key")
if err != nil {
t.Fatal(err)
}
if got.Value != "test" {
t.Errorf("got %q, want %q", got.Value, "test")
}
}
BoltDB (Temp File)
func setupBoltStore(t *testing.T) *grub.Store[TestData] {
f, err := os.CreateTemp("", "bolt-test-*.db")
if err != nil {
t.Fatal(err)
}
f.Close()
db, err := bbolt.Open(f.Name(), 0600, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
db.Close()
os.Remove(f.Name())
})
return grub.NewStore[TestData](bolt.New(db, "test"))
}
SQLite (In-Memory)
func setupTestDB(t *testing.T) *grub.Database[User] {
db, err := sqlx.Connect("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
// Create table
db.MustExec(`CREATE TABLE users (
id TEXT PRIMARY KEY,
name TEXT,
email TEXT
)`)
userDB := grub.NewDatabase[User](db, "users", sqlite.New())
return userDB
}
Using grub/testing Helpers
The grub/testing package provides assertion helpers:
import grubtesting "github.com/zoobz-io/grub/testing"
func TestStore_Operations(t *testing.T) {
store := setupTestStore(t)
ctx := grubtesting.WithTimeout(t, 5*time.Second)
// Set
err := store.Set(ctx, "key", &TestData{Value: "test"}, 0)
grubtesting.AssertNoError(t, err)
// Get
got, err := store.Get(ctx, "key")
grubtesting.AssertNoError(t, err)
grubtesting.AssertEqual(t, got.Value, "test")
// Exists
exists, err := store.Exists(ctx, "key")
grubtesting.AssertNoError(t, err)
grubtesting.AssertTrue(t, exists, "key should exist")
// Delete
err = store.Delete(ctx, "key")
grubtesting.AssertNoError(t, err)
// Verify deleted
_, err = store.Get(ctx, "key")
grubtesting.AssertError(t, err)
}
Available Helpers
| Helper | Description |
|---|---|
WithTimeout(t, d) | Context with timeout and cleanup |
AssertNoError(t, err) | Fails if err != nil |
AssertError(t, err) | Fails if err == nil |
AssertEqual(t, got, want) | Compares values |
AssertTrue(t, cond, msg) | Asserts condition |
AssertFalse(t, cond, msg) | Asserts negation |
AssertNil(t, v) | Asserts nil |
AssertNotNil(t, v) | Asserts non-nil |
AssertLen(t, slice, n) | Asserts slice length |
AssertContains(t, slice, item) | Asserts membership |
AssertMapHasKey(t, m, k) | Asserts key exists |
Integration Tests with Testcontainers
For testing against real services:
Redis
func setupRedisStore(t *testing.T) *grub.Store[TestData] {
ctx := context.Background()
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "redis:7",
ExposedPorts: []string{"6379/tcp"},
WaitingFor: wait.ForListeningPort("6379/tcp"),
},
Started: true,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { container.Terminate(ctx) })
host, _ := container.Host(ctx)
port, _ := container.MappedPort(ctx, "6379")
client := goredis.NewClient(&goredis.Options{
Addr: fmt.Sprintf("%s:%s", host, port.Port()),
})
t.Cleanup(func() { client.Close() })
return grub.NewStore[TestData](redis.New(client))
}
PostgreSQL
func setupPostgresDB(t *testing.T) *grub.Database[User] {
ctx := context.Background()
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "postgres:16",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_PASSWORD": "test",
"POSTGRES_DB": "test",
},
WaitingFor: wait.ForListeningPort("5432/tcp"),
},
Started: true,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { container.Terminate(ctx) })
host, _ := container.Host(ctx)
port, _ := container.MappedPort(ctx, "5432")
dsn := fmt.Sprintf("postgres://postgres:test@%s:%s/test?sslmode=disable", host, port.Port())
db, err := sqlx.Connect("postgres", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
// Create schema
db.MustExec(`CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT, email TEXT)`)
userDB, _ := grub.NewDatabase[User](db, "users", postgres.New())
return userDB
}
Testing Best Practices
1. Use Table-Driven Tests
func TestStore_Get(t *testing.T) {
store := setupTestStore(t)
ctx := context.Background()
// Setup
store.Set(ctx, "exists", &TestData{Value: "value"}, 0)
tests := []struct {
name string
key string
want string
wantErr error
}{
{"existing key", "exists", "value", nil},
{"missing key", "missing", "", grub.ErrNotFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := store.Get(ctx, tt.key)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Errorf("got error %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Value != tt.want {
t.Errorf("got %q, want %q", got.Value, tt.want)
}
})
}
}
2. Isolate Tests
Use unique prefixes to avoid test interference:
func TestConcurrent(t *testing.T) {
store := setupTestStore(t)
prefix := fmt.Sprintf("test-%d:", time.Now().UnixNano())
// Use prefix for all keys
key := prefix + "mykey"
}
3. Clean Up in t.Cleanup
func setupStore(t *testing.T) *grub.Store[Data] {
db, _ := badgerdb.Open(opts)
t.Cleanup(func() { db.Close() })
return grub.NewStore[Data](badger.New(db))
}
4. Skip Slow Tests
func TestIntegration_Redis(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
// ... test with real Redis
}
Run fast tests only: go test -short ./...