您当前的位置:首页 > 电脑百科 > 程序开发 > 程序员

为什么你写的代码总是有 Bug?用它来保证 Go 代码质量

时间:2020-06-26 12:10:20  来源:  作者:

序幕

这篇文章是集成测试系列两个部分中的第二部分。你可以先读 Go 语言中的集成测试:第一部分 - 用 Docker 执行测试。本文中的示例可以从 代码仓库[1] 获取。

简介

“比起测试行为,设计测试行为是已知的最好的错误预防程序之一。” —— Boris Beizer

在执行集成测试之前,必须正确配置该测试相关的外部系统。否则,测试结果是无效和不可靠的。例如,数据库需要有定义好的数据,这些数据对于要测试的行为是正确的。测试期间更改的数据需要进行验证,尤其是如果要求更改的数据对于后续测试而言是准确的时侯。

Go 测试工具提供了有在执行测试函数前执行代码的能力,使用叫做 TestMain 的入口函数实现。它类似于 Go 应用程序的 Main 函数。有了 TestMain 函数,我们可以在执行测试之前做其他系统配置,比如数据库连接之类的。在本文中,我将分享如何使用它 TestMain 来配置和连接 Postgres 数据库,以及如何针对该数据库编写和运行测试。

填充初始数据

为了填充数据库,需要定义数据并将其放置在测试工具可以访问的位置。一种常见的方法是定义一个 SQL 文件,该文件是项目的一部分,并且包含所有需要执行的 SQL 命令。另一种方法是将 SQL 命令存储在代码内部的常量中。不同于这两种方法,我将只使用 Go 语言实现来解决此问题。

通常情况下,你已将你的数据结构定义为 Go 结构体类型,用于数据库通信。我将利用这些已存在的数据结构,已经可以控制数据从数据库中流入流出。基于已有的数据结构声明变量,构造所有填充数据,而无需 SQL 语句。

我喜欢这种解决方式,因为它简化了编写集成测试和验证数据是否能够正确用于数据库和应用程序之间的通信的。不必将数据直接与 JSON 比较,就可以将数据解编为适当的类型,然后直接与为之前数据结构定义的变量进行比较。这不仅可以最大程度地减少测试中的语法比较错误,还可以使您的测试更具可维护性、可扩展性和可读性。

填充数据库

译者注:原文为 Seeding The Database,下面部分相关功能函数就称为种子函数

本文提到的,所有用于填充数数据库功能函数,都在 `testdb`[2] 包中。这个包仅用于测试,不用做第三方依赖。用来辅助填充测试数据库的三个核心函数分别是:SeedLists, SeedItems, 和Truncate,如下:

这是 SeedLists 函数:

代码清单 1

func SeedLists(dbc *sqlx.DB) ([]list.List, error) {
    now := time.Now().Truncate(time.Microsecond)

    lists := []list.List{
        {
            Name:     "Grocery",
            Created:  now,
            Modified: now,
        },
        {
            Name:     "To-do",
            Created:  now,
            Modified: now,
        },
        {
            Name:     "Employees",
            Created:  now,
            Modified: now,
        },
    }

    for i := range lists {
        stmt, err := dbc.Prepare("INSERT INTO list (name, created, modified) VALUES ($1, $2, $3) RETURNING list_id;")
        if err != nil {
            return nil, errors.Wrap(err, "prepare list insertion")
        }

        row := stmt.QueryRow(lists[i].Name, lists[i].Created, lists[i].Modified)

        if err = row.Scan(&lists[i].ID); err != nil {
            if err := stmt.Close(); err != nil {
                return nil, errors.Wrap(err, "close psql statement")
            }

            return nil, errors.Wrap(err, "capture list id")
        }

        if err := stmt.Close(); err != nil {
            return nil, errors.Wrap(err, "close psql statement")
        }
    }

    return lists, nil
}

代码清单 1 展示了 SeedLists 函数及其如何创建测试数据。list.List 定义了一个用于插入的数据表。然后,将测试数据插入数据库。为了帮助将插入的数据与测试期间进行的任何数据库调用的结果进行比较,测试数据集返回给调用方。

接下来,我们看看将更多测试数据插入数据库的 SeedItems 函数。

代码清单 2

