Dependency injection in go using fx, and replacing services for test

August 25, 2023 0 By addshore

I’m writing a new go application and ended up giving fx (by uber) a try for dependency injection. The getting started docs were brilliant for my use case (creating an API), but the examples for how to inject mock services for tests were lacking, so I decided to write some code examples of how I am currently using fx in tests.

General app setup

I set up my whole application in a app package, which pulls in all services that are needed.

Most of these are provided as constructor functions, with some more dynamic registration of routes as is done in the getting started docs.

package app

func New(additionalOpts ...fx.Option) *fx.App {
	return fx.New(
		fx.Provide(
			config.NewDefault,
			ses.NewSES,
			ses.NewEmailSender,
			mysql.NewGormGB,
			middleware.NewLimiterFactory,
			middleware.NewAuth,
			AsRoute(handlers.UserLogin),
			AsRoute(handlers.ListStuff),
			fx.Annotate(
				router.NewGinRouter,
				fx.ParamTags(`group:"routes"`),
			),
			server.NewHTTPServer,
		),
		fx.Invoke(func(*http.Server) {}),
		fx.Options(additionalOpts...),
	)
}Code language: Go (go)

The main entry point for my application simply loads and runs this fx app, which includes the invocation of the HTTP server.

package main

import "addshore.com/someapp/api/app"


func main() {
	app.New().Run()
}
Code language: Go (go)

App for tests

Within the app package, I have another function that returns an app for use in tests, based on the main app, and making use of the additionalOpts argument.

This ensures that the majority of the services provided and wiring is all the same, while allowing for some application-wide services to be replaced.

The main example below replaces the default gorm DB instance, with a copyist DB instance which can be used to record SQL interactions and write them to disk for use in tests.

func NewForTests(t *testing.T, additionalOpts ...fx.Option) (*fx.App, func()) {
	gin.SetMode(gin.TestMode)
	c := config.Config{
		AppJWTSecret: "TESTING_ur19irjf0kdsaffasf",
	}

	if copyist.IsRecording() {
		entryutils.LoadGoDotEnvFromRoot()
		defaultConfig := config.NewDefault()
		c.SQLUser = defaultConfig.SQLUser
		c.SQLPass = defaultConfig.SQLPass
		c.SQLAddr = defaultConfig.SQLAddr
		c.SQLDB = defaultConfig.SQLDB
	}

	CopyistDB, close := mysql.NewCopyistTestDB(t, c)
	return New(
		fx.Replace(
			c,
			CopyistDB,
		),
		fx.Options(additionalOpts...),
		fx.NopLogger, // Disable logging during tests
	), close
}Code language: Go (go)

This code uses the fx Replace function to replace the service with the services that we have already instantiated.

Use in tests

In general, most of the tests using this setup are making use of the copyist DB recording, and generally want most of the configured services as they are.

So that the fully wired together router can be used in tests, each test function starts with these 3 lines, which grab the router from the fully configured fx application for use in the tests

var router *gin.Engine
_, close := NewForTests(t, fx.Populate(&router))
defer close()Code language: Go (go)

You may want to replace other services in specific tests, such as below where I replace the EmailSender service with a mock, which can then also be used and checked in the test.

emailSenderMock := mock.NewEmailSenderMock()
var router *gin.Engine
_, close := NewForTests(t,
	fx.Replace(fx.Annotate(emailSenderMock, fx.As(new(services.EmailSender)))),
	fx.Populate(&router),
)
defer close()Code language: Go (go)

In this case, services.EmailSender is an interface shared by the main implementation and the mock.

package services

type EmailSender interface {
	SendEmail(sender string, recipient string, subject string, html string, text string) (success bool, err error)
}
Code language: Go (go)

Test locations

So far the one downside I have found from this approach is it leads me to have these tests all within the app package rather than closer to the things I’m actually trying to test here, such as handlers.UserLogin.

But this is rather my fault, as these are not unit tests, they are full application tests, hence they start with the full application.

This can lead to undesirable test coverage indications if this is one of your primary testing methods for HTTP handlers etc, but I work around that by appending the -coverpkg=all option to go test so that all code touched by these tests is shown as covered no matter the package (otherwise only app shows as covered).