跳转至

01 TDD演示(1):任务分解法与整体工作流程

你好,我是徐昊。今天我们来聊聊测试驱动开发(Test-Driven Development,TDD)。

测试驱动开发,顾名思义,就是将软件需求转化为一组自动化测试,然后再根据测试描绘的场景,逐步实现软件功能的开发方法。

在正式开始学习TDD之前,我想通过四节课的时间,来演示如何通过TDD的方式完成一段完整的功能,让你对TDD的做法有个感性的认识。毕竟,我想很多人对TDD心存质疑,最主要还是因为不光没吃过猪肉,其实也没见过猪跑吧。

TDD的基本原则

为了让我的演示更有针对性,有些基本原则你需要先了解一下。TDD的创始人Kent Beck,在他的传世大作 Test-Driven Development by Example 的开篇中给出了TDD的基本原则:

  1. 当且仅当存在失败的自动化测试时,才开始编写生产代码;
  2. 消除重复。

不过在今时今日,我认为第二条应该改为“消除坏味道(Bad Smell)”。毕竟重复仅仅是一种坏味道,还有很多不是重复的坏味道。

那么根据TDD的基本原则,Kent Beck将开发工作分成了三步,也就是后世广为流传的测试驱动开发咒语——红/绿/重构(Red/Green/Refactoring):

  1. 红:编写一个失败的小测试,甚至可以是无法编译的测试;
  2. 绿:让这个测试快速通过,甚至不惜犯下任何罪恶;
  3. 重构:消除上一步中产生的所有重复(坏味道)。

然而红/绿/重构循环仅仅关注单个测试这个层面,它没有回答测试从何而来。于是很多尝试采用TDD的人都卡在了第零步:我该写哪些测试?于是在2006年前后我总结了任务分解法,将任务列表作为TDD的核心要素。

任务分解法的步骤如下:

  1. 大致构思软件被使用的方式,把握对外接口的方向;
  2. 大致构思功能的实现方式,划分所需的组件(Component)以及组件间的关系(所谓的架构)。当然,如果没思路,也可以不划分;
  3. 根据需求的功能描述拆分功能点,功能点要考虑正确路径(Happy Path)和边界条件(Sad Path);
  4. 依照组件以及组件间的关系,将功能拆分到对应组件;
  5. 针对拆分的结果编写测试,进入红/绿/重构循环。

那么TDD的整体工作流程如下图所示:

请花几分钟仔细记忆这个流程,它将会在后续所有示例题目中反复出现。

命令行参数解析

接下来,我会通过TDD来实现命令行参数解析的功能。这个练习源自Robert C. Martin的*Clean Code*第十四章的一个例子。需求描述如下:

我们中的大多数人都不得不时不时地解析一下命令行参数。如果我们没有一个方便的工具,那么我们就简单地处理一下传入main函数的字符串数组。有很多开源工具可以完成这个任务,但它们可能并不能完全满足我们的要求。所以我们再写一个吧。

传递给程序的参数由标志和值组成。标志应该是一个字符,前面有一个减号。每个标志都应该有零个或多个与之相关的值。例如:

-l -p 8080 -d /usr/logs

“l”(日志)没有相关的值,它是一个布尔标志,如果存在则为true,不存在则为false。“p”(端口)有一个整数值,“d”(目录)有一个字符串值。标志后面如果存在多个值,则该标志表示一个列表:

-g this is a list -d 1 2 -3 5

"g"表示一个字符串列表[“this”, “is”, “a”, “list”],“d"标志表示一个整数列表[1, 2, -3, 5]。

如果参数中没有指定某个标志,那么解析器应该指定一个默认值。例如,false代表布尔值,0代表数字,”"代表字符串,[]代表列表。如果给出的参数与模式不匹配,重要的是给出一个好的错误信息,准确地解释什么是错误的。

确保你的代码是可扩展的,即如何增加新的数值类型是直接和明显的。

那么接下来,就让我们结合任务分解法使用TDD来完成这个需求。

API构思与组件划分

首先我们需要考虑,别人将以何种方式使用这段代码,也就是这段代码的整体对外接口部分。我们可以通过写测试的方式,来感受API的友好程度。

在确定了API的形式之后,我们需要大致构思如何实现这个功能:

功能分解与任务列表

在API与实现方式有了方向之后,我们就可以根据需求的描述对功能进行分解了。这里可以先不求全面,有个大致的范围即可:

红/绿循环

那么先让我们选择最简单的任务,并通过红绿循环实现它。注意其中绿的环节中,我们是如何“不惜犯下任何罪恶”的!

之后的两个任务,也要以同样的方式进行:

到此为止,我们已经完成题目中要求的第一个功能点了:

小结

至此为止,我们实现了第一个主要功能:支持由三个不同类型组合而成的命令行参数的解析。

可以看到,我们在进入TDD的红/绿/重构循环之前做了许多准备工作。我们先花费2~3分钟设计了API,2~3分钟构思了实现策略,然后在任务分解上花费了更多的时间,差不多有5分钟。到目前为止,编码的时间大约是15分钟。