func SeedItems(dbc *sqlx.DB, lists []list.List) ([]item.Item, error) {
    now := time.Now().Truncate(time.Microsecond)

    items := []item.Item{
        {
            ListID:   lists[0].ID, // Grocery
            Name:     "Chocolate Milk",
            Quantity: 1,
            Created:  now,
            Modified: now,
        },
        {
            ListID:   lists[0].ID, // Grocery
            Name:     "mac and Cheese",
            Quantity: 2,
            Created:  now,
            Modified: now,
        },
        {
            ListID:   lists[1].ID, // To-do
            Name:     "Write Integration Tests",
            Quantity: 1,
            Created:  now,
            Modified: now,
        },
    }

    for i := range items {
        stmt, err := dbc.Prepare("INSERT INTO item (list_id, name, quantity, created, modified) VALUES ($1, $2, $3, $4, $5) RETURNING item_id;")
        if err != nil {
            return nil, errors.Wrap(err, "prepare item insertion")
        }

        row := stmt.QueryRow(items[i].ListID, items[i].Name, items[i].Quantity, items[i].Created, items[i].Modified)

        if err = row.Scan(&items[i].ID); err != nil {
            if err := stmt.Close(); err != nil {
                return nil, errors.Wrap(err, "close psql statement")
            }

            return nil, errors.Wrap(err, "capture list id")
        }

        if err := stmt.Close(); err != nil {
            return nil, errors.Wrap(err, "close psql statement")
        }
    }

    return items, nil
}

代码清单 2 显示了 SeedItems 函数如何创建测试数据。除了使用 item.Item 数据类型,该代码与清单 1 基本相同。testdb 包中还有一个未提到的函数 Truncate。

代码清单 3

func Truncate(dbc *sqlx.DB) error {
    stmt := "TRUNCATE TABLE list, item;"

    if _, err := dbc.Exec(stmt); err != nil {
        return errors.Wrap(err, "truncate test database tables")
    }

    return nil
}

代码清单 3 展示了 Truncate 函数。顾名思义,它用于删除 SeedLists 和 SeedItems 函数插入的所有数据。

使用 testing.M 创建 TestMain

使用便于 填充/清除 数据库的软件包后,该集中精力配置以运行真正的集成测试了。Go 自带的测试工具可以让你在 TestMain 函数中定义需要的行为,在测试函数执行前执行。

代码清单 4

func TestMain(m *testing.M) {
    os.Exit(testMain(m))
}

代码清单 4 是 TestMain 函数,它在所有集成测试之前执行。在 23 行,叫做 testMain 的未导出的函数被 os.Exit 调用。这样做是为了 testMain 可以执行其中的延迟函数,并且仍可以在 os.Exit 调用内部设置适当的整数值。以下是 testMain 函数的实现。

代码清单 5

func testMain(m *testing.M) int {
    dbc, err := testdb.Open()
    if err != nil {
        log.WithError(err).Info("create test database connection")
        return 1
    }
    defer dbc.Close()

    a = handlers.NewApplication(dbc)

    return m.Run()
}

在代码清单 5 中,你可以看到 testMain 只有 8 行代码。28 行,函数调用 testdb.Open() 开始建立数据库连接。此调用的配置参数在 testdb 包中设置为常量。重要的是要注意,如果测试用的数据库未运行,调用 Opne 连接数据库会失败。该测试数据库是由 docker-compose 创建提供的,详细说明在本系列的第 1 部分中(单击 这里[3] 阅读第 1 部分)。

成功连接测试数据库后,连接将传递给 handlers.NewApplication(),并且此函数的返回值用于初始化的包级变量 *handlers.Application 类型。handlers.Application 类型是这个项目自定义的结构体,有用于 http.Handler 接口的字段,以简化 Web 服务的路由以及对已创建的数据库连接的引用。

现在,应用程序值已初始化,可以调用 m.Run 来执行所有测试函数。对 m.Run 的调用处于阻塞状态,直到所有确定要运行的测试函数都执行完之后,该调用才会返回。非零退出代码表示失败,0 表示成功。

编写 Web 服务的集成测试

