Go 语言的一个惯例就是让单元测试代码时刻伴随着你编写的 Go 代码。阅读过 Go 自身实现以及标准库代码的 gopher 都清楚,每个标准库的 Go 包都包含对应的测试代码。下面是对 Go 1.13 版本 Go 根目录的 src 下($GOROOT/src)Go 代码与对应的测试代码的代码量的粗略统计:

  1. // $GOROOT/src下面的Go代码(不包括单元测试代码):
  2. $find . -name "*.go" |grep -v test|xargs wc -l
  3. 71 ./cmd/vet/doc.go
  4. 59 ./cmd/vet/main.go
  5. 104 ./cmd/objdump/main.go
  6. 876 ./cmd/asm/internal/asm/asm.go
  7. 1349 ./cmd/asm/internal/asm/parse.go
  8. ... ...
  9. 2995 ./debug/elf/elf.go
  10. 1431 ./debug/elf/file.go
  11. 108 ./debug/elf/reader.go
  12. 1492338 total
  13. // $GOROOT/src下面的Go单元测试代码:
  14. $find . -name "*_test.go"|xargs wc -l
  15. 3 ./cmd/vet/testdata/testingpkg/tests_test.go
  16. 412 ./cmd/vet/vet_test.go
  17. 253 ./cmd/objdump/objdump_test.go
  18. ... ...
  19. 838 ./debug/elf/symbols_test.go
  20. 823 ./debug/elf/file_test.go
  21. 49 ./debug/elf/elf_test.go
  22. 345503 total

“写测试代码浪费时间”早已被证明是谬论,从一个软件系统或服务的全生命周期来看,编写测试代码正是为了”磨刀不费砍柴功“。不主动磨刀(编写测试代码),后续对代码进行修改和重构时要付出更多的代价,相应工作效率也会大打折扣。因此,写出好测试代码与写出好代码同等重要。

单元测试是自包含和自运行的,运行时一般是不会依赖外部资源(比如外部数据库、外部邮件服务器),并具备跨环境的可重复性(比如:既可以在开发人员本地运行,也可以在持续集成环境中运行)。因此,一旦被测代码中耦合了对外部资源的依赖,被测代码的可测试性就不会高,也会让开发人员有了”这段代码无法测试“的理由。为了提高代码的可测试性,我们就要降低代码耦合、管理被测代码中对外部的依赖。而这也是接口可以发挥其魔力的地方。本节我们就来看看如何使用接口来提高代码的可测试性。

1. 实现一个附加免责声明的电子邮件发送函数

我们将附加免责声明的电子邮件发送函数命名为:SendMailWithDisclaimer,其第一版实现如下:

  1. // send_mail_with_disclaimer/v1/mail.go
  2. package mail
  3. import (
  4. "net/smtp"
  5. email "github.com/jordan-wright/email"
  6. )
  7. const DISCLAIMER = `--------------------------------------------------------
  8. 免责声明:此电子邮件和任何附件可能包含特权和机密信息,仅供指定的收件人使用。如果您错误收到此电子邮件,请通知发件人 并立即删除此电子邮件。任何保密性,特权或版权都不会被放弃或丢失,因为此电子邮件是错误地发送给您的。您有责任检查此电子邮件和任何附件是否包含病毒。不保证此材料不含计算机病毒或任何其他缺陷或错误。使用本材料引起的任何损失/损坏不由寄件人负责。发件人的全部责任将仅限于重新提供材料。
  9. --------------------------------------------------------`
  10. func attachDisclaimer(content string) string {
  11. return content + "\n\n" + DISCLAIMER
  12. }
  13. func SendMailWithDisclaimer(subject, from string, to []string,
  14. content string, mailserver string,
  15. a smtp.Auth) error {
  16. e := email.NewEmail()
  17. e.From = from
  18. e.To = to
  19. e.Subject = subject
  20. e.Text = []byte(attachDisclaimer(content))
  21. return e.Send(mailserver, a)
  22. }

接下来为这个函数编写单元测试,见下面代码:

  1. //send_mail_with_disclaimer/v1/mail_test.go
  2. package mail_test
  3. import (
  4. "net/smtp"
  5. "testing"
  6. mail "github.com/bigwhite/mail"
  7. )
  8. func TestSendMail(t *testing.T) {
  9. err := mail.SendMailWithDisclaimer("gopher mail test v1",
  10. "YOUR_MAILBOX",
  11. []string{"DEST_MAILBOX"},
  12. "hello, gopher",
  13. "smtp.163.com:25",
  14. smtp.PlainAuth("", "YOUR_EMAIL_ACCOUNT", "YOUR_EMAIL_PASSWD!", "smtp.163.com"))
  15. if err != nil {
  16. t.Fatalf("want: nil, actual: %s\n", err)
  17. }
  18. }

由于github.com/jordan-wright/email中 Email 实例的 Send 方法会真实地去连接外部的 email 服务器,因此该测试每执行一次就会向目标电子邮箱发送一封电邮。如果用例中的参数有误或执行用例的环境无法联网又或无法访问 mail 服务器,那么这个测试将会以失败告终,因此这种测试代码并不具备跨环境的可重复性。而究根结底,其深层原因则是我们的第一版SendMailWithDisclaimer实现对github.com/jordan-wright/email包有着紧密的依赖,耦合较高。

