Skip to content

Testing

The demo tests the whole application in memory: a SQLite database per test, API clients connected without network, and the telemetry exported in memory.

Terminal window
go test ./...

The demotest helper

demotest.Start starts the application with coretest, and connects a client to each API:

internal/demotest/demotest.go
func Start(t *testing.T, options ...fx.Option) *App {
app := &App{t: t}
var (
grpcServer *grpc.Server
mcpServer *mcp.Server
)
coretest.Start(t, demo.App,
append(options,
config.AsOptions(config.WithValues(map[string]any{
"sql.driver": "sqlite",
"sql.dsn": filepath.Join(t.TempDir(), "test.db"),
})),
sql.Migrate(),
fx.Populate(&app.HTTPServer, &grpcServer, &mcpServer, &app.Spans, &app.Logs, &app.Metrics),
)...,
)
app.GRPC = tasksv1.NewTasksServiceClient(grpcservertest.Dial(t, grpcServer)) // in memory, with bufconn
app.MCP = mcpservertest.Connect(t, mcpServer) // in-memory MCP transport
return app
}

The service

func TestService(t *testing.T) {
t.Parallel()
var service *tasks.Service
app := demotest.Start(t, fx.Populate(&service))
task, err := service.Create(t.Context(), "test_title")
require.NoError(t, err)
create := spantest.Find(app.Spans, spantest.WithName("tasks.create"), spantest.WithAttributeValue(tasks.TaskIDKey.String(task.ID)))
require.Len(t, create, 1)
spantest.HasSpan(t, app.Spans, spantest.WithName("INSERT"), spantest.WithParent(create[0].SpanContext.SpanID()))
logtest.HasRecord(t, app.Logs, logtest.WithBody("task created"), logtest.WithAttributeValue("task_id", task.ID))
assert.Equal(t, int64(1), metrictest.Int64Sum(t, app.Metrics, "tasks.created"))
}

The APIs

Each API test calls the API, then checks that the tasks.create span is a child of the server span.

internal/http/handlers_test.go
response := app.HTTP(http.MethodPost, "/api/tasks", `{"title": "test_title"}`)
require.Equal(t, http.StatusCreated, response.Code)
server := spantest.Find(app.Spans, spantest.WithName("POST /api/tasks"))
spantest.HasSpan(t, app.Spans, spantest.WithName("tasks.create"), spantest.WithParent(server[0].SpanContext.SpanID()))
internal/grpc/service_test.go
task, err := app.GRPC.CreateTask(t.Context(), &tasksv1.CreateTaskRequest{Title: "test_title"})
require.NoError(t, err)
_, err = app.GRPC.GetTask(t.Context(), &tasksv1.GetTaskRequest{Id: "missing"})
assert.Equal(t, codes.NotFound, status.Code(err))
server := spantest.Find(app.Spans, spantest.WithName("tasks.v1.TasksService/CreateTask"))
internal/mcp/tools_test.go
result, err := app.MCP.CallTool(t.Context(), &mcp.CallToolParams{
Name: "create_task",
Arguments: mcpapi.CreateInput{Title: "test_title"},
})
require.NoError(t, err)
assert.False(t, result.IsError)
server := spantest.Find(app.Spans, spantest.WithName("tools/call create_task"))

Replace dependencies with fx.Replace

The tasks.Service depends on a tasks.Store interface, provided by the SQL Repository:

internal/tasks/module.go
var Module = fx.Module("tasks",
fx.Provide(
fx.Annotate(NewRepository, fx.As(new(Store))),
NewService,
),
)

Tests replace it with fx.Replace, here with a store failing every call, to check how each API handles an unexpected error. The rest of the application is unchanged:

internal/demotest/demotest.go
func ReplaceStore(store tasks.Store) fx.Option {
return fx.Replace(fx.Annotate(store, fx.As(new(tasks.Store))))
}
internal/http/handlers_test.go
app := demotest.Start(t, demotest.ReplaceStore(demotest.FailingStore{}))
response := app.HTTP(http.MethodGet, "/api/tasks", "")
assert.Equal(t, http.StatusInternalServerError, response.Code)
assert.JSONEq(t, `{"error": "internal error"}`, response.Body.String(), "the error is not exposed")
spantest.HasSpan(t, app.Spans, spantest.WithName("GET /api/tasks"), spantest.WithStatus(codes.Error))
logtest.HasRecord(t, app.Logs, logtest.WithBody("tasks request failed"), logtest.WithLevel(slog.LevelError))

The same failure is tested on every surface:

Surface Result
tasks.Service the error, a tasks.create span with an Error status, no tasks.created count
HTTP 500, without the error details, and an error log
gRPC INTERNAL, a failed server span, and an error log
MCP a tool error, with error.type=tool_error, and an error log

Replace any provided dependency the same way: a clock, an HTTP client, a fake of a third party API.

The requests go through the whole servers, with their instrumentation, middlewares and interceptors: the same code as in production, without ports. See the testing pages of httpserver, grpcserver and mcpserver.