记一次测试驱动开发练习

2023-06-02 ⏳10.1分钟(4.1千字)

为什么要尝试测试驱动

从毕业工作到现在,自己也是个十年的码农了。十年,可以让新手变成专家。但如果把一年的经验用了十年,新手还是新手。很不幸,我是后者。以前的工作经历都是单打独斗,来活了就是短平快,一把梭。这样会有什么积累呢?

最近进入了新的项目组,团队合作的项目明显多了。自己提交的代码遭到了客观的差评,脸上有点火辣辣的。自己确实和优秀还有很大的进步空间。大概是3月份加班忙的头昏脑胀的时候,一起加班的 leader 给了我一些学习的建议,简单的说书看的太少,思考太少。

在项目的业务代码开发完成后, leader 说让大家写一些单元测试。期间对自己写的单元测试不是很满意,空时找了一本书《有效的单元测试》看了一遍。对于为什么要写单元测试我是很认可的。

对于一些写单元测试的困惑

这本书目前只读了2/3,书中的一些理念基本上已经解决了我的困惑。作者讲到他在一家公司中看到一个同事的编码风格后很受启发:

Sebastian 的编码风格是先写一个会失败(很明显是这样)的测试,再写足够使测试通过的代码,然后再编写另一个失败的测试,他重复这个循环直到完成任务。与 Sebastian 共事,Marcus 意识到自己的编程风格开始转变。他的对象结构不同了,代码看起来稍微不同了,只是因为他从调用者的角度来审视自己代码的设计和行为了。

这本书并不是讲 tdd 的,只是介绍了 tdd 的理念。之前看过一些重构,编码风格的书,虽然产生了一些共鸣,但是很快就忘记了。想来是自己没有通过练习转化成自己内在的东西,纸上得来终觉浅,觉知此事要躬行, 正好工作上接入了一个对接广告系统的活,业务不是很复杂,时间比较宽裕,于是就有了这次练习。希望可以通过练习把它变成自己的习惯。

需求背景

业务上需要对接 RTA 广告投放的功能,广告平台在竞价时会调用广告主的 RTA 服务,请求参数是:本次可参与竞价的账户列表。广告主根据账户规则配置,返回参与竞价的账户,价格,以及个性化的素材。主要解决了广告投放中的人群定向、素材个性化问题(指定哪些用户参与竞价,以及展示的广告内容是什么)。

本次的账户规则主要有:

  1. 用户活跃等级:
    • 根据用户最近未活跃的天数判断账户是否参与竞价,以及出价
  2. 账户类型:
    • 根据账户类型判断账户是否参与竞价,以及出价
    • 品类账户,如果用户的偏好分类与账户品类匹配,返回出价
    • dpa账户,如果用户有可用的 sku_list,参与竞价,返回 sku_list
  3. 安装app规则:
    • 根据规则判断用户是否安装/卸载了某个app,满足条件后出价
  4. 支付能力:
    • 根据规则判断用户的支付能力等级,满足条件后出价

拆分子问题

根据需求,下面拆分一下需要解决的子问题。每个账户需要一个规则配置?规则之间的关联关系是什么?用户数据从哪里来?

  1. 账户规则配置
  2. 账户的出价行为
    • 一个账户下可以配置不同种类的出价规则 逻辑关系 and 命中的多个类型规则,出价相乘,skulist 叠加.
    • 同类型规则下面可以配置多个规则 ,逻辑关系 or 命中多个规则出价取最大,skulist 叠加。
  3. 数据访问
    • 用户付费等级
    • 用户 App 安装列表
    • 用户偏好:偏好品类,偏好 sku
    • 用户最近活跃时间

本次只把账户的出价行为部分,进行举例

开发过程