2. 使用接口来降低耦合

接口本是契约,具有天然的降低耦合作用。下面我们就用接口对第一版SendMailWithDisclaimer实现进行改造,将对github.com/jordan-wright/email的依赖去除,将 email 发送的行为抽象成一个接口MailSender,并暴露给SendMailWithDisclaimer的用户:

  1. // send_mail_with_disclaimer/v2/mail.go
  2. // 考虑篇幅,这里省略一些代码
  3. ... ...
  4. type MailSender interface {
  5. Send(subject, from string, to []string, content string, mailserver string, a smtp.Auth) error
  6. }
  7. func SendMailWithDisclaimer(sender MailSender, subject, from string,
  8. to []string, content string, mailserver string, a smtp.Auth) error {
  9. return sender.Send(subject, from, to, attachDisclaimer(content), mailserver, a)
  10. }

现在如果要对SendMailWithDisclaimer进行测试,我们完全可以构造出一个或多个 fake MailSender(根据不同单元测试用例的需求定制),下面是一个例子:

  1. // send_mail_with_disclaimer/v2/mail_test.go
  2. package mail_test
  3. import (
  4. "net/smtp"
  5. "testing"
  6. mail "github.com/bigwhite/mail"
  7. )
  8. type FakeEmailSender struct {
  9. subject string
  10. from string
  11. to []string
  12. content string
  13. }
  14. func (s *FakeEmailSender) Send(subject, from string,
  15. to []string, content string, mailserver string, a smtp.Auth) error {
  16. s.subject = subject
  17. s.from = from
  18. s.to = to
  19. s.content = content
  20. return nil
  21. }
  22. func TestSendMailWithDisclaimer(t *testing.T) {
  23. s := &FakeEmailSender{}
  24. err := mail.SendMailWithDisclaimer(s, "gopher mail test v2",
  25. "YOUR_MAILBOX",
  26. []string{"DEST_MAILBOX"},
  27. "hello, gopher",
  28. "smtp.163.com:25",
  29. smtp.PlainAuth("", "YOUR_EMAIL_ACCOUNT", "YOUR_EMAIL_PASSWD!", "smtp.163.com"))
  30. if err != nil {
  31. t.Fatalf("want: nil, actual: %s\n", err)
  32. return
  33. }
  34. want := "hello, gopher" + "\n\n" + mail.DISCLAIMER
  35. if s.content != want {
  36. t.Fatalf("want: %s, actual: %s\n", want, s.content)
  37. }
  38. }

和 v1 版本中的测试用例不同,v2 版的测试用例不再对外部有任何依赖,是具备跨环境可重复性的。在这个用例我们对经过mail.SendMailWithDisclaimer处理后的 content 字段进行了验证,验证其是否包含了免责声明,这也是在 v1 版本中无法进行测试验证的。

如果我们依然要使用github.com/jordan-wright/email包中 Email 实例作为 email sender,那么由于 Email 类型并不是上面 MailSender 接口的实现者,我们需要在业务代码中做一些适配工作,比如下面代码:

  1. // send_mail_with_disclaimer/v2/example_test.go
  2. package mail_test
  3. import (
  4. "fmt"
  5. "net/smtp"
  6. mail "github.com/bigwhite/mail"
  7. email "github.com/jordan-wright/email"
  8. )
  9. type EmailSenderAdapter struct {
  10. e *email.Email
  11. }
  12. func (adapter *EmailSenderAdapter) Send(subject, from string,
  13. to []string, content string, mailserver string, a smtp.Auth) error {
  14. adapter.e.Subject = subject
  15. adapter.e.From = from
  16. adapter.e.To = to
  17. adapter.e.Text = []byte(content)
  18. return adapter.e.Send(mailserver, a)
  19. }
  20. func ExampleSendMailWithDisclaimer() {
  21. adapter := &EmailSenderAdapter{
  22. e: email.NewEmail(),
  23. }
  24. err := mail.SendMailWithDisclaimer(adapter, "gopher mail test v2",
  25. "YOUR_MAILBOX",
  26. []string{"DEST_MAILBOX"},
  27. "hello, gopher",
  28. "smtp.163.com:25",
  29. smtp.PlainAuth("", "YOUR_EMAIL_ACCOUNT", "YOUR_EMAIL_PASSWD!", "smtp.163.com"))
  30. if err != nil {
  31. fmt.Printf("SendMail error: %s\n", err)
  32. return
  33. }
  34. fmt.Println("SendMail ok")
  35. // OutPut:
  36. // SendMail ok
  37. }

SendMailWithDisclaimer的实现从 v1 版到 v2 版的变化可以用下面图示来更好地理解:

image.png

3. 小结

代码的可测试性(testability)已经成为了判定 Go 代码是否优秀的一条重要标准。适当抽取接口,让接口成为好代码与单元测试之间的桥梁是 Go 语言的一种最佳实践。