Testing Guide
Running Tests
All test commands use Task:
| Command | What it runs |
|---|---|
task test | Unit tests for ./src/... |
task test:e2e | End-to-end tests (./test/e2e/) |
task test:all | All of the above, sequentially |
Plugins are no longer part of the core repository — each first-party plugin lives in its own repository under the directedbits org and is tested there.
Run a single package:
go test ./src/app/trigger/...
Unit Test Patterns
Temporary directories
Use t.TempDir() for any test that needs filesystem access. It is automatically cleaned up:
dir := t.TempDir()
testFile := filepath.Join(dir, "test.txt")
os.WriteFile(testFile, []byte("hello"), 0644)
Table-driven tests
Use table-driven tests for validating multiple input/output combinations:
func TestStartInterval_Errors(t *testing.T) {
cases := []struct{ name, every string }{
{"invalid", "not-a-duration"},
{"negative", "-5s"},
{"zero", "0s"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, _, err := StartInterval(tc.every, false)
if err == nil {
t.Fatal("expected error")
}
})
}
}
Time-based tests
For tests involving timers and event channels:
- Use short durations for the thing being tested (10ms-20ms intervals).
- Use generous timeouts for assertions (500ms-5s) to avoid flaky failures.
events, stop, err := StartInterval("20ms", false)
defer stop()
select {
case tick := <-events:
// assert
case <-time.After(500 * time.Millisecond):
t.Fatal("timed out waiting for tick")
}
Mock Patterns
The codebase uses three main mock strategies, all without external mock libraries.
Injectable function variables
Package-level function variables that default to real implementations but can be swapped in tests. Defined in src/app/cli/dial.go:
var (
dialFunc = watchgrpc.Dial
dialOrNilFunc = watchgrpc.DialOrNil
)
The MQTT plugin uses the same pattern with clientFactory in its client.go (see the recur-mqtt repository):
var clientFactory MQTTClientFactory = defaultClientFactory
Tests override the variable, then restore it in cleanup:
origFactory := clientFactory
clientFactory = func(opts *mqtt.ClientOptions) MQTTClient {
return &mockClient{}
}
defer func() { clientFactory = origFactory }()
Interface mocks
Define a narrow interface for the external dependency, then implement it as a mock in tests.
DBusConn (from the recur-devicemonitor repository’s conn.go):
type DBusConn interface {
AddMatchSignal(options ...dbus.MatchOption) error
Signal(ch chan<- *dbus.Signal)
}
MQTTClient (from the recur-mqtt repository’s client.go):
type MQTTClient interface {
Connect() mqtt.Token
Subscribe(topic string, qos byte, callback mqtt.MessageHandler) mqtt.Token
Unsubscribe(topics ...string) mqtt.Token
Publish(topic string, qos byte, retained bool, payload interface{}) mqtt.Token
Disconnect(quiesce uint)
}
TriggerLookup (src/app/trigger/engine.go):
type TriggerLookup interface {
GetTrigger(id string) *trigger.Trigger
GetActionsForTrigger(triggerID string) []*action.Action
}
Test implementations (e.g., mockLookup in engine_test.go) use simple maps:
type mockLookup struct {
triggers map[string]*trigger.Trigger
actions map[string][]*action.Action
}
Mock gRPC service with bufconn
The CLI tests use google.golang.org/grpc/test/bufconn to create an in-memory gRPC server. Defined in src/app/cli/mock_test.go:
type mockService struct {
recurv1.UnimplementedRecurServiceServer
listTriggersResp *recurv1.ListTriggersResponse
getStatusResp *recurv1.GetStatusResponse
// ... one field per RPC response
}
Each method returns the pre-set response (or a sensible default if nil).
CLI Testing with bufconn
The startMockDaemon helper in src/app/cli/mock_test.go wires everything together:
- Creates a
bufconn.Listen(bufSize)in-memory listener. - Starts a
grpc.NewServer()with themockServiceregistered. - Overrides
dialFuncanddialOrNilFuncto return clients connected through the bufconn. - Returns a cleanup function that stops the server and restores the original dial functions.
Usage in a test:
func TestListTriggers(t *testing.T) {
svc := &mockService{
listTriggersResp: &recurv1.ListTriggersResponse{
Triggers: []*recurv1.TriggerSummary{{Id: "abc123", Name: "Cron"}},
},
}
cleanup := startMockDaemon(t, svc)
defer cleanup()
// Now run the CLI command -- it will connect to the mock server
// ...
}
E2E Tests
End-to-end tests live in test/e2e/ and exercise the full stack (build binaries, start daemon, run CLI commands, verify output).
TestMain
TestMain in test/e2e/helpers_test.go builds both recur and recurd binaries once into a shared temp directory before any tests run:
func TestMain(m *testing.M) {
tmp, _ := os.MkdirTemp("", "recur-e2e-*")
defer os.RemoveAll(tmp)
binDir = tmp
// go build -o tmp/recur ../../src/app/recur
// go build -o tmp/recurd ../../src/app/recurd
os.Exit(m.Run())
}
startDaemonForTest
Each test that needs a running daemon calls startDaemonForTest(t). It:
- Creates an isolated
HOMEviat.TempDir()so tests do not interfere with each other. - Runs
recur startto launch the daemon. - Returns the recur binary path, home directory, and a cleanup function that runs
recur stop.
Test isolation
Every E2E test gets its own HOME directory. This means:
- Config, state, PID, and socket files are isolated per test.
- Tests can run in parallel without conflicting.
Coverage Enforcement
CI enforces a 75% coverage threshold on the core codebase (./src/...).
Exclusions
The following are filtered out of the coverage report:
src/cmd/– entry pointmain()functionssrc/app/cli/– tested via E2E and bufconn mocks, not unit-measurablesrc/infra/grpc/v1/– generated protobuf code
Local coverage check
go test -coverprofile=coverage.out ./src/...
# Filter exclusions
head -1 coverage.out > coverage-filtered.out
grep -vE 'src/cmd/|src/app/cli/|src/infra/grpc/v1/' coverage.out | tail -n +2 >> coverage-filtered.out
# Check total
go tool cover -func=coverage-filtered.out | grep '^total:'
CI step
The coverage step in .github/workflows/ci.yml runs the same filtering, compares the total against the 75% threshold, and fails the build if it drops below.
Plugin Test Conventions
Each plugin follows a two-file test pattern:
| File | What it tests |
|---|---|
main_test.go | parseInput – JSON parsing, validation, edge cases (invalid JSON, missing fields, unsupported types). Uses strings.NewReader to feed stdin. |
<core>_test.go | Event source logic – channel behavior, start/stop, error conditions, configuration validation. For timer: timer_test.go tests StartCron and StartInterval. |
This separation keeps input parsing tests fast and deterministic, while event source tests can use short durations for real timing behavior.