Interfaces in Go are really great. They help us get good test coverage and make our code's dependencies explicit and therefore easier to read.
Let's look at some existing code and see how we can change it to make the dependencies explicit.
The important thing to focus on here is that this function takes no parameters but calls out to imported functions such as glog
, http
and netutil
.
// Check validates http connectivity type, direct or via proxy.
func Check() (warnings, errors []error) {
glog.V(1).Infof("validating if the connectivity type is via proxy or direct")
u := (&url.URL{Scheme: hst.Proto, Host: hst.Host}).String()
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return nil, []error{err}
}
proxy, err := netutil.SetOldTransportDefaults(&http.Transport{}).Proxy(req)
if err != nil {
return nil, []error{err}
}
if proxy != nil {
return []error{fmt.Errorf("Connection to %q uses proxy %q. If that is not intended, adjust your proxy settings", u, proxy)}, nil
}
return nil, nil
}
(source)
Let's look at the first line of the function: glog.V(1).Infof(...)
. All we really care about here is that we can call Infof
. We can define a type that exactly satisfies that property:
type logger interface {
Infof(string, ...interface{})
}
Now we can have our function depend on just that interface.
func Check(log logger) (warnings, errors []error) { ... }
And we can replace the call to glog.V(1).Infof
with log.Infof
.
type logger interface {
Infof(string, ...interface{})
}
func Check(log logger) (warnings, errors []error) {
log.Infof("validating if the connectivity type is via proxy or direct")
...
}
func main() {
// glog.V(1) is now merely an implementation detail. This function accepts anything that implements our interface!
Check(glog.V(1))
}
The process we just went through is this:
- Look at the dependency we want to be able to control.
- Extract the behavior into an interface.
- Pass that interface into the function.
- Replace the function call with a method call to the interface passed in.
- Pass an implementation of the interface we defined into the function call.
We can repeat this for any of the other implicit dependencies we want. Here is a more complex refactor that wraps http.NewRequest
in a struct that implements an interface we create that exactly defines the behavior we need.
type requestBuilder interface {
NewRequest(string, string, io.Reader) (*http.Request, error)
}
type reqBuilder struct {}
func (rb *reqBuilder) NewRequest(method, url string, body io.Reader) (*http.Request, error) {
return http.NewRequest(method, url, body)
}
func Check(rb requestBuilder) (warnings, errors []error) {
...
req, err := rb.NewRequest("GET", u, nil)
if err != nil {
return nil, []error{err}
}
...
}
func main() {
Check(&reqBuilder{})
}
Why would we go through this much trouble? Well, in this case we really wouldn't, but it all depends on the situation. If the error case were very complex and we had a need to test specific errors that are returned from NewRequest
, then this makes sense to me. But if you just care about testing err != nil
, it's a lot easier to read the http.NewRequest
source and find out how to generate an error from that function.
The last dependency, netutil
, is left as an exercise to the reader.
One other very important thing to note is that the interfaces are defined at the call site. They are not imported. We don't import interfaces because it is very likely the interface we import would be larger than what we actually need. Our interfaces should be exactly the behavior we seek and nothing more.
Top comments (2)
I could see why u'de like to declare dependencies like http
But why would you hassel yourself for a logger?
I'de rather to setup logger config for tests at the beginning of the test suite (i usually turn it off) thus declaring only the important dependecies such as http calls ...
Sometimes loggers are fine global, but sometimes, particularly if you don't want to be tied to one logging implementation, it's better to make the dependency explicit because your code can evolve much more easily as software trends start to mature.
I hear you though, I use a global logger when it makes sense and use dependency injection when it makes sense. Refactoring like this is all about situational awareness. There is no one size fits all solution.