4.1 KiB
Testing with uber-go/fx
go.uber.org/fx/fxtest integrates fx applications with *testing.T: errors fail the test instead of crashing the process, and lifecycle teardown is registered automatically.
Pulling a value out of the graph with fx.Populate
func TestUserService_Create(t *testing.T) {
var svc *UserService
app := fxtest.New(t,
fx.Provide(
func() Database { return &fakeDatabase{} },
NewUserService,
),
fx.Populate(&svc),
)
defer app.RequireStop()
app.RequireStart()
require.NoError(t, svc.Create(context.Background(), "alice@example.com"))
}
fx.Populate(&svc) fills svc with the value the graph would resolve. It replaces ad-hoc fx.Invoke(func(s *UserService) { svc = s }) patterns.
fx.Replace to swap a real dependency for a fake
func TestServer_HandlesDBError(t *testing.T) {
var srv *http.Server
fakeDB := &erroringDatabase{}
app := fxtest.New(t,
ProductionModule, // the real wiring
fx.Replace(fx.Annotate(fakeDB, fx.As(new(Database)))),
fx.Populate(&srv),
)
defer app.RequireStop()
app.RequireStart()
// Drive the server with a fake DB
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/users", nil)
srv.Handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusInternalServerError, rec.Code)
}
fx.Replace works even when the original provider is buried inside a module — it overrides the resolved type without rewriting the module.
Standalone lifecycle for a unit test
fxtest.NewLifecycle(t) gives you an fx.Lifecycle outside the fx.New machinery, useful for testing a single constructor that registers hooks:
func TestWorker_StartStop(t *testing.T) {
lc := fxtest.NewLifecycle(t)
worker := NewWorker(lc, zaptest.NewLogger(t))
require.NotNil(t, worker)
lc.RequireStart() // runs OnStart hooks
require.True(t, worker.IsRunning())
lc.RequireStop() // runs OnStop hooks
require.False(t, worker.IsRunning())
}
This is the lightest test for a constructor — no full graph, no fx.New.
Asserting wire-time errors
func TestWiring_MissingDependency(t *testing.T) {
app := fx.New(
fx.Provide(NewServer), // depends on *sql.DB which is not provided
fx.NopLogger,
)
require.Error(t, app.Err())
require.Contains(t, app.Err().Error(), "missing type: *sql.DB")
}
Use fx.New (not fxtest.New) when you expect the wiring to fail — fxtest.New would call t.Fatal.
Validating the production graph in CI
func TestProductionGraph(t *testing.T) {
app := fx.New(
ProductionOptions(), // every fx.Provide / fx.Module the binary uses
fx.NopLogger,
)
require.NoError(t, app.Err())
}
fx.New validates the type graph without starting. The test fails before deploy on any missing-provider, cycle, or annotation mismatch.
Test logger that captures fx events
When you want to assert on lifecycle behavior, route fx events into an in-memory observer:
// go.uber.org/zap/zaptest/observer
core, recorded := observer.New(zap.InfoLevel)
log := zap.New(core)
app := fxtest.New(t,
fx.WithLogger(func() fxevent.Logger {
return &fxevent.ZapLogger{Logger: log}
}),
fx.Provide(NewWorker),
fx.Invoke(func(*Worker) {}),
)
defer app.RequireStop()
app.RequireStart()
require.NotEmpty(t, recorded.FilterMessage("OnStart hook executed").All())
Testing a lifecycle hook in isolation
If a constructor returns a value and registers a hook, you often want to test both halves:
func TestNewServer_OnStartFailsBindError(t *testing.T) {
// Bind a port so :0 is unavailable... no, simpler: pre-bind and pass that addr
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer listener.Close()
addr := listener.Addr().String()
cfg := &Config{Addr: addr}
lc := fxtest.NewLifecycle(t)
NewHTTPServer(lc, zaptest.NewLogger(t), cfg)
// Use Start directly (not RequireStart) so we can assert the error.
require.Error(t, lc.Start(context.Background()))
}