| name | test |
| description | Write tests for OpenMeter services following project conventions. Use when creating unit tests, integration tests, or service tests. |
| user-invocable | true |
| argument-hint | [description of what to test] |
| allowed-tools | Read, Edit, Write, Bash, Grep, Glob, Agent |
Testing
You are helping the user write tests for OpenMeter following established conventions.
Test Types & File Locations
| Type | Purpose | Location | DB Required |
|---|
| Unit tests | Validation, pure functions | openmeter/<domain>/<domain>_test.go | No |
| Integration tests | Adapter against real Postgres | openmeter/<domain>/adapter/*_test.go | Yes |
| Service tests | Full stack via TestEnv | openmeter/<domain>/service/*_test.go | Yes |
Running Tests
make test
make test-nocache
make etoe
Before running: docker compose up -d postgres
Build tag: all Go test commands use -tags=dynamic.
Prefer direct command execution. Do not wrap test commands in sh -lc, bash -lc, or similar helper shells when a direct invocation works. For environment variables, prefer env POSTGRES_HOST=127.0.0.1 go test ... or POSTGRES_HOST=127.0.0.1 go test ....
For running a specific test directly:
POSTGRES_HOST=127.0.0.1 go test -tags=dynamic ./openmeter/<domain>/...
When a Postgres-backed test fails, the suite output often includes a testdbconf: URL for the per-test database. Use that URL with psql to inspect the failed test state before rerunning, because the database is still useful for RCA. Quote the connection string if it contains query parameters, for example:
psql 'postgres://pgtdbuser:pgtdbpass@127.0.0.1:5432/testdb_tpl_...?sslmode=disable'
Key Test Utilities
From openmeter/testutils/:
| Utility | Usage |
|---|
testutils.InitPostgresDB(t) | Provisions fresh Postgres DB per test; skips if POSTGRES_HOST not set |
testutils.NewDiscardLogger(t) | Silent logger for tests |
testutils.NewLogger(t) | Default slog logger for tests |
From openmeter/watermill/eventbus/:
| Utility | Usage |
|---|
eventbus.NewMock(t) | Mock event publisher for tests |
TestEnv Pattern
For service/integration tests, create a testutils/ package with a TestEnv that wires up the full stack.
Reference: openmeter/customer/testutils/env.go
package testutils
import (
"sync"
"testing"
"github.com/stretchr/testify/require"
"<domain>"
<domain>adapter "<domain>/adapter"
<domain>service "<domain>/service"
entdb "github.com/openmeterio/openmeter/openmeter/ent/db"
"github.com/openmeterio/openmeter/openmeter/testutils"
"github.com/openmeterio/openmeter/openmeter/watermill/eventbus"
)
type TestEnv struct {
Logger *slog.Logger
Service <domain>.Service
Client *entdb.Client
db *testutils.TestDB
close sync.Once
}
func (e *TestEnv) DBSchemaMigrate(t *testing.T) {
t.Helper()
require.NotNilf(t, e.db, "database must be initialized")
err := e.db.EntDriver.Client().Schema.Create(t.Context())
require.NoErrorf(t, err, "schema migration must not fail")
}
func (e *TestEnv) Close(t *testing.T) {
t.Helper()
e.close.Do(func() {
if e.db != nil {
if err := e.db.EntDriver.Close(); err != nil {
t.Errorf("failed to close ent driver: %v", err)
}
if err := e.db.PGDriver.Close(); err != nil {
t.Errorf("failed to close postgres driver: %v", err)
}
}
if e.Client != nil {
if err := e.Client.Close(); err != nil {
t.Errorf("failed to close ent client: %v", err)
}
}
})
}
func NewTestEnv(t *testing.T) *TestEnv {
t.Helper()
logger := testutils.NewDiscardLogger(t)
db := testutils.InitPostgresDB(t)
client := db.EntDriver.Client()
publisher := eventbus.NewMock(t)
adapter, err := <domain>adapter.New(<domain>adapter.Config{
Client: client,
Logger: logger,
})
require.NoErrorf(t, err, "initializing adapter must not fail")
service, err := <domain>service.New(<domain>service.Config{
Adapter: adapter,
Publisher: publisher,
})
require.NoErrorf(t, err, "initializing service must not fail")
return &TestEnv{
Logger: logger,
Service: service,
Client: client,
db: db,
close: sync.Once{},
}
}
Using TestEnv in Tests
func TestCreate<Resource>(t *testing.T) {
env := testutils.NewTestEnv(t)
t.Cleanup(func() { env.Close(t) })
env.DBSchemaMigrate(t)
ns := testutils.NewTestNamespace(t)
t.Run("success", func(t *testing.T) {
result, err := env.Service.Create<Resource>(t.Context(), <domain>.Create<Resource>Input{
Namespace: ns,
Name: "test",
})
require.NoError(t, err)
assert.Equal(t, "test", result.Name)
})
t.Run("validation error", func(t *testing.T) {
_, err := env.Service.Create<Resource>(t.Context(), <domain>.Create<Resource>Input{})
require.Error(t, err)
assert.True(t, models.IsGenericValidationError(err))
})
}
Unit Test Pattern
For testing validation, pure functions, and domain logic without DB:
func TestCreate<Resource>Input_Validate(t *testing.T) {
tests := []struct {
name string
input <domain>.Create<Resource>Input
wantErr bool
}{
{
name: "valid",
input: <domain>.Create<Resource>Input{
Namespace: "test-ns",
Name: "test",
},
wantErr: false,
},
{
name: "missing namespace",
input: <domain>.Create<Resource>Input{Name: "test"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.input.Validate()
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
Time Control
Use clock.SetTime or clock.FreezeTime for time-deterministic tests. Always defer a reset:
func TestTimeSensitive(t *testing.T) {
now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
clock.SetTime(now)
defer clock.ResetTime()
}
clock.SetTime(t) — sets the clock to a specific time
clock.FreezeTime(t) — freezes time so it doesn't advance
- Always
defer clock.ResetTime() to avoid leaking into other tests
- Do not use
WithinDuration(...) for clock-sensitive assertions. It is fragile under varying CI worker load and scheduler timing.
- If the code under test stamps
clock.Now(), prefer clock.FreezeTime(...) and assert exact equality instead of tolerances.
Suite Pattern
For complex test scenarios with shared setup/teardown, use testify's suite.Suite:
type <Domain>Suite struct {
suite.Suite
*require.Assertions
env *testutils.TestEnv
}
func (s *<Domain>Suite) SetupSuite() {
s.env = testutils.NewTestEnv(s.T())
s.env.DBSchemaMigrate(s.T())
}
func (s *<Domain>Suite) TearDownSuite() {
s.env.Close(s.T())
}
func (s *<Domain>Suite) TestCreate() {
result, err := s.env.Service.Create(s.T().Context(), input)
s.NoError(err)
s.Equal("expected", result.Name)
}
func TestSuite(t *testing.T) {
suite.Run(t, new(<Domain>Suite))
}
Key conventions:
- Embed
*require.Assertions for convenient assertion methods
- Use
SetupSuite/TearDownSuite for one-time setup (DB, env)
- Use
SetupTest/TearDownTest for per-test setup if needed
Testing Conventions
- Use
require for fatal assertions (test cannot continue), assert for soft assertions
- Use
t.Helper() in all helper functions
- Use
t.Context() instead of context.Background()
- Use table-driven tests with
t.Run() for multiple cases
- Use
testutils.NewTestULID(t) or testutils.NewTestNamespace(t) for random test identifiers
- Each test gets its own namespace to avoid cross-test interference
- Integration tests are automatically skipped when
POSTGRES_HOST is not set (via testutils.InitPostgresDB)