TDD:程序员的“先上车后买票”指南

这个世界还有希望吗

目前开发模式

一般来讲,我们的开发模式是这样的:

  1. 拿到需求,粗粗的看一遍
  2. 不管了,先开发再说,时间紧任务重
  3. 咔咔一顿敲键盘,再补点测试用例,完事
  4. wc,产品怎么又改需求
  5. wc,测试用例通过不了了,算了,不管了
    在这里插入图片描述

这样做有哪些问题

  1. 大部分测试都是手动的,依赖你自己或者测试同事,基本上都是黑盒测试,质量上很难保证
  2. 假如线上有bug需要复现,大概率是没有测试代码的,那就需要手动构造参数或者环境去调试,效率非常低
  3. 假如有一天需要其他同事修改你的代码,那压根就不敢改,改不动,只能往上面写个if else,拉一坨屎就走,慢慢的,这代码就没法看了,改动风险太高

TDD来了

TDD是什么

TDD是测试驱动开发(Test-Driven Development)的英文简称,是敏捷开发中的一项核心实践和技术,也是一种设计方法论。
TDD 的基本思想就是在开发功能代码之前,先编写测试代码。也就是说在明确要开发某个功能后,首先思考 如何对这个功能进行测试,并完成测试代码的编写,然后编写相关的代码满足这些测试用例。然后循环进行 添加其他功能,直到完全部功能的开发。

测试驱动开发的想法来自于极限编程(Extreme Programming,XP)。Kent Beck在他2002年出版的书中《Test-Driven Development》书中系统的进行了测试驱动开发的理念阐述,用一些实践例子论述了TDD整个过程。

TDD期望达到的效果

TDD流程上是先写测试再写实现代码,核心目标是期望用一种标准化的流程来保证项目流程透明,风险可控。
使用TDD后,理论上会有几个效果:

  • 代码都是经过测试的,不写没有测试的代码
  • 没有冗余的代码,你的代码都是为了测试用例而实现的,不是为了未来的需求实现的
  • 肯定能通过测试用例,质量上有保证
  • 更快更安全的重构

TDD实践

TDD流程

在使用TDD时,一般有以下几个流程:

  1. 分析需求,理解需求,拆分为任务
  2. 对一个任务编写测试用例
  3. 编译运行,肯定失败(红灯)
  4. 编写实现代码,让用例通过(绿灯)
  5. 重构代码,让用例通过(重构)
  6. 重复以上步骤
    如下图所示
    在这里插入图片描述

在实现代码之前要进行需求的分析,将需求分解为一个个任务列表。每一个任务,需要考虑其输入输出,可能最终会抽象为一个可测的类或者函数,使其具备可测试性。

每一个任务,需要有一组测试用例相对应。用例的编写要从用户角度出发来编写。

为什么不能先实现后补测试用例?因为这样出发点就变了,你不是为了需求而出发的,而是为了单纯提高测试覆盖率,初心变了,增加了很多负担,还不如不写。

绿

这一步TDD要求用最简单的方式让测试用例通过。

因为最简单,所以不需要考虑未来需求的变化,只是满足当前的需要,也许看起来代码很乱,例如很多if else等等,没关系。

重构

用例全部通过,就可以着手重构了,因为上一步写的代码大概率是非常丑陋的,简直没法看。

关于重构的技法,建议阅读《重构》这本书,写的非常全面了。

这一步我们尽量让代码变得可读,变得更优雅。

TDD实践

我们来找个LeetCode的题来练习一下,经典简单题[狗头]
地址:https://leetcode.cn/problems/two-sum/
在这里插入图片描述

写测试用例

先把题目的示例数据当做输入输出编写测试用例

package main

import (
    "reflect"
    "testing"
)

func TestTwoSum(t *testing.T) {
    tests := []struct {
        nums     []int
        target   int
        expected []int
    }{
        {[]int{2, 7, 11, 15}, 9, []int{0, 1}},
    }

    for _, tt := range tests {
        result := twoSum(tt.nums, tt.target)
        if !reflect.DeepEqual(result, tt.expected) {
            t.Errorf("twoSum(%v, %d) = %v, expected %v", tt.nums, tt.target, result, tt.expected)
        }
    }
}

func twoSum(nums []int, target int) []int {
    return nil
}

OK,运行,能通过就有鬼了(红)

编写第一个实现

func twoSum(nums []int, target int) []int {
    return []int{0, 1}
}

这肯定过了(绿),接下来填充其他两个测试用例,于是代码就变成了这样子

func twoSum(nums []int, target int) []int {
	if reflect.DeepEqual(nums, []int{2, 7, 11, 15}) && target == 9 {
		return []int{0, 1}
	}
	if reflect.DeepEqual(nums, []int{3, 2, 4}) && target == 6 {
		return []int{1, 2}
	}
	if reflect.DeepEqual(nums, []int{3, 3}) && target == 6 {
		return []int{0, 1}
	}
	return nil
}

怎么样,是不是非常丑陋

重构1

接下来使用一个双重循环进行重构,测试用例通过,非常安心

func twoSum(nums []int, target int) []int {
    for i := 0; i < len(nums); i++ {
        for j := i + 1; j < len(nums); j++ {
            if nums[i] + nums[j] == target {
                return []int{i, j}
            }
        }
    }
    return nil
}

然后你放到LeetCode运行,发现超时了,需要进一步优化

重构2

虽然双重循环可以满足需求,但效率不高。我们可以使用一个哈希表来优化查找过程

func twoSum(nums []int, target int) []int {
    numToIndex := make(map[int]int)
    for i, num := range nums {
        complement := target - num
        if idx, ok := numToIndex[complement]; ok {
            return []int{idx, i}
        }
        numToIndex[num] = i
    }
    return nil
}

通过逐步实现和优化,我们不仅确保了代码的正确性,还提高了代码的效率。

实践小结

以上是选取的LeetCode的一道题,测试用例是现成的,但是在实际工作中,大部分需求是需要我们先深刻理解需求,站在用户/使用方的角度去看待整个系统,用户有哪些操作,期望达成效果是什么,依次为依据来编写测试用例。

思考

为啥国内很少能真正落地

私以为,主要有几个原因:

  1. 时间紧任务重,很多时候排期不会给你那么长,而写测试用例需要额外的时间
  2. 需求变化非常大,甚至需求文档形同虚设,你按需求文档写好测试用例并提测了,哦,不好意思,验收的时候,产品表示他希望的不是这样的,或者 更可恶的是,他说他后面更新了需求单,逻辑有变化了,什么时间修改的呢?噢,凌晨,修改了也不会通知你,测试用例等于白写
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值