明确了需要解决的子问题后,就可以以尝试进行开发了。按照测试驱动的原则,我需要构建一个单元测试,面临的第一个问题是:写单元测试的时候可能没有任何的结构体,方法。这个时候我需要先准备一些程序骨架,然后编写单元测试,最后编写代码使测试通过。定义程序骨架的时候,我倾向于坚持只定义必要的结构体、函数、接口。而不写任何多余的代码。

准备程序骨架

第一个单元测试

有了基本的程序骨架,就可以构建测试用例,我选择了从 RTA方法作为入口写第一个单元测试。首先需要明确的测试的目的,关注的是账户规则的出价行为,而出价行为包括了多规则组,规则组下多规则的情况。这时遇到了一些问题: 测试 RTA方法的单元测是否要构建一个具体的账户出价规则比如[用户活跃规则A出价&&用户活跃规则B不出价]?这显然是不需要的。如何 mock 单个规则的出价返回值 ? 由于每个具体的规则 xxRule 都会实现 bider 接口,这样就可以不关心具体的 xxRule 的实现细节,通过构建一个 mockRule 来进行出价行为的 mock ,通过打桩操作 mock getAccountRuleGroup 方法返回一个 [][]{mockBidRule{}},即可表示多个规则组的不同规则的出价行为。


// 构建用于 mock 单规则出价行为
type mockBidRule struct {
    ret BidData
}

// 实现 bider 接口
func (m mockBidRule) Bid(u User) (BidData, error) {
    return m.ret, nil
}

func TestRTAWithOneNoBidRule(t *testing.T) {
    // 单规则,不出价
    inputRuleBid := BidData{IsBid: false}

    // mock 某个账号的出价规则
    m1 := mock.Patch(getAccountRuleGroup, func(id int) (rule [][]bider) {
        // 通过 mockBidRule 模拟了某个规则的出价行为
        rule = [][]bider{
            []bider{mockBidRule{ret: inputRuleBid}},
        }
        return
    })
    defer m1.Unpatch()

    mid := 1
    accountID := 11
    res, _ := RTA(mid, []int{accountID})
    // 该账号不出价
    assert.False(t, res[accountID].IsBid)
    return
}

