首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Go 单元测试与接口

Go 单元测试与接口

作者头像
李海彬
发布2018-10-08 14:46:51
6780
发布2018-10-08 14:46:51
举报
文章被收录于专栏:Golang语言社区Golang语言社区

原文作者:Andrei Avram

  • 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:

 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:

  • 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:

 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.


版权申明:内容来源网络,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知,我们会立即删除并表示歉意。谢谢。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2018-08-20,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Golang语言社区 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档