集成测试将多个代码单元以及所有集成服务(例如数据库)组合在一起,并测试各个单元的功能以及各个单元之间的关系。为 Web 服务编写集成测试通常意味着每个集成测试的所有入口点都是一个路由。http.Handler 接口是任何 Web 服务的必需组件,它包含的 ServeHTTP 函数使我们能够利用应用程序中定义的路由。

在 Web 服务的集成测试中,构建初始化数据并且以 Go 类型返回初始数据,对返回的响应体的结构进行断言非常有用。在接下来的代码清单中,我将一个典型的 API 路由集成测试分解成几个不同的部分。第一步是使用代码清单 1 和代码清单 2 中定义的种子数据。

清单 6

func Test_getItems(t *testing.T) {
    defer func() {
        if err := testdb.Truncate(a.DB); err != nil {
            t.Errorf("error truncating test database tables: %v", err)
        }
    }()

    expectedLists, err := testdb.SeedLists(a.DB)
    if err != nil {
        t.Fatalf("error seeding lists: %v", err)
    }

    expectedItems, err := testdb.SeedItems(a.DB, expectedLists)
    if err != nil {
        t.Fatalf("error seeding items: %v", err)
    }
}

在获取种子数据失败前,必须设置延迟函数清理数据库,这样,无论函数失败与否,测试结束后保证数据库是干净的。然后,调用 testdb 中的种子函数(testdb.SeedLists 和 testdb.SeedItems )构造初始数据,并获取他们的返回值作为预期值,以便在集成测试中与实际路由请求结果(真实值)做对比。如果这两个种子函数中的任何一个失败,测试就会调用 t.Fatalf 。

清单 7

// Application is the struct that contains the server handler as well as
// any references to services that the application needs.
type Application struct {
    DB      *sqlx.DB
    handler http.Handler
}

// ServeHTTP implements the http.Handler interface for the Application type.
func (a *Application) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    a.handler.ServeHTTP(w, r)
}

为了调用注册的路由,Application 类型实现 http.Handler 接口。http.Handler 作为 Application 的内嵌结构体字段,因此 Application 可以调用 http.Handler 接口实现的ServeHTTP 函数

清单 8

req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/list/%d/item", test.ListID), nil)
if err != nil {
   t.Errorf("error creating request: %v", err)
}

w := httptest.NewRecorder()
a.ServeHTTP(w, req)

回顾一下代码清单 5,构造 Application 是为了在测试中使用。ServeHTTP 函数需要两个参数:http.ResponseWriter 和 http.Request。http.NewRequest 构造 http.Request,httptest.NewRecorder 构造 http.ResponseRecorder——即 http.Response 。

http.NewRecorder 函数的返回 ResponseRecorder 值实现了 ResponseWriter 接口。调用路由请求后,ResponseRecorder 可以用来分析了。其中最关键的字段 Code 和 Body,前者是该请求的实际响应码,后者是一个指向响应内容的 bytes.Buffer 类型的指针。

译者注:这里的 http.ResponseWriter 和 http.Request 实现了 Golang 中常见的 Writer和 Reader 接口,即 输出 和 输入,在 http 请求中即 Response 和 Request。

清单 9

if want, got := http.StatusOK, w.Code; want != got {
    t.Errorf("expected status code: %v, got status code: %v", want, got)
}

清单 9 中,实际的响应码和预期的响应码做对比。如果不同,将调用 t.Errorf,它将输出失败原因。

清单 10

var items []item.Item
resp := web.Response{
    Results: items,
}

if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
    t.Errorf("error decoding response body: %v", err)
}

if d := cmp.Diff(expectedItems, items); d != "" {
    t.Errorf("unexpected difference in response body:n%v", d)
}

示例中使用自定义响应体 web.Response,使用 键为 results 的 JSON 字符串存储路由返回信息。代码清单 10 中声明了一个 []item.Item 类型的变量 items,用于和预期值对比。初始化 items 变量传递给 resp 的字段 results。接下来,items 会随着解析路由响应体数据到 resp 中,从而包含响应体的数据。

google 的 go-cmp[4] 包可替代 reflect.DeepEqual ,在对比 struct,map,slice 和 array 时更安全,更易用。调用 cmp.Diff 对比清单 6 中定义的种子数据和实际响应体中返回的数据,如果不等,测试将失败,并且将差异输出到标准输出(stdout)中。

