Please Use Dependency Injection, If Not For Me, Do It For Your Unit Tests
TLDR; see title.
Long time no see, and it’s been a while since we haven’t talked about Go. I still love the language, I still use it on a daily basis, and today I feel like talking a bit about dependency injection. So hold on to your seats because it’s going to get boring very soon, or not we’ll see.
What’s the problem?
Take the following file.
It has a Fetch
function that takes base currency and a target currency, and it fetches the exchange rate between
the two currencies by contacting an API that has that information in JSON format.
package exchangerate
import (
"encoding/json"
"fmt"
"net/http"
"os"
)
const (
exchangeRateApiAddrEnvVar = "EXCHANGE_RATE_API_ADDR"
)
func Fetch(base, target string) (rate float64, err error) {
addr := "https://api.exchangerate.host"
if s := os.Getenv(exchangeRateApiAddrEnvVar); s != "" {
addr = s
}
resp, err := http.Get(fmt.Sprintf("%s/latest?base=%s&symbols=%s", addr, base, target))
if err != nil {
return 0, fmt.Errorf("failed to contact exchangerate API: %w", err)
}
defer resp.Body.Close()
r := ExchangeRatesApiResponse{}
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return 0, fmt.Errorf("failed to json decode exchange rate api response: %w", err)
}
return r.Rates[target], nil
}
type ExchangeRatesApiResponse struct {
Rates map[string]float64 `json:"rates"`
}
Let’s say we want to write a unit test for Fetch
.
Since it’s going to be easier to write it if we control the data returned by the API,
and since we can’t change what the actual API returns (at least I can’t, and maybe you can
and if that’s the case, well thank you this is a cool API), we have made the function to
fetch the address set in an environment variable.
So we’ll use that to make the test use a local test HTTP server. It’s easy enough, let’s do this.
package exchangerate
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestFetch(t *testing.T) {
tests := []struct {
name string
base string
target string
expectedRate float64
}{
{
name: "EUR -> CAD",
base: "EUR",
target: "CAD",
expectedRate: 1.30,
},
{
name: "CAD -> EUR",
base: "CAD",
target: "EUR",
expectedRate: 0.75,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, fmt.Sprintf("/latest?base=%s&symbols=%s", test.base, test.target), r.URL.String())
_ = json.NewEncoder(w).Encode(&ExchangeRatesApiResponse{
Rates: map[string]float64{
"CAD": 1.30,
"EUR": 0.75,
},
})
}))
defer testServer.Close()
_ = os.Setenv(exchangeRateApiAddrEnvVar, testServer.URL)
rate, err := Fetch(test.base, test.target)
assert.NoError(t, err)
assert.Equal(t, test.expectedRate, rate)
})
}
}
There, the function has a test.
Okay but what’s the problem?
Here is the main file.
It just calls the Fetch
function and passes it the base currency and the target currency
that it got from command flags.
package main
import (
"flag"
"fmt"
"os"
"github.com/aubm/golang-dependency-injection/pkg/exchangerate"
)
func main() {
var base string
var target string
flag.StringVar(&base, "base", "EUR", "the base currency")
flag.StringVar(&target, "target", "CAD", "the target currency")
flag.Parse()
rate, err := exchangerate.Fetch(base, target)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "failed to fetch %s to %s exchange rate: %v", base, target, err)
}
_, _ = fmt.Fprintf(os.Stdout, "%s to %s exchange rate is %v", base, target, rate)
}
Now to the problem: since Fetch(...)
is baked in main
, if we want
to write a unit test on the main function, we’ll have to do the whole
local test http server sh*t again, which is boring (told you) and really
doesn’t scale when the code base gains weight.
Let’s talk about two very similar techniques to tackle that situation that I’ve observed, and why I think they’re wrong.
Make it tomorrow’s problem
This is actually a pretty smart technique in situations where the person who will be working on the project tomorrow is not you. Some will argue that it is not nice, but I’m not here to judge.
In most situations however, that person is going to be you, and pooping on the floor that is yours to clean is not a very rational thing to do. But again I’m not here to judge, so if you think this is okay, by all means, poop away!
Make do
Here is the deal: if we don’t want to repeat the things we’ve done in the unit test
for Fetch
, let’s just test the most of main
that is not the call to Fetch
.
Here is what the new main
function looks like.
package main
import (
"flag"
"fmt"
"os"
"github.com/aubm/golang-dependency-injection/pkg/exchangerate"
)
func main() {
base, target := getBaseAndTarget()
rate, err := exchangerate.Fetch(base, target)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "failed to fetch %s to %s exchange rate: %v", base, target, err)
}
_, _ = fmt.Fprintf(os.Stdout, "%s to %s exchange rate is %v", base, target, rate)
}
func getBaseAndTarget() (string, string) {
var base string
var target string
flag.StringVar(&base, "base", "EUR", "the base currency")
flag.StringVar(&target, "target", "CAD", "the target currency")
flag.Parse()
return base, target
}
There, now we can just have a unit test on getBaseAndTarget
and job done.
If you feel satisfied with this solution, then I respectfully disagree.
To me this feels like admitting our defeat. We paid the lack of a better solution in test reliability. Conclusion: Go sucks, programming sucks, bye.
Or we can try another approach.
Dependency injection is your scary friend
Look at this new main file.
package main
import (
"log"
"github.com/aubm/golang-dependency-injection/pkg/app"
)
func main() {
app := app.Initialize()
app.Run()
}
Beautiful, isn’t it? I like it empty too.
Now I’ve moved everything into a new app
package.
Let’s open it and see what is different from before.
This is app.go
.
package app
import (
"flag"
"fmt"
"os"
"github.com/aubm/golang-dependency-injection/pkg/exchangerate"
)
func NewApp(fetcher *exchangerate.ApiFetcher) *App {
return &App{fetcher: fetcher}
}
type App struct {
fetcher exchangerate.Fetcher
}
func (a *App) Run() error {
var base string
var target string
flag.StringVar(&base, "base", "EUR", "the base currency")
flag.StringVar(&target, "target", "CAD", "the target currency")
flag.Parse()
rate, err := a.fetcher.Fetch(base, target)
if err != nil {
return fmt.Errorf("failed to fetch %s to %s exchange rate: %w", base, target, err)
}
_, _ = fmt.Fprintf(os.Stdout, "%s to %s exchange rate is %v", base, target, rate)
return nil
}
Everything that used to be in main
is now in Run
, with one
little change though: Run
is attached to *App
, which has a fetcher
property
of type exchangerate.Fetcher
.
What is it, you asked? It’s an interface, take a look at it.
type Fetcher interface {
Fetch(base, target string) (rate float64, err error)
}
What’s good with an interface is that you can pass an actual implementation, let say something like that.
func NewApiFetcher() *ApiFetcher {
return &ApiFetcher{}
}
type ApiFetcher struct{}
func (*ApiFetcher) Fetch(base, target string) (rate float64, err error) {
// nothing new under the sun
}
or you something completely different, say…
type MockFetcher struct{}
func (*MockFetcher) Fetch(base, target string) (rate float64, err error) {
println("I do nothing lol")
return 0, nil
}
And with that, we could write a proper unit test on *App.Run
, because that
will build.
package app
import "testing"
func TestApp_Run(t *testing.T) {
app := &App{
fetcher: &MockFetcher{},
}
app.Run()
}
And the cherry on the top: to a certain extent (which is the interface definition)
we can change the implementation of *ApiFetch.Fetch
without changing a single line
to *App.Run
or its unit test. Now we have scalability.
In a real world case, it would be good to have MockFetcher
work for us
by making the returned values configurable so that we can test how *App.Run
reacts to different values. For the test to be complete, we would also want to
make sure *App.fetcher.Fetch
is actually called.
There are libraries out there that can help you generate mocks based on interfaces
definitions. One I like is github.com/golang/mock.
Bonus take
You notice that I haven’t zoomed in app.Initialize()
, which is the first line in the
new almost empty main
.
I assume you guessed what’s in there but let’s look anyway at init.go
.
package app
import "github.com/aubm/golang-dependency-injection/pkg/exchangerate"
func Initialize() *App {
fetcher := exchangerate.NewApiFetcher()
app := NewApp(fetcher)
return app
}
Here we use the constructor functions that we saw earlier to build the whole application graph.
Since in this case we only have two types, this is easy enough.
But keeping Initialize
in sync with the changes throughout a codebase that
goes larger and larger as we add more types that depend on each others is boring.
It’s not particularly difficult, but it really is boring.
This is something github.com/google/wire can help you with.