专栏目录
11 理解包导入路径的含义 44 利其器!Go常用工具大检阅 22 Go 并发模型和常见并发模式 35 告别乱码!GO语言字符集编码方案间转换 12 go语言 init 函数的妙用 41 与时俱进!使用module管理依赖包 1 参考 Go 项目布局设计你的项目结构 16 方法集合决定接口实现 17 go变长参数函数的妙用 7 go语言定义“零值可用”的类型 30 Go 惯例:将测试依赖的外部数据文件放在 testdata 下面 34 一文告诉你如何在 Go 中实现 HTTPS 通信 24 sync 包的正确使用姿势 6 Go“枚举常量”的惯用实现方法 4 变量声明形式尽量保持一致 2 gofmt:Go代码风格的唯一标准 3 Go 标识符的命名惯例 9 深入理解和高效运用切片 10 Go 字符串是原生类型 8 用复合字面值作初值构造器 13 Go 函数是“一等公民”的理解 14 defer 让你的代码更清晰 23 Go channel 的常见使用模式 18 定义小接口是 Go 的惯例 19 不要在函数参数中使用空接口(interface{}) 15 Go 方法的本质 21 面试必考!掌握 goroutine 的调度原理 29 Go 单元测试惯例:表驱动 26 if err != nil 重复太多可以这么办 27 不要让 panic 掺和到正常错误处理中 32 掌握 Go 代码性能剖析神器:pprof 31 为被测对象建立性能基准 25 别笑!这就是 Go 的错误处理哲学 39 慎用reflect包提供的反射能力 38 小心被kill!不要忽略对系统信号的处理 36 像极!bytes包和strings包的那些相似操作 33 掌握 Go 代码调试利器:delve 40 与C互操作不是免费的!疑问了解cgo的使用成本 43 让你的Go包拥有个性化的导入路径 37 time包,你用对了吗 42 小即是美?构建最小Go程序容器镜像 20 要提高代码可测试性,请使用接口 45 未雨绸缪!Go语言常见“坑”大汇 28 一文告诉你测试包的包名要不要带“\_test”后缀 5 无类型常量让代码更简化

20 要提高代码可测试性,请使用接口

九路
• 阅读 608

要提高代码可测试性,请使用接口

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 行的测试代码来保证那一万行代码的质量。

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

为一段代码编写对应测试代码的前提是这段代码具有可测试性。如果代码不可测或可测试性较低,那么无论是为其编写测试代码,还是运行编写后的测试,都需要开发人员较多的额外付出,这将打击开发人员编写测试代码的积极性,从而降低测试代码比例或完全不编写测试代码,这种情况是我们所不愿意见到的。

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

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

一些正规的大公司或组织会在员工/成员发往公司或组织外部的邮件的尾部添加上免责声明,以避免一些因电子邮件发到非预期目的地址上而导致的一些法律问题。这里我们就来实现这样一个为电子邮件附加免责声明的电子邮件发送函数。

我们将附加免责声明的电子邮件发送函数命名为: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 string, 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,SendMailWithDisclaimer 函数利用 email 包的实例将 email 相关信息组装并发送出去。

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

//send_mail_with_disclaimer/v1/mail_test.go
package mail_test

import (
    "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.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 版的变化可以用下面图示来更好地理解:

20 要提高代码可测试性,请使用接口

图5-3-1:v1和v2版本对比

从图中,我们看到接口 MailSender 将 SendMailWithDisclaimer 与具体的 Email 发送实现之间的耦合解开。通过上述例子我们也可以看出接口在测试过程中成为了 fake 对象或 mock 对象的注入点。通过这种方式,我们可以通过灵活定制接口实现者以控制实现行为,继而实现对被测代码的代码逻辑的测试覆盖。

3. 小结

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

点赞
收藏
评论区
推荐文章

暂无数据