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.
go test ./...The demotest helper
demotest.Start starts the application with coretest, and connects a client to each API:
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.
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()))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"))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:
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:
func ReplaceStore(store tasks.Store) fx.Option { return fx.Replace(fx.Annotate(store, fx.As(new(tasks.Store))))}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.