测试技巧

就测试而言,最好的建议是尽早测试,并且经常测试,而不是将测试放到开发之后考虑,而且测试应该推动、驱动应用程序的开发。这就是“测试驱动开发(TDD)”。通常情况下,没有随时测试代码。在编写代码时,将测试的想法抛到脑后,自己(开发人员)默认编写的代码是可测试的。代码单元(通常是一个函数)不管再小都能进行测试。你的服务进行越多测试,未知的就越少,隐藏的副作用(bug)就越少。

有了下面这些技巧,你的测试将洞察力,更易读,更快。

表测试

表测试是一种编写测试的方式,可以防止针对同一代码单元的不同可测试结果重复测试断言。以下面的求和函数为例:

清单 11

// Add takes an indefinite amount of operands and adds them together, returning
// the sum of the operation.
func Add(operands ...int) int {
    var sum int

    for _, operand := range operands {
        sum += operand
    }

    return sum
}

在测试中,我想确保函数可以处理以下情况:

  • 没有参数(operands),应返回 0。
  • 一个参数,直接返回参数值。
  • 两个参数,返回这两个数之和。
  • 三个参数,则返回这三个数之和。

彼此独立地编写这些测试将导致重复许多相同的调用和断言。我认为,更好的方法是利用表测试。为了编写表测试,必须定义一片匿名声明的结构,其中包含我们每个测试用例的元数据。然后可以使用循环遍历不同测试用例的这些条目,并可以对用例进行测试和独立运行 t.Run。t.Run 需要两个参数,子测试函数和这个子测试函数的函数名,子测试函数必须符合这种类型:func(*testing.T)。

清单 12

// TestAdd tests the Add function.
func TestAdd(t *testing.T) {
    tt := []struct {
        Name     string
        Operands []int
        Sum      int
    }{
        {
            Name:     "NoOperands",
            Operands: []int{},
            Sum:      0,
        },
        {
            Name:     "OneOperand",
            Operands: []int{10},
            Sum:      10,
        },
        {
            Name:     "TwoOperands",
            Operands: []int{10, 5},
            Sum:      15,
        },
        {
            Name:     "ThreeOperands",
            Operands: []int{10, 5, 4},
            Sum:      19,
        },
    }

    for _, test := range tt {
        fn := func(t *testing.T) {
            if e, a := test.Sum, Add(test.Operands...); e != a {
                t.Errorf("expected sum %d, got sum %d", e, a)
            }
        }

        t.Run(test.Name, fn)
    }
}

测试清单 12 中,使用匿名声明的结构体定义了不同的情况。遍历这些情况,执行这些测试用例。比较实际返回值和预期值,如果不等,则调用 t.Errorf,返回测试失败的信息。清单中,遍历调用 t.Run 执行每个测试用例。

t.Helper() 和 t.Parallel()

标准库中的 testing 包提供了很多有用的程序(函数)辅助测试,而不用导入之外的第三方包。其中我最喜欢的两个函数是 t.Helper() 和 t.Parallel(),它们都定义为 testing.T 接收者,它是在 _test.go 文件中每个 Test 函数都必需的一个的参数。

清单 13

// GenerateTempFile generates a temp file and returns the reference to
// the underlying os.File and an error.
func GenerateTempFile() (*os.File, error) {
    f, err := ioutil.TempFile("", "")
    if err != nil {
        return nil, err
    }

    return f, nil
}

在代码清单 13 中,为特定的测试包定义了一个辅助函数。这个函数返回 os.File 指针和error。每次测试调用这个辅助函数必须判断 error 是一个 non-nil 。通常情况这也没什么,但是有一个更好的方式:使用 t.Helper() ,这种方式省略了 error 返回。

清单 14

// GenerateTempFile generates a temp file and returns the reference to
// the underlying os.File.
func GenerateTempFile(t *testing.T) *os.File {
    t.Helper()

    f, err := ioutil.TempFile("", "")
    if err != nil {
        t.Fatalf("unable to generate temp file: %v", err)
    }

    return f
}

