Unit testing and interfaces

  • Good code needs tests
  • Tests require good design
  • Good design implies decoupling
  • Interfaces help decouple
  • Decoupling lets you write tests
  • Tests help having good code

Good code and unit testing come hand in hand, and sometimes the bridge between them are interfaces. When you have an interface, you can easily “hide” any implementation behind it, even a mock for a unit test.

An important subject of unit testing is managing external dependencies. The tests should directly cover the unit while using fake replacements (mocks) for the dependencies.

I was given the following code and asked to write tests for it:

package mail

import (
   "fmt"
   "net"
   "net/smtp"
   "strings"
)

func ValidateHost(email string) (err error) {
   mx, err := net.LookupMX(host(email))
   if err != nil {
      return err
   }

   client, err := smtp.Dial(fmt.Sprintf("%s:%d", mx[0].Host, 25))
   if err != nil {
      return err
   }

   defer func() {
      if er := client.Close(); er != nil {
         err = er
      }
   }()

   if err = client.Hello("checkmail.me"); err != nil {
      return err
   }
   if err = client.Mail("testing-email-host@gmail.com"); err != nil {
      return err
   }
   return client.Rcpt(email)
}

func host(email string) (host string) {
   i := strings.LastIndexByte(email, '@')
   return email[i+1:]
}

The first steps were to identify test cases and dependencies:

  • Cases are represented by each flow that the code can go through, starting with the normal one when the last return gives no error, and then all the branches that can change this normal flow (all the if statements). So beware of complexity. More branches, mores cases, more tests, more possible issues.
  • The dependencies that will be mocked net.LookupMX and smtp.Dial.

The first test would look like this:

package mail

import (
   "testing"

   "github.com/stretchr/testify/assert"
)

func TestValidateHost(t *testing.T) {
   email := "mail@host.tld"
   actual := ValidateHost(email)
   assert.NoError(t, actual)
}

And the result:

=== RUN   TestValidateHost
--- FAIL: TestValidateHost (0.02s)
mail_test.go:12:
Error Trace:   mail_test.go:12
Error:         Received unexpected error:
lookup host.tld on 127.0.1.1:53: no such host
Test:          TestValidateHost
FAIL

The net.LookupMX function was actually called, but it’s a dependency, so we need to mock it. Go has first class functions, so net.LookupMX can be assigned to a variable:

package mail

import (
   "fmt"
   "net"
   "net/smtp"
   "strings"
)

var netLookupMX = net.LookupMX

func ValidateHost(email string) (err error) {
   mx, err := netLookupMX(host(email))
   if err != nil {
      return err
   }

   ...
}

...

Then replaced in the test:

package mail

import (
   "net"
   "testing"

   "github.com/stretchr/testify/assert"
)

func TestValidateHost(t *testing.T) {
   netLookupMX = func(name string) ([]*net.MX, error) {
      mxs := []*net.MX{
         {
            Host: "host.tld",
            Pref: 1,
         },
      }

      return mxs, nil
   }

   ...
}

Our custom function will be called instead of the real one.

=== RUN   TestValidateHost
--- FAIL: TestValidateHost (0.01s)
mail_test.go:24:
Error Trace:   mail_test.go:24
Error:         Received unexpected error:
dial tcp: lookup host.tld on 127.0.1.1:53: no such host
Test:          TestValidateHost
FAIL

Now, the test fails because of the smtp.Dial call. We’ll handle this situation like we did for the first one, but there’s one important difference: the function smtp.Dial returns an SMTP Client that we need a mock for, and here the interface comes to help.

Let’s create a function to return the real SMTP client in the implementation and the mock in the test. This is a polymorphic situation: two implementations described by an interface. So let’s also have an interface which is implemented by both real and mock SMTP client.

package mail

import (
   "fmt"
   "net"
   "net/smtp"
   "strings"
)

type dialer interface {
   Close() error
   Hello(localName string) error
   Mail(from string) error
   Rcpt(to string) error
}

var (
   netLookupMX = net.LookupMX
   smtpClient  = func(addr string) (dialer, error) {
      // Dial the tcp connection
      conn, err := net.Dial("tcp", addr)
      if err != nil {
         return nil, err
      }

      // Connect to the SMTP server
      c, err := smtp.NewClient(conn, addr)
      if err != nil {
         return nil, err
      }

      return c, nil
   }
)

func ValidateHost(email string) (err error) {
   mx, err := netLookupMX(host(email))
   if err != nil {
      return err
   }

   client, err := smtpClient(fmt.Sprintf("%s:%d", mx[0].Host, 25))
   if err != nil {
      return err
   }

   defer func() {
      if er := client.Close(); er != nil {
         err = er
      }
   }()

   if err = client.Hello("checkmail.me"); err != nil {
      return err
   }
   if err = client.Mail("testing-email-host@gmail.com"); err != nil {
      return err
   }
   return client.Rcpt(email)
}

func host(email string) (host string) {
   i := strings.LastIndexByte(email, '@')
   return email[i+1:]
}

The real SMTP client implements our interface because it has all its methods.

package mail

import (
   "net"
   "testing"

   "github.com/stretchr/testify/assert"
)

type smtpDialerMock struct {
}

func (*smtpDialerMock) Close() error {
   return nil
}
func (*smtpDialerMock) Hello(localName string) error {
   return nil
}
func (*smtpDialerMock) Mail(from string) error {
   return nil
}
func (*smtpDialerMock) Rcpt(to string) error {
   return nil
}

func TestValidateHost(t *testing.T) {
   netLookupMX = func(name string) ([]*net.MX, error) {
      mxs := []*net.MX{
         {
            Host: "host.tld",
            Pref: 1,
         },
      }

      return mxs, nil
   }

   smtpClient = func(addr string) (dialer, error) {
      client := &smtpDialerMock{}
      return client, nil
   }

   email := "mail@host.tld"
   actual := ValidateHost(email)
   assert.NoError(t, actual)
}

For our mock we’ve implemented the required methods.

=== RUN   TestValidateHost
--- PASS: TestValidateHost (0.00s)
PASS

As for the other test cases (the errors), we will create a different mock for each one.

type smtpDialerMockFail struct {
}

func (*smtpDialerMockFail) Close() error {
   return nil
}
func (*smtpDialerMockFail) Hello(localName string) error {
   return errors.New("err")
}
func (*smtpDialerMockFail) Mail(from string) error {
   return nil
}
func (*smtpDialerMockFail) Rcpt(to string) error {
   return nil
}

func TestValidateHostWhenFails(t *testing.T) {
   netLookupMX = func(name string) ([]*net.MX, error) {
      mxs := []*net.MX{
         {
            Host: "host.tld",
            Pref: 1,
         },
      }

      return mxs, nil
   }

   smtpClient = func(addr string) (dialer, error) {
      client := &smtpDialerMockFail{}
      return client, nil
   }

   email := "mail@host.tld"
   actual := ValidateHost(email)
   assert.Error(t, actual)
}

You can write a test function for each case or you can write table driven tests. When I want to be very precises about all the behaviors, I prefer writing a test function for each case.

You may have noticed I’ve used Testify, a great testing package. Besides assertions, it also offers a mocking framework. I’ve written basic mocks, but Testify helps you generate advanced and fluent mocks on which you can test more details of their behavior.

One thought on “Unit testing and interfaces”

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.