Dependency injection in go using fx, and replacing services for test
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).