原文作者:Andrei Avram
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:
1package mail
2
3import (
4 "fmt"
5 "net"
6 "net/smtp"
7 "strings"
8)
9
10func ValidateHost(email string) (err error) {
11 mx, err := net.LookupMX(host(email))
12 if err != nil {
13 return err
14 }
15
16 client, err := smtp.Dial(fmt.Sprintf("%s:%d", mx[0].Host, 25))
17 if err != nil {
18 return err
19 }
20
21 defer func() {
22 if er := client.Close(); er != nil {
23 err = er
24 }
25 }()
26
27 if err = client.Hello("checkmail.me"); err != nil {
28 return err
29 }
30 if err = client.Mail("testing-email-host@gmail.com"); err != nil {
31 return err
32 }
33 return client.Rcpt(email)
34}
35
36func host(email string) (host string) {
37 i := strings.LastIndexByte(email, '@')
38 return email[i+1:]
39}
The first steps were to identify test cases and dependencies:
The first test would look like this:
1package mail
2
3import (
4 "testing"
5
6 "github.com/stretchr/testify/assert"
7)
8
9func TestValidateHost(t *testing.T) {
10 email := "mail@host.tld"
11 actual := ValidateHost(email)
12 assert.NoError(t, actual)
13}
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:
1package mail
2
3import (
4 "fmt"
5 "net"
6 "net/smtp"
7 "strings"
8)
9
10var netLookupMX = net.LookupMX
11
12func ValidateHost(email string) (err error) {
13 mx, err := netLookupMX(host(email))
14 if err != nil {
15 return err
16 }
17
18 ...
19}
20
21...
Then replaced in the test:
1package mail
2
3import (
4 "net"
5 "testing"
6
7 "github.com/stretchr/testify/assert"
8)
9
10func TestValidateHost(t *testing.T) {
11 netLookupMX = func(name string) ([]*net.MX, error) {
12 mxs := []*net.MX{
13 {
14 Host: "host.tld",
15 Pref: 1,
16 },
17 }
18
19 return mxs, nil
20 }
21
22 ...
23}
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.
1package mail
2
3import (
4 "fmt"
5 "net"
6 "net/smtp"
7 "strings"
8)
9
10type dialer interface {
11 Close() error
12 Hello(localName string) error
13 Mail(from string) error
14 Rcpt(to string) error
15}
16
17var (
18 netLookupMX = net.LookupMX
19 smtpClient = func(addr string) (dialer, error) {
20 // Dial the tcp connection
21 conn, err := net.Dial("tcp", addr)
22 if err != nil {
23 return nil, err
24 }
25
26 // Connect to the SMTP server
27 c, err := smtp.NewClient(conn, addr)
28 if err != nil {
29 return nil, err
30 }
31
32 return c, nil
33 }
34)
35
36func ValidateHost(email string) (err error) {
37 mx, err := netLookupMX(host(email))
38 if err != nil {
39 return err
40 }
41
42 client, err := smtpClient(fmt.Sprintf("%s:%d", mx[0].Host, 25))
43 if err != nil {
44 return err
45 }
46
47 defer func() {
48 if er := client.Close(); er != nil {
49 err = er
50 }
51 }()
52
53 if err = client.Hello("checkmail.me"); err != nil {
54 return err
55 }
56 if err = client.Mail("testing-email-host@gmail.com"); err != nil {
57 return err
58 }
59 return client.Rcpt(email)
60}
61
62func host(email string) (host string) {
63 i := strings.LastIndexByte(email, '@')
64 return email[i+1:]
65}
The real SMTP client implements our interface because it has all its methods.
1package mail
2
3import (
4 "net"
5 "testing"
6
7 "github.com/stretchr/testify/assert"
8)
9
10type smtpDialerMock struct {
11}
12
13func (*smtpDialerMock) Close() error {
14 return nil
15}
16func (*smtpDialerMock) Hello(localName string) error {
17 return nil
18}
19func (*smtpDialerMock) Mail(from string) error {
20 return nil
21}
22func (*smtpDialerMock) Rcpt(to string) error {
23 return nil
24}
25
26func TestValidateHost(t *testing.T) {
27 netLookupMX = func(name string) ([]*net.MX, error) {
28 mxs := []*net.MX{
29 {
30 Host: "host.tld",
31 Pref: 1,
32 },
33 }
34
35 return mxs, nil
36 }
37
38 smtpClient = func(addr string) (dialer, error) {
39 client := &smtpDialerMock{}
40 return client, nil
41 }
42
43 email := "mail@host.tld"
44 actual := ValidateHost(email)
45 assert.NoError(t, actual)
46}
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.
1package mail
2
3import (
4 "fmt"
5 "net"
6 "net/smtp"
7 "strings"
8)
9
10type dialer interface {
11 Close() error
12 Hello(localName string) error
13 Mail(from string) error
14 Rcpt(to string) error
15}
16
17var (
18 netLookupMX = net.LookupMX
19 smtpClient = func(addr string) (dialer, error) {
20 // Dial the tcp connection
21 conn, err := net.Dial("tcp", addr)
22 if err != nil {
23 return nil, err
24 }
25
26 // Connect to the SMTP server
27 c, err := smtp.NewClient(conn, addr)
28 if err != nil {
29 return nil, err
30 }
31
32 return c, nil
33 }
34type smtpDialerMockFail struct {
35}
36
37func (*smtpDialerMockFail) Close() error {
38 return nil
39}
40func (*smtpDialerMockFail) Hello(localName string) error {
41 return errors.New("err")
42}
43func (*smtpDialerMockFail) Mail(from string) error {
44 return nil
45}
46func (*smtpDialerMockFail) Rcpt(to string) error {
47 return nil
48}
49
50func TestValidateHostWhenFails(t *testing.T) {
51 netLookupMX = func(name string) ([]*net.MX, error) {
52 mxs := []*net.MX{
53 {
54 Host: "host.tld",
55 Pref: 1,
56 },
57 }
58
59 return mxs, nil
60 }
61
62 smtpClient = func(addr string) (dialer, error) {
63 client := &smtpDialerMockFail{}
64 return client, nil
65 }
66
67 email := "mail@host.tld"
68 actual := ValidateHost(email)
69 assert.Error(t, actual)
70}
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.
版权申明:内容来源网络,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知,我们会立即删除并表示歉意。谢谢。