- 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 Golang unit 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.
Thanks so much for this article. It filled in a lot of holes in my understanding. Very well done! One a minor note, the following worked for me, although I may find out in a bit that I’m wrong.
var smtpClient = func(addr string) (dialer,error) {
return smtp.Dial(addr)
}
Using smtp.Dial will actually connect to the specified address and return a real SMTP client. This would be an integration test which verifies how your code works together with a real instance of your dependency (the SMTP client).
If you aim for a pure unit test which mocks all external dependencies (uses fake instances which look like the real ones), you’ll have to provide a SMTP client mock (that’s why I used smtpDialerMock).
I’m glad the article helped you out!