Go 语言的一个惯例就是让单元测试代码时刻伴随着你编写的 Go 代码。阅读过 Go 自身实现以及标准库代码的 gopher 都清楚,每个标准库的 Go 包都包含对应的测试代码。下面是对 Go 1.13 版本 Go 根目录的 src 下($GOROOT/src)Go 代码与对应的测试代码的代码量的粗略统计:
// $GOROOT/src下面的Go代码(不包括单元测试代码):$find . -name "*.go" |grep -v test|xargs wc -l71 ./cmd/vet/doc.go59 ./cmd/vet/main.go104 ./cmd/objdump/main.go876 ./cmd/asm/internal/asm/asm.go1349 ./cmd/asm/internal/asm/parse.go... ...2995 ./debug/elf/elf.go1431 ./debug/elf/file.go108 ./debug/elf/reader.go1492338 total// $GOROOT/src下面的Go单元测试代码:$find . -name "*_test.go"|xargs wc -l3 ./cmd/vet/testdata/testingpkg/tests_test.go412 ./cmd/vet/vet_test.go253 ./cmd/objdump/objdump_test.go... ...838 ./debug/elf/symbols_test.go823 ./debug/elf/file_test.go49 ./debug/elf/elf_test.go345503 total
“写测试代码浪费时间”早已被证明是谬论,从一个软件系统或服务的全生命周期来看,编写测试代码正是为了”磨刀不费砍柴功“。不主动磨刀(编写测试代码),后续对代码进行修改和重构时要付出更多的代价,相应工作效率也会大打折扣。因此,写出好测试代码与写出好代码同等重要。
单元测试是自包含和自运行的,运行时一般是不会依赖外部资源(比如外部数据库、外部邮件服务器),并具备跨环境的可重复性(比如:既可以在开发人员本地运行,也可以在持续集成环境中运行)。因此,一旦被测代码中耦合了对外部资源的依赖,被测代码的可测试性就不会高,也会让开发人员有了”这段代码无法测试“的理由。为了提高代码的可测试性,我们就要降低代码耦合、管理被测代码中对外部的依赖。而这也是接口可以发挥其魔力的地方。本节我们就来看看如何使用接口来提高代码的可测试性。
1. 实现一个附加免责声明的电子邮件发送函数
我们将附加免责声明的电子邮件发送函数命名为:SendMailWithDisclaimer,其第一版实现如下:
// send_mail_with_disclaimer/v1/mail.gopackage mailimport ("net/smtp"email "github.com/jordan-wright/email")const DISCLAIMER = `--------------------------------------------------------免责声明:此电子邮件和任何附件可能包含特权和机密信息,仅供指定的收件人使用。如果您错误收到此电子邮件,请通知发件人 并立即删除此电子邮件。任何保密性,特权或版权都不会被放弃或丢失,因为此电子邮件是错误地发送给您的。您有责任检查此电子邮件和任何附件是否包含病毒。不保证此材料不含计算机病毒或任何其他缺陷或错误。使用本材料引起的任何损失/损坏不由寄件人负责。发件人的全部责任将仅限于重新提供材料。--------------------------------------------------------`func attachDisclaimer(content string) string {return content + "\n\n" + DISCLAIMER}func SendMailWithDisclaimer(subject, from string, to []string,content string, mailserver string,a smtp.Auth) error {e := email.NewEmail()e.From = frome.To = toe.Subject = subjecte.Text = []byte(attachDisclaimer(content))return e.Send(mailserver, a)}
接下来为这个函数编写单元测试,见下面代码:
//send_mail_with_disclaimer/v1/mail_test.gopackage mail_testimport ("net/smtp""testing"mail "github.com/bigwhite/mail")func TestSendMail(t *testing.T) {err := mail.SendMailWithDisclaimer("gopher mail test v1","YOUR_MAILBOX",[]string{"DEST_MAILBOX"},"hello, gopher","smtp.163.com:25",smtp.PlainAuth("", "YOUR_EMAIL_ACCOUNT", "YOUR_EMAIL_PASSWD!", "smtp.163.com"))if err != nil {t.Fatalf("want: nil, actual: %s\n", err)}}
由于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的用户:
// send_mail_with_disclaimer/v2/mail.go// 考虑篇幅,这里省略一些代码... ...type MailSender interface {Send(subject, from string, to []string, content string, mailserver string, a smtp.Auth) error}func SendMailWithDisclaimer(sender MailSender, subject, from string,to []string, content string, mailserver string, a smtp.Auth) error {return sender.Send(subject, from, to, attachDisclaimer(content), mailserver, a)}
现在如果要对SendMailWithDisclaimer进行测试,我们完全可以构造出一个或多个 fake MailSender(根据不同单元测试用例的需求定制),下面是一个例子:
// send_mail_with_disclaimer/v2/mail_test.gopackage mail_testimport ("net/smtp""testing"mail "github.com/bigwhite/mail")type FakeEmailSender struct {subject stringfrom stringto []stringcontent string}func (s *FakeEmailSender) Send(subject, from string,to []string, content string, mailserver string, a smtp.Auth) error {s.subject = subjects.from = froms.to = tos.content = contentreturn nil}func TestSendMailWithDisclaimer(t *testing.T) {s := &FakeEmailSender{}err := mail.SendMailWithDisclaimer(s, "gopher mail test v2","YOUR_MAILBOX",[]string{"DEST_MAILBOX"},"hello, gopher","smtp.163.com:25",smtp.PlainAuth("", "YOUR_EMAIL_ACCOUNT", "YOUR_EMAIL_PASSWD!", "smtp.163.com"))if err != nil {t.Fatalf("want: nil, actual: %s\n", err)return}want := "hello, gopher" + "\n\n" + mail.DISCLAIMERif s.content != want {t.Fatalf("want: %s, actual: %s\n", want, s.content)}}
和 v1 版本中的测试用例不同,v2 版的测试用例不再对外部有任何依赖,是具备跨环境可重复性的。在这个用例我们对经过mail.SendMailWithDisclaimer处理后的 content 字段进行了验证,验证其是否包含了免责声明,这也是在 v1 版本中无法进行测试验证的。
如果我们依然要使用github.com/jordan-wright/email包中 Email 实例作为 email sender,那么由于 Email 类型并不是上面 MailSender 接口的实现者,我们需要在业务代码中做一些适配工作,比如下面代码:
// send_mail_with_disclaimer/v2/example_test.gopackage mail_testimport ("fmt""net/smtp"mail "github.com/bigwhite/mail"email "github.com/jordan-wright/email")type EmailSenderAdapter struct {e *email.Email}func (adapter *EmailSenderAdapter) Send(subject, from string,to []string, content string, mailserver string, a smtp.Auth) error {adapter.e.Subject = subjectadapter.e.From = fromadapter.e.To = toadapter.e.Text = []byte(content)return adapter.e.Send(mailserver, a)}func ExampleSendMailWithDisclaimer() {adapter := &EmailSenderAdapter{e: email.NewEmail(),}err := mail.SendMailWithDisclaimer(adapter, "gopher mail test v2","YOUR_MAILBOX",[]string{"DEST_MAILBOX"},"hello, gopher","smtp.163.com:25",smtp.PlainAuth("", "YOUR_EMAIL_ACCOUNT", "YOUR_EMAIL_PASSWD!", "smtp.163.com"))if err != nil {fmt.Printf("SendMail error: %s\n", err)return}fmt.Println("SendMail ok")// OutPut:// SendMail ok}
SendMailWithDisclaimer的实现从 v1 版到 v2 版的变化可以用下面图示来更好地理解:

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