单元测试写好了以后,可以 go test 看一下单元测试的结果,由于 go 语言默认的问题,这个单元测试并没有报错。原因是 RTA 返回的 res 是一个空 mapres[accountID] 得到的是一个空的结构体,空结构体的 bool 类型的属性默认值是 false 所以 assert.False((t, res[accountID].IsBid)的断言不会报错。这里是我第一步的失误之处,还是要先写一个明显失败的单元测试,然后填充代码使得单元测试通过,这样能够验证程序确实有在执行工作。

由于第一个单元测试就是通过的,继续填充代码的操作也是无效的。所以再来写一个真第一个单元测试

[真] 第一个单元测试

func TestRTAWithOneBidRule(t *testing.T) {
    // 单规则、出价、返回 sku
    inputRuleBid := BidData{IsBid: true, Ratio: 1, SKUList: []int{1}}
    // 账号出价、返回 sku
    assertAccountBid := BidData{IsBid: true, Ratio: 1, SKUList: []int{1}}

    // mock 某个账号的出价规则
    m1 := mock.Patch(getAccountRuleGroup, func(id int) (rule [][]bider) {
        // 通过 mockBidRule 模拟了某个规则的出价行为
        rule = [][]bider{
            []bider{mockBidRule{ret: inputRuleBid}},
        }
        return
    })
    defer m1.Unpatch()

    mid := 1
    accountID := 11
    res, _ := RTA(mid, []int{accountID})
    assert.Equal(t, assertAccountBid, res[accountID])
    return
}

这次 go test 后,提示测试用例失败了,可以进行代码填充了。

通过第一个单元测试

func RTA(mid int, accounts []int) (res map[int]BidData, err error) {
    res = map[int]BidData{}
    // 单元测试中没有依赖 user 的数据,可以先给个空
    u := User{}
    for _, account := range accounts {
        // 获取某个账号的规则
        group := getAccountRuleGroup(account)
        for _, g := range group {
            for _, r := range g {
                ruleBid, _ := r.Bid(u)
                if ruleBid.IsBid {
                    res[account] = ruleBid
                    return
                }
            }
        }
    }
    return
}

这里为了示例代码简单,我省略了一些 go 语言中的 if err != nil 的处理。开发过程中,只填充了部分使单元测试通过的代码。这段代码中有明显的逻辑问题,比如单个规则组中多规则、以及多个规则组不同出价情况的处理。但本次填充的代码足够使单元测试通过了,那就可以进行提交了,不写过多代码的原因,可能是因为过多的代码需要考虑代码结构设计的问题,而过早的考虑这些问题同样会有过度设计,或者错误设计的问题,只有在需要设计结构的时候进行合适的设计

现在回顾下之前的单元测试,就能看到 bider 接口抽象的作用,以及 mock getAccountRuleGroup 方法模拟不同规则的出价行为的作用了。

第二个单元测试

func TestRTAWithMultiBidRules(t *testing.T) {
    // 单规则组,多个出价规则
    inputRuleBids := []BidData{
        {IsBid: true, Ratio: 1, SKUList: []int{1}},
        {IsBid: true, Ratio: 2, SKUList: []int{2}},
    }
    assertAccountBid := BidData{
        IsBid: true,
        // 出价取最大
        Ratio: 2,
        // sku 合并
        SKUList: []int{1, 2},
    }
    // mock 某个账号的出价规则
    m1 := mock.Patch(getAccountRuleGroup, func(id int) (rule [][]bider) {
        g1 := []bider{}
        for _, bid := range inputRuleBids {
            g1 = append(g1, mockBidRule{ret: bid})
        }
        // 通过 mockBidRule 模拟了某个规则的出价行为
        rule = [][]bider{g1}
        return
    })
    defer m1.Unpatch()
    mid := 1
    accountID := 11
    res, _ := RTA(mid, []int{accountID})
    assert.Equal(t, assertAccountBid, res[accountID])
    return
}

通过第二个单元测试

在第二次填充代码时,需要实现规则组内多个出价规则合并出价结果的逻辑,代码如下:

// 业务处理入口函数
func RTA(mid int, accounts []int) (res map[int]BidData, err error) {
    res = map[int]BidData{}
    // 单元测试中没有依赖 user 的数据,可以先给个空
    u := User{}
    for _, account := range accounts {
        // 获取某个账号的规则
        group := getAccountRuleGroup(account)
        for _, g := range group {
            groupBid := BidData{}
            for _, r := range g {
                ruleBid, _ := r.Bid(u)
                // 规则组内多个规则出价,出价结果合并
                if ruleBid.IsBid {
                    groupBid.IsBid = true
                    groupBid.Ratio = math.Max(groupBid.Ratio, ruleBid.Ratio)
                    groupBid.SKUList = append(groupBid.SKUList, ruleBid.SKUList...)
                }
            }
            if groupBid.IsBid {
                res[account] = groupBid
            }
        }
    }
    return
}

第三个单元测试


func TestRTAWithMultiBidGroups(t *testing.T) {
    // 单规则组,多个出价规则
    inputGroupABid := BidData{IsBid: true, Ratio: 3, SKUList: []int{1}}
    inputGroupBBid := BidData{IsBid: true, Ratio: 2, SKUList: []int{2}}

    assertAccountBid := BidData{
        IsBid: true,
        // 出价相乘
        Ratio: 6,
        // sku 合并
        SKUList: []int{1, 2},
    }

    // mock 某个账号的出价规则
    m1 := mock.Patch(getAccountRuleGroup, func(id int) (rule [][]bider) {
        groupBids := []BidData{inputGroupABid, inputGroupBBid}

        // 通过 mockBidRule 模拟了某个规则的出价行为
        for _, groupBid := range groupBids {
            rule = append(rule, []bider{mockBidRule{ret: groupBid}})
        }
        return
    })
    defer m1.Unpatch()

    mid := 1
    accountID := 11
    res, _ := RTA(mid, []int{accountID})
    assert.Equal(t, assertAccountBid, res[accountID])
    return
}

通过第三个单元测试

本次填充代码,需要关注的逻辑是规则组间多个规则组出价,合并出价结果的逻辑


// 业务处理入口函数
func RTA(mid int, accounts []int) (res map[int]BidData, err error) {
    res = map[int]BidData{}
    // 单元测试中没有依赖 user 的数据,可以先给个空
    u := User{}
    for _, account := range accounts {
        // 获取某个账号的规则
        group := getAccountRuleGroup(account)
        accountBid := BidData{}
        for _, g := range group {
            groupBid := BidData{}
            for _, r := range g {
                ruleBid, _ := r.Bid(u)
                if ruleBid.IsBid {
                    groupBid.IsBid = true
                    groupBid.Ratio = math.Max(groupBid.Ratio, ruleBid.Ratio)
                    groupBid.SKUList = append(groupBid.SKUList, ruleBid.SKUList...)
                }
            }
            if groupBid.IsBid {
                accountBid.IsBid = true
                // 出价系数相乘
                accountBid.Ratio = math.Max(1, accountBid.Ratio) * math.Max(1, groupBid.Ratio)
                accountBid.SKUList = append(accountBid.SKUList, groupBid.SKUList...)
            }
        }
        if accountBid.IsBid {
            res[account] = accountBid
        }
    }
    return
} 

到这里 RTA 方法已经有了基本的血肉,其实还有很多 case 没有列举出来,比如规则组之间有一个不出价的规则组,整个账号不出价、以及后续的每种具体规则的实现,这些具体的过程不再赘述,至此我对测试驱动开发的模式已经开始有了一些感觉。他能帮助我们在开发过程中聚焦,我的开发过程始终在聚焦在测试用例描述的业务事件下,编写的代码是站在调用者的角度上思考,每次提交记录也变得有条理,他们都遵循着,构建测试、通过测试的模式。

重构单元测试

目前为止,可以发现对于 RTA 方法的单元测试,不同的 case 散落在各种 TestXXX 方法中,这也是 review 过程中同事帮我提出的建议,单元测试应该更专注事件是什么,行为是什么,期望是什么 而对于数据的准备、校验逻辑要尽量统一。下面开始重构。

首先定义一个 tc struct 来描述测试用例,mock 每个规则组中,不同规则的返回值是什么, 期望的账户出价是什么。对于这个测试用例,他只描述了业务行为。这就是一个非常明确的测试用例。

这里我使用了 groupARuleBids/groupBRuleBids 表示两个规则组中每个规则不同的出价行为。不用 [][]BidData 表示的原因是希望单元测试更简单,如果测试单个规则组的 case ,另一个 groupXRuleBids 为空即可。 assertAccountBid 表示期望账户的出价。单元测试所专注的行为、事件全部由 tc 来描述,即不同规则组中规则的出价行为,对应账号的出价行为

第二段代码,就是在组织具体的测试用例。这里我发了一个最终版。在这里需要关注的是,尽量避免无关数据的组织,尽量精简这部分的代码。降低阅读代码的成本。这也是我在本次实践中的体会。如果组织测试用例的部分,无关代码太多,构建过程太繁琐。那么可能对于 tc 的抽象还不到位, 还需要继续重构。

第三段代码就是运行构建好的测试用例,根据 tc 的数据构建 mock 需要的参数,执行目标方法,校验期望返回。


func TestRTA(t *testing.T) {
    // ***第一段代码
    // 测试不同规则组内的规则出价行为所影响的账号出价行为
    type tc struct {
        // 规则组 A 中的规则出价结果
        groupARuleBids []BidData
        // 规则组 B 中的规则出价结果
        groupBRuleBids   []BidData
        assertAccountBid BidData
        // 单元测试报错时,提示的消息
        msg string
    }

    // ***第二段代码
    cases := []tc{}
    {
        // 单规则组内有多个规则,只有一个规则返回了出价
        rg1 := []BidData{
            // 一个出价
            {IsBid: true, Ratio: 3, SKUList: []int{1}},
            // 一个不出价
            {IsBid: false},
        }
        // 账户出价,返回 sku list
        bd := BidData{IsBid: true, Ratio: 3, SKUList: []int{1}}
        cases = append(cases, tc{groupARuleBids: rg1, assertAccountBid: bd, msg: "t1"})
    }

    {
        // 单个规则组多个出价规则命中
        rg1 := []BidData{
            {IsBid: true, Ratio: 2, SKUList: []int{1}},
            {IsBid: true, Ratio: 3, SKUList: []int{2}},
        }
        // 出价比例取最大
        // sku 合并
        bd := BidData{IsBid: true, Ratio: 3, SKUList: []int{1, 2}}
        cases = append(cases, tc{groupARuleBids: rg1, assertAccountBid: bd, msg: "t2"})
    }

    {
        // 测试多个规则组,有一组规则没有命中
        rg1 := []BidData{{IsBid: true, Ratio: 2}}
        rg2 := []BidData{{IsBid: false, Ratio: 2}}

        // 整个账户不出价
        bd := BidData{IsBid: false}
        cases = append(cases, tc{groupARuleBids: rg1, groupBRuleBids: rg2, assertAccountBid: bd, msg: "t3"})
    }
    {
        // 测试多个规则组,多组规则命中
        rg1 := []BidData{{IsBid: true, Ratio: 2, SKUList: []int{1}}}
        rg2 := []BidData{{IsBid: true, Ratio: 3, SKUList: []int{2}}}

        // 出价系数相乘 2*3
        // sku list 叠加
        bd := BidData{IsBid: true, Ratio: 6, SKUList: []int{1, 2}}
        cases = append(cases, tc{groupARuleBids: rg1, groupBRuleBids: rg2, assertAccountBid: bd, msg: "t4"})
    }

    // ***第三段代码
    for _, c := range cases {
        m1 := mock.Patch(getAccountRuleGroup, func(id int) (rule [][]bider) {
            groupBids := [][]BidData{c.groupARuleBids, c.groupBRuleBids}

            // 通过 mockBidRule 模拟了多个规则组下不同规则的出价行为
            for _, group := range groupBids {
                ruleBids := []bider{}
                for _, ruleBid := range group {
                    ruleBids = append(ruleBids, mockBidRule{ret: ruleBid})
                }
            }
            return
        })

        mid := 1
        accountID := 11
        res, _ := RTA(mid, []int{accountID})

        m1.Unpatch()
        assert.Equal(t, c.assertAccountBid, res[accountID], c.msg)
    }
}

经过重构后,我对单元测试的理解已经有了一些感觉,也非常明确了单元测试所需要聚焦事件而非具体的代码的含义,让我之前对单元测试覆盖的模糊之处又清晰了一些,这里为什么要一直强调测试用例要聚焦业务事件的原因是,单元测试不是把代码通过单元测试钉死在木板上,关注函数的每个分支,每个子函数。这会让单元测试后期的维护产生巨大的工作量,也许只是重构改动了一下函数结构。就会引起单元测试的报错。下面就来看一下,测试驱动对重构的友好体现。

重构 RTA 方法

回顾下之前已经实现的 RTA 方法,可以一眼看到多个 for 循环的嵌套,过多的层次嵌套会导致不在一个抽象层次上的代码混合在了一起,阅读代码的人要在多个层次间切换。也很难得清楚程序的控制流是什么。要一行行的去读,下面就来重构下 RTA 方法吧。

// 业务处理入口函数
func RTA2(mid int, accounts []int) (res map[int]BidData, err error) {
    res = map[int]BidData{}

    // 单元测试中没有依赖 user 的数据,可以先给个空
    u := User{}
    for _, account := range accounts {
        // 获取某个账号的规则
        accountRule := getAccountRuleGroup(account)
        bid := accountBid(accountRule, u)
        if bid.IsBid {
            res[account] = bid
        }
    }
    return
}
func accountBid(rules [][]bider, u User) (res BidData) {
    // 收集多个规则组的出价
    groupBids := []BidData{}
    for _, g := range rules {
        // 收集规则组下多个规则的出价
        ruleBids := []BidData{}
        for _, r := range g {
            ruleBid, _ := r.Bid(u)
            ruleBids = append(ruleBids, ruleBid)
        }

        // 合并多个规则的出价
        groupBid := mergeRulesBids(ruleBids)
        groupBids = append(groupBids, groupBid)
    }
    // 合并多个规则组的出价
    res = mergeGroupBids(groupBids)
    return
}

func mergeRulesBids(bids []BidData) (res BidData) {
    return
}

func mergeGroupBids(bids []BidData) (res BidData) {
    return
}

再来回顾下,为什么测试驱动对重构是友好的,因为在一开始编写单元测试的阶段,我们所聚焦的就不是代码本身的实现,而是业务逻辑,业务行为。如果先开发好 RTA 方法以后再去编写单元测试测,那么以我目前的理解,可能会写出以下聚焦代码本身,而非业务逻辑的单元测试以目前最终的 RTA2 方法为例,

简单的说就是用一种机器生成单元测试的方式,为每一个函数生成单元测试 ,每个单元测试只关注本函数的流程控制,通过 mock 的手段 mock 依赖函数的返回值,判断测试函数的期望返回值。 还记得当时看到自己写的单元测试时,还经常困惑于这个方法就一个简单的流程控制还需要写单元测试吗?现在想来有一种把代码钉在案板上的感觉

当然一些经验非常丰富的高手可能即使后写单元测试,也能写的非常聚焦业务逻辑,非常的有感觉。但这对目前的我来说是困难的。这也许是思维角度的问题,写合适的单元测试是一个好问题。不能为了测试的覆盖率而写单元测试,因为单元测试的代码也是需要维护的。测试驱动从一开始帮你转换了角度,在写单元测试的时候就关注业务逻辑,而非具体的代码实现,代码结构。

关于重构

还想在聊一些关于重构的体会,之前我写代码的时候,经常是面对一个需求,先大概想好了要怎么实现,在实现的过程中发现设计的不合理,可能会直接推倒重来,大改之前已经实现好的部分逻辑。这种破坏性的修改随着需求的规模大小,可能要往返数次。经常会引起一些问题:

这些看似小的问题,往往都在最后的集成测试中发现,甚至带到了线上。虽然我对重构的学习还不够,但已经渐渐明白了重构的一些感觉。

测试驱动在一开始就控制了我的开发节奏,每次行动之前先明确单元测试,每次行动只写一部分可以通过测试的代码。在这个项目初版产生了 100 多个提交,在实践过程中,也有很多次对设计,结构的改动。我发现我的代码结构,甚至抽象结构设计的不合理,不符合需求的时候,在我需要改动他之前。会有单元测试来保证,即使我要做的改动是破坏性的,我也会先按照新的结构来重新改好单元测试,在提交代码。因为这种模式,主动的暴露了很多开发过程中的马虎之处。

另外很重要的一点是,在测试驱动的过程中会让我站在函数调用者的角度,而不是开发者的角度来思考实现和设计

总结

最后来总结下本次实践中,测试驱动所解决自己遇到的问题。

这次练习只是初步的体会下测试驱动带来的好处,后面会多看一些书,总结下比较好的方法论, 应用在开发过程中。