zoobzio January 6, 2025 Edit this page

Testing

This guide covers testing strategies for applications using grub.

Testing Approaches

ApproachSpeedFidelityUse Case
Mock providerFastLowUnit tests
Embedded DBMediumMediumIntegration tests
Real serviceSlowHighE2E 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

HelperDescription
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 ./...