清单 14 和清单 13 相同,只是使用 t.Helper()。这个函数定义使用了 *testing.T 作为参数,省略了 error 的返回。函数先调用 t.Helper(),这在编译测试二进制文件时发出信号:如果 t 在这个函数中调用任何接收器函数,则将其报告给调用函数(Test*)。与辅助函数不同,所有行号和文件信息会都会关联到这个函数。

一些测试可以进行安全的并行进行,并且 Go testing 包原生支持并行运行测试。在所有 Test* 函数开始调用 t.Parallel(), 可以编译出可以安全并行运行的测试二进制文件。就是这么简单,就是这么强大!

结论

如果不配置程序运行时所需的外部系统,则无法在集成测试的上下文中完全验证程序的行为。此外,需要持续监测那些外部系统(特别是当它们包含应用程序状态数据的情况下),以确保它们包含有效和有意义的数据。Go 使开发人员不仅可以在测试过程中进行配置,还可以无需标准库之外的包就能维护外部数据。因此,我们可以编写可读性,一致性,性能和可靠性同时都能保证的集成测试。Go 的真正魅力正在于其简约而功能齐全的工具集,它为开发人员提供了无需依赖外部库或任何非常规限制的功能。


via: https://www.ardanlabs.com/blog/2019/10/integration-testing-in-go-set-up-and-writing-tests.html

作者:George Shaw[5]译者:TomatoAres[6]校对:lxbwolf[7]

本文由 GCTT[8] 原创编译,Go 中文网[9] 荣誉推出

 



Tags:代码   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,如有任何标注错误或版权侵犯请与我们联系(Email:2595517585@qq.com),我们将及时更正、删除,谢谢。
▌相关推荐
前言几乎所有.NET序列化程序的实现基础都是反射。下列代码是Newtonsoft.Json的实现:protectedvirtualJsonPropertyCreateProperty(MemberInfomember,MemberSerializationmemb...【详细内容】
2021-12-28  Tags: 代码  点击:(2)  评论:(0)  加入收藏
在SEO优化中,最重要的评估之一就是确定网站上存在哪些HTTP状态代码。这些代码可能会变得很复杂,成为一个难题,必须先解决这些难题,然后才能完成其他任务。例如,如果你放置的页面...【详细内容】
2021-12-24  Tags: 代码  点击:(5)  评论:(0)  加入收藏
1、通过条件判断给变量赋值布尔值的正确姿势// badif (a === 'a') { b = true} else { b = false}// goodb = a === 'a'2、在if中判断数组长度不为零...【详细内容】
2021-12-24  Tags: 代码  点击:(6)  评论:(0)  加入收藏
前言本文提供将视频调整分辨率的Python代码,一如既往的实用主义。环境依赖ffmpeg环境安装,可以参考我的另一篇文章: windows ffmpeg安装部署_阿良的博客-CSDN博客ffmpy安装:pip...【详细内容】
2021-12-14  Tags: 代码  点击:(15)  评论:(0)  加入收藏
大家好, 我是林路,今天就给大家介绍Python代码都是用的什么编辑器写的?Jupyter Notebook ,没有Pycharm,没有Vscode,没有Sublime text。 只有一款工具:Jupyter Notebook 。工欲善其...【详细内容】
2021-12-09  Tags: 代码  点击:(27)  评论:(0)  加入收藏
在这篇文章中,我将与你分享一些关于JS的技巧,可以提高你的JS技能。1.避免if过长如果判断值满足多个条件,我们可能会这么写:if (value === 'a' || value === 'b'...【详细内容】
2021-11-17  Tags: 代码  点击:(22)  评论:(0)  加入收藏
一、为什么需要使用内存池在C/C++中我们通常使用malloc,free或new,delete来动态分配内存。一方面,因为这些函数涉及到了系统调用,所以频繁的调用必然会导致程序性能的损耗;另一...【详细内容】
2021-11-17  Tags: 代码  点击:(38)  评论:(0)  加入收藏
《开源精选》是我们分享Github、Gitee等开源社区中优质项目的栏目,包括技术、学习、实用与各种有趣的内容。本期推荐的是一个由百度开源的低代码前端框架——amis...【详细内容】
2021-11-05  Tags: 代码  点击:(68)  评论:(0)  加入收藏
程序员是青春饭,这在国内似乎是公认的。所以很多公司不愿招大龄程序员,很多程序员也“知趣”地及早转型。有的做管理,有的做架构,我还见过改行卖保险的。总之,年龄大了不想敲代码...【详细内容】
2021-10-27  Tags: 代码  点击:(30)  评论:(0)  加入收藏
我们来看看我们拨号键盘除了能打电话还能干什么iphone 的拨号键盘除了用来拨号,其实暗藏代码输入星井06井可以查询手机真实的IMEI码,这个码是独一无二的没有双胞胎 输入星3001...【详细内容】
2021-10-25  Tags: 代码  点击:(78)  评论:(0)  加入收藏
▌简易百科推荐
我 2010 年开始在 Github 上开源自己的代码。在 push 代码之前我根本没想过为什么。只是因为我当时学了 git,而且我又觉得 Github 很方便,可以用来备份自己的代码。而后我就参...【详细内容】
2021-12-28  程序员的喵    Tags:Github   点击:(2)  评论:(0)  加入收藏
JAVA开发工程师(北京)本科 3-5年经验 面议 (招1人)岗位职责:1.负责我行应用系统的设计,完成软件编码工作,负责管理代码设计规范等工作;2.根据应用需求分析说明书,评估需求研发的可行...【详细内容】
2021-12-27  just do丶IT公众号    Tags:国企   点击:(2)  评论:(0)  加入收藏
今天聊聊编程的本质。程序就是数据结构+控制+逻辑,程序员编程工作的本质是翻译,翻译机要来了,程序员怎么办?黑客帝国中的程序黑客帝国4就要上映了,不知道前三部你看懂了么?值得多...【详细内容】
2021-12-17  博士聊IT    Tags:程序员   点击:(9)  评论:(0)  加入收藏
梦醒之后,每个人对于这份职业的未来、互联网行业的未来,以及更重要的,自己的未来都有了更现实的判断 文 | 祝颖丽编辑 | 黄俊杰一个生于 1986 年的人,他所走过的前半生:从出生起,...【详细内容】
2021-12-03    财经杂志  Tags:程序员   点击:(16)  评论:(0)  加入收藏
前些天在头条看到一个八二年的哥们,述说自己找工作屡次被拒的问题,在网上引起了广泛的讨论,这件事给我留下了很深的印象,因为这哥们和我同是程序员,都人到中年,上有老下有小。唯一...【详细内容】
2021-12-01  云南贤哥在深圳    Tags:程序员   点击:(20)  评论:(0)  加入收藏
很多读者都问过一个问题:程序员如何实现高速成长?之前也写过相关的文章,强调的主要是夯实计算机体系基础知识。 再说另一个诀窍:多看经典开源项目,这些项目大多是众多顶尖程序员...【详细内容】
2021-11-30  findyi    Tags:程序员   点击:(15)  评论:(0)  加入收藏
近日,一位45岁的网民在中国政府网留言求职,引发关注。该网民自称是一名软件开发人员,今年45岁,精通各种技术体系,“而我辞职回家半年后再回来寻找工作机会的时候,却发现连个面试...【详细内容】
2021-11-17  郭主任    Tags:程序员   点击:(42)  评论:(0)  加入收藏
即使在安全技术取得进步之后,网络犯罪仍在不断增加。据统计,网络犯罪每分钟给企业造成约 290 万美元的损失。主要是因为新技术不断涌现,难以维护安全。随着网络威胁的增加,网络...【详细内容】
2021-11-04  章大千    Tags:编程语言   点击:(40)  评论:(0)  加入收藏
北漂小伙李强(化名),在北京互联网大厂工作7年,月薪3万,离职回老家开摄影店,亏了200万。李强出生于山西一座名不经传的小城市,互联网专业大学毕业的他,没有听父母的劝言回到家乡考公...【详细内容】
2021-10-29  霸王课  今日头条  Tags:程序员   点击:(53)  评论:(0)  加入收藏
程序员是青春饭,这在国内似乎是公认的。所以很多公司不愿招大龄程序员,很多程序员也“知趣”地及早转型。有的做管理,有的做架构,我还见过改行卖保险的。总之,年龄大了不想敲代码...【详细内容】
2021-10-27  编程的艺术    Tags:   点击:(30)  评论:(0)  加入收藏
最新更新
栏目热门
栏目头条