这并不像很多原教旨主义TDD实践者所推崇的那样,完全依赖重构而不去做设计。然而以我二十年来实践TDD的经验来看,理解需求,并通过测试构成高效的节奏,是有效实施TDD的前提。特别是在有其他团队成员的情况下(结对或项目组),更需要如此。希望你能从今天起,更加注重“TDD的准备工作”。

下节课,我们将在这段代码的基础上开始重构,然后再逐步完成后续的开发。

思考题

如果在思考实现策略的时候,我们选择了其他的实现方式,那么任务分解会有什么不同?

如果你在学习过程中还有什么问题或想法,欢迎加入读者交流群。最后,也欢迎把你学习这节课的代码与体会分享在留言区,我们下节课再见!

精选留言(15)
  • 🐑 👍(0) 💬(0)

    TDD专栏福利大合集: 1、打卡赢好礼(4月23日-5月10日):正在进行中,学习专栏第1-10讲并在留言区打卡,结束后奖励; 2、代码亲手评(5月底):预计打卡结束后启动,完成前10讲的打卡,即可提交代码练习作业,徐昊老师会亲自点评; 3、线上带你练:根据专栏更新节奏和老师时间安排确定,徐昊老师会线上带四个同学手把手地改代码,敬请期待! 具体活动介绍见 👉 http://gk.link/a/11jPi

    2022-04-28

  • 🐑 👍(5) 💬(2)

    大家好~我是TDD这门课的编辑辰洋~ 🎐我来友情提示一下下~ 01-04是视频演示,好对TDD有个感性的认识。 05-10是对演示的复盘,同时也会讲解TDD的相关知识点。比如测试的基本结构,TDD中的测试是啥~ 所以,如果你在01-04的操作卡壳了,可以从05开始学,看看5-10,这才是重点哇!看完再回头去看01-04~

    2022-03-22

  • Gojustforfun 👍(24) 💬(2)

    Go语言实现: https://github.com/longyue0521/TDD-In-Go/tree/args 提交历史记录: https://github.com/longyue0521/TDD-In-Go/commits/args (用vscode对比提交记录看更方便) 1. TDD其实是三项已有技术的重组 —— 先大概设计,再落地测试,再重构出最终代码 1) 设计能力:软件设计原则/思想/模式 2) 测试能力:测试技术/方法/工具 3) 重构能力:代码坏味道,重构方法/工具 2. 需求拆分一般有两种方式 1) 任务列表 —— 从无到有实现各个功能点,从内到外,比如实现Option和ListOption等 2) 测试列表 —— 通过所有测试即表示实现功能,Outside-in,从外部API一点一点向内推 希望老师多提供案例,带领大家多多练习以提升需求拆分这项能力——解决测试从哪来的问题 3. 为什么一定要先看到红灯? 1) 测试的正确性如何保证?你不可能再为测试写测试,你需要看到测试以你预期的方式失败! 2) 如果你没看到红灯,要么测试有问题,要么代码已提前实现(推荐把代码改错,强制看到红灯) 3) 红灯表示“缺少功能/实现错误”,“以终为始”失败测试就是那个“终”帮助确定产品代码缺少当前失败测试所捕获的“功能”. 4) 此阶段的目标:确定“终”后需努力不落空! 4. 为什么一定要快速看到绿灯? 1) 关注点分离,“终”已经由失败的测试确定,代码只要通过“终”即可. 2) 代码硬编码,僵化,设计不好等问题,要么是“终”不够多,要么是重构阶段的锅 3) 此阶段的目标:用尽可能简洁的代码使当前所有测试通过! 5. 为什么一定要重构? 1) 好的代码使重构出来的,这里指的是代码的实现,API一般是提前构思好的,偶有部分返工. 2) 识别代码坏味道,熟练运用重构方法/技巧/工具及软件设计思想/原则/模式等驱动出最终产品代码和测试代码 3) 强调:测试代码也需要重构! 4) 每次重构都要运行所有测试,确保绿灯!一旦红灯,回退到绿灯再重构! 5) 此阶段的目标:弥补为了快速看到绿灯所犯的过错! 实践TDD就像玩游戏,进入下一个关卡(红灯/绿灯/重构)前先保存进度(git),挂了就重来!

    2022-03-19

  • Jxin 👍(2) 💬(1)

    总结 1.分离关注点,实现的时候只关注一个原子功能点的实现,不关心重构。(不纠结坏味道) 2.重构的时只看红绿过不过,不关心功能实现。(不需要顾虑是否改坏了) 疑问: 1.当前例子可以拆分成一个个原子功能,彼此没依赖。但有些时候功能与功能间是会嵌套的,该把他们拆开还是合并成一个去看?拆分原子功能(测试列表)的粒度是个难点。 2.实际工作中,我们伴随的可能是大量需要mock的接口,又该如何平衡?

    2022-03-18

  • 李圣悦 👍(2) 💬(1)

    java不太懂,tdd有不好的地方吗?往自己做的项目上套,大多业务相关的需要复杂环境,简单的获取cpu占用率,这种的如何自动化验证结果正确?如果更加复杂无法简单构造环境的呢?

    2022-04-22

  • 阿崔cxr 👍(2) 💬(2)

    红 / 绿循环这一集里 我看是先写了一个伪实现,然后通过取反的测试来驱动出真正的实现 我自己的做法是按照 TPP 的步骤,一开始也是伪实现,不过因为我知道他是错误的,所以直接在这一个测试的基础上就直接把代码实现成正确的了, 没有做取反的测试。

    2022-03-16

  • 冯俊晨 👍(1) 💬(1)

    作为Java小白,从0摸索配置。 Idea IntelliJ -> 创建新项目-> Java / Grovy / Kotlin -> start with sample code;等待编译完成,就形成了第一个视频里代码框架

    2022-05-25

  • 👍(1) 💬(1)

    能用一个项目实战的方式吗? 比如真实的项目需求,从需求分解到任务的分解,到测试的目标, 大家都说这种参数的例子,总是有点教科书的感觉

    2022-03-28

  • 文经 👍(1) 💬(1)

    徐老师,我对Java不了解,通过注解编写测试用例隐藏了太多实现的细节,也多细节没看懂。能不能加个餐,介绍一下Java 通过注解编写的原理。

    2022-03-23

  • aoe 👍(1) 💬(2)

    两天前看完视频,感觉很容易,当自信满满的开始练习时,完全不像视频中行云流水,而是一步一个砍。最后跟着视频写代码,写到解析多参数时感觉像在 LeetCode 上做算法题。以下是解题思路(最终还是跟着老师的视频敲代码。实现第一个布尔类型的时候用 Stream 求解,结果到第二个求整数的时候发现还是 List 好用,又换了回去) TDD 模板 1. API 构思与组件划分 首先我们需要考虑,**别人将以何种方式使用这段代码**,也就是这段代码的整体对外接口部分。 我们可以通过写测试的方式,来感受 API 的友好程度 2. 在确定了 API 的形式之后,我们需要大致构思如何实现这个功能 3. 功能分解与任务列表 ⭐️⭐️⭐️⭐️⭐️(TDD 核心之一) 在 API 与实现方式有了方向之后,我们就可以根据需求的描述对功能进行分解了。 这里可以先不求全面,有个大致的范围即可 4. 红 / 绿循环 那么先让我们选择最简单的任务,并通过红绿循环实现它 功能分析:解析空格分割的字符串 示例:-l -p 8080 -d /usr/logs -g this is a list -d 1 2 -3 5 功能 - 减号后的字符代表一个功能符号 - l - 功能:日志,没有相关的值 - 类型:布尔 - 描述:存在=true;不存在=false - p - 功能:端口 - 类型:整数 - 描述:有一个整数值 - d - 功能:目录 - 类型:字符串 - 描述有一个字符串 - g - 功能:一个字符串列表 - 类型:字符串 - d - 功能:表示一个整数列表 - 类型:整数组成的字符串 - 如果参数中没有指定某个标志,那么解析器应该指定一个默认值 - 布尔:false - 整数:0 - 列表:[] - 如果给出的参数与模式不匹配,给出友好提示 - 简要错误信息 - 告知错误原因 三种实现方式 1. -l -p 8080 -d /usr/logs 一次解析整个字符串解析(难度大) 2. [-l], [-p, 8080], [-d, /usr/logs] 按功能划分,处理特定数组(简单)💡 课程中选取了最简单的方案实现 3. {-l:[], -p:[8080], -d:[/usr/logs]} 按功能划分,从Map中取出相应的值(比数组复杂) 方式 2 具体实现 单个功能 布尔类型:-l 单整数类型:-p 8080 单个连续字符串类型:-d /usr/logs 组合功能 -l -p 8080 -d /usr/logs 异常情况 布尔类型:输入 -l a; -l 3 单整数类型:输入 -p a; -p 3.14 单个连续字符串类型:输入 -d a b c; -d /usr/logs /usr/logs/a.log 感悟:站在使用者的角度看待自己的代码

    2022-03-20

  • aoe 👍(1) 💬(1)

    原来需要一个 TDOD list ,不是想到什么写什么

    2022-03-18

  • Objective 👍(0) 💬(1)

    TDD PHP DEMO: https://github.com/r3nyou/tdd-php-demo

    2022-05-07

  • webmin 👍(0) 💬(1)

    自顶向下分解,拆解为小问题后,逐个解决掉,合起来就把大问题给解决掉了,递归不单是一种算法,也是一种解决计算问题的思维方式。 正确路径 主干问题,边界条件 细节问题,就像一颗树的树干、树枝和树叶的关系。 看了老师的演示才知道在工程实践中怎么正确的使用TDD来落地预构、实现、验证这个闭环。

    2022-03-21

  • 飞翔 👍(0) 💬(1)

    老师 能问问权限认证放在接口层还是应用层呀 为什么?

    2022-03-19

  • 飞翔 👍(0) 💬(1)

    老师 DDD只能用于业务建模嘛 框架比如Spring zookeeper 能用ddd嘛

    2022-03-18