Don't use a different interface in tests. (05 May 2022) This is probably going to be an unpopular opinion, but; Your test code should not be using a different interface implementation compared to your non test code. Using different implementations(sometimes called 'mocking') is one of the best ways to lie to yourself that you are testing your code whereas you really aren't. An example will suffice to drive my point; let's say we have some functionality that is meant to be used in logging:
// logg writes msg to the data stream provided by w
func logg(w io.Writer, msg string) error {
msg = msg + "\n"
_, err := w.Write([]byte(msg))
return err
}
And the way we use it in our application(ie non-test code) is;
// httpLogger logs messages to a HTTP logging service
type httpLogger struct{}
// Write fulfills io.Writer interface
func (h httpLogger) Write(p []byte) (n int, err error) {
tr := &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 10 * time.Second,
DisableCompression: true,
}
client := &http.Client{
Transport: tr,
Timeout: 10 * time.Second,
}
// assume httpbin.org is an actual logging service.
resp, err := client.Post("https://httpbin.org/post", "application/json", bytes.NewReader(p))
return int(resp.Request.ContentLength), err
}
func main() {
err := logg(httpLogger{}, "hey-httpLogger")
if err != nil {
log.Fatal(err)
}
}
At this point we would now like to write some test for our application's code. What usually happens in
practice is that people
will create a 'mock' that fulfills the interface expected by the logg function instead of using the
type that their non-test code is already using(ie httpLogger)
func Test_logg(t *testing.T) {
msg := "hey"
mockWriter := &bytes.Buffer{}
err := logg(mockWriter, msg)
if err != nil {
t.Fatalf("logg() got error = %v, wantErr = %v", err, nil)
return
}
gotMsg := mockWriter.String()
wantMsg := msg + "\n"
if gotMsg != wantMsg {
t.Fatalf("logg() got = %v, want = %v", gotMsg, wantMsg)
}
}
And it is not just developers that will write code like this. I checked both VS Code & JetBrains GoLand;
they both used a &bytes.Buffer{} when I asked them to Generate test for this
code.
The problem with this code is; the only thing we have tested is that bytes.Buffer implements
the io.Writer interface and that httpLogger also implements the same.
In other words, the only thing that our test/s have done is just duplicate the compile-time checks that Go
is
already giving us for free. The bulk of our application's implementation(the Write method of
httpLogger) is still
wholly untested.
A photo of the test coverage as produced by go test -cover illustrates the situation more
clearly;

// betterhttpLogger logs messages to a HTTP logging service
type betterhttpLogger struct {
test struct {
enabled bool
written string
}
}
// Write fulfills io.Writer interface
func (h *betterhttpLogger) Write(p []byte) (n int, err error) {
tr := &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 10 * time.Second,
DisableCompression: true,
}
client := &http.Client{
Transport: tr,
Timeout: 10 * time.Second,
}
if h.test.enabled {
mockWriter := &bytes.Buffer{}
n, err := mockWriter.Write(p)
h.test.written = mockWriter.String()
return n, err
}
// assume httpbin.org is an actual logging service.
resp, err := client.Post("https://httpbin.org/post", "application/json", bytes.NewReader(p))
return int(resp.Request.ContentLength), err
}
func main() {
err := logg(&betterhttpLogger{}, "hey-httpLogger")
if err != nil {
log.Fatal(err)
}
}
And the test code will be,
func Test_logg(t *testing.T) {
msg := "hey"
w := &betterhttpLogger{test: struct {
enabled bool
written string
}{enabled: true}}
err := logg(w, msg)
if err != nil {
t.Fatalf("logg() got error = %v, wantErr = %v", err, nil)
return
}
gotMsg := w.test.written
wantMsg := msg + "\n"
if gotMsg != wantMsg {
t.Fatalf("logg() got = %v, want = %v", gotMsg, wantMsg)
}
}
Notice that in this version, the only piece of code that is not tested is just one line;
http.Client.Post("https://httpbin.org/post", "application/json", bytes.NewReader(p))
And guess what? Since that line is calling code from the standard library, you can bet that it
is one of
the most heavily
tested functionality out there.
Here's the test coverage of this version:

var testHookServerServe func(*Server, net.Listener) // used if non-nil
...
func (srv *Server) Serve(l net.Listener) error {
if fn := testHookServerServe; fn != nil {
fn(srv, l) // call hook with unwrapped listener
}
...
Here's also a good blogpost that makes a similar kind of argument
"Do not use interfaces at all, just add test hooks to your real-life structs" - @jrockwayAnd another one by James Shore that expresses the same idea but uses the term, nullables.