20 要提高代码可测试性,请使用接口
Go 语言诞生之时就自带单元测试框架(包括:go test
命令及testing
包)是它为人所津津乐道的重要原因之一。这使得那些推崇测试驱动开发(TDD)编码思想的 Gopher 们在 Go 编码过程中得以惬意发挥。即便你不是测试驱动开发的粉丝,如此低的单元测试代码编写门槛也或多或少地会让你更愿意去为自己的代码编写单元测试,从而让 Go 代码质量更好地得以保证。
Go 语言的一个惯例就是让单元测试代码时刻伴随着你编写的 Go 代码。阅读过 Go 自身实现以及标准库代码的 gopher 都清楚,每个标准库的 Go 包都包含对应的测试代码。下面是对 Go 1.13 版本 Go 根目录的 src 下($GOROOT/src)Go 代码与对应的测试代码的代码量的粗略统计:
// $GOROOT/src下面的Go代码(不包括单元测试代码):
$find . -name "*.go" |grep -v test|xargs wc -l
71 ./cmd/vet/doc.go
59 ./cmd/vet/main.go
104 ./cmd/objdump/main.go
876 ./cmd/asm/internal/asm/asm.go
1349 ./cmd/asm/internal/asm/parse.go
... ...
2995 ./debug/elf/elf.go
1431 ./debug/elf/file.go
108 ./debug/elf/reader.go
1492338 total
// $GOROOT/src下面的Go单元测试代码:
$find . -name "*_test.go"|xargs wc -l
3 ./cmd/vet/testdata/testingpkg/tests_test.go
412 ./cmd/vet/vet_test.go
253 ./cmd/objdump/objdump_test.go
... ...
838 ./debug/elf/symbols_test.go
823 ./debug/elf/file_test.go
49 ./debug/elf/elf_test.go
345503 total
我们看到测试代码行数约占 Go 代码行数(不包含测试代码)的四分之一,也就是说每写一万行 Go 代码,就要编写 2500 行的测试代码来保证那一万行代码的质量。
"写测试代码浪费时间"早已被证明是谬论,从一个软件系统或服务的全生命周期来看,编写测试代码正是为了”磨刀不费砍柴功“。不主动磨刀(编写测试代码),后续对代码进行修改和重构时要付出更多的代价,相应工作效率也会大打折扣。因此,写出好测试代码与写出好代码同等重要。
为一段代码编写对应测试代码的前提是这段代码具有可测试性。如果代码不可测或可测试性较低,那么无论是为其编写测试代码,还是运行编写后的测试,都需要开发人员较多的额外付出,这将打击开发人员编写测试代码的积极性,从而降低测试代码比例或完全不编写测试代码,这种情况是我们所不愿意见到的。
单元测试是自包含和自运行的,运行时一般是不会依赖外部资源(比如外部数据库、外部邮件服务器),并具备跨环境的可重复性(比如:既可以在开发人员本地运行,也可以在持续集成环境中运行)。因此,一旦被测代码中耦合了对外部资源的依赖,被测代码的可测试性就不会高,也会让开发人员有了”这段代码无法测试“的理由。为了提高代码的可测试性,我们就要降低代码耦合、管理被测代码中对外部的依赖。而这也是接口可以发挥其魔力的地方。本节我们就来看看如何使用接口来提高代码的可测试性。
一些正规的大公司或组织会在员工/成员发往公司或组织外部的邮件的尾部添加上免责声明,以避免一些因电子邮件发到非预期目的地址上而导致的一些法律问题。这里我们就来实现这样一个为电子邮件附加免责声明的电子邮件发送函数。
我们将附加免责声明的电子邮件发送函数命名为:SendMailWithDisclaimer
,其第一版实现如下:
// send_mail_with_disclaimer/v1/mail.go
package mail
import (
"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, mailserver string,
a smtp.Auth) error {
e := email.NewEmail()
e.From = from
e.To = to
e.Subject = subject
e.Text = []byte(attachDisclaimer(content))
return e.Send(mailserver, a)
}
由于github.com/jordan-wright/email
中 Email 实例的 Send 方法会真实地去连接外部的 email 服务器,因此该测试每执行一次就会向目标电子邮箱发送一封电邮。如果用例中的参数有误或执行用例的环境无法联网又或无法访问 mail 服务器,那么这个测试将会以失败告终,因此这种测试代码并不具备跨环境的可重复性。而究根结底,其深层原因则是我们的第一版SendMailWithDisclaimer
实现对github.com/jordan-wright/email
包有着紧密的依赖,耦合较高。
接口本是契约,具有天然的降低耦合作用。下面我们就用接口对第一版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, 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.go
package mail_test
import (
"net/smtp"
"testing"
mail "github.com/bigwhite/mail"
)
type FakeEmailSender struct {
subject string
from string
to []string
content string
}
func (s *FakeEmailSender) Send(subject, from string,
to []string, content string, mailserver string, a smtp.Auth) error {
s.subject = subject
s.from = from
s.to = to
s.content = content
return 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.DISCLAIMER
if 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.go
package mail_test
import (
"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 = subject
adapter.e.From = from
adapter.e.To = to
adapter.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
}
我们使用一个适配器对github.com/jordan-wright/email
包中 Email 实例进行了包装,使其成为接口MailSender
的实现者,从而顺利传递给SendMailWithDisclaimer
承担 mail 发送的责任。
SendMailWithDisclaimer
的实现从 v1 版到 v2 版的变化可以用下面图示来更好地理解:
图5-3-1:v1和v2版本对比
从图中,我们看到接口 MailSender 将 SendMailWithDisclaimer 与具体的 Email 发送实现之间解耦。通过上述例子我们也可以看出接口在测试过程中成为了 fake 对象或 mock 对象的注入点。通过这种方式,我们可以通过灵活定制接口实现者以控制实现行为,继而实现对被测代码的代码逻辑的测试覆盖。
代码的可测试性(testability)已经成为了判定 Go 代码是否优秀的一条重要标准。适当抽取接口,让接口成为好代码与单元测试之间的桥梁是 Go 语言的一种最佳实践。