一场说练就练的 Kata

Code Kata · asj · 于 发布 · 最后由 lvjian700回复 · 2589 次阅读
377

干货版

文野同学提出了一个题目:

我们是做海外用车的,跟国内的出租车一样,如果夜间用车,就要收夜间服务费。
每个产品都有自己的夜间服务费收取时段,如开始时间是20:00,结束时间是5:00,用户下单时选择的用车时间是2016-4-23 22:30,现在用TDD的方式来写一个计算用户用车时间是否需要收夜间服务费?

引发了大家的热烈讨论和操练,截至目前已经有了如下练习:

灌水版

发问

这是一个下着雨慵懒的周六下午,不知怎么地,文野同学提出了这么一个问题。

给大家出个题,是我工作中的。最终代码肯定是写出来的了,看看大家是如何通过测试驱动的方式生产出来的。
我们是做海外用车的,跟国内的出租车一样,如果夜间用车,就要收夜间服务费。
每个产品都有自己的夜间服务费收取时段,如开始时间是20:00,结束时间是5:00,用户下单时选择的用车时间是2016-4-23 22:30,现在用TDD的方式来写一个计算用户用车时间是否需要收夜间服务费?
我昨天曾尝试以TDD的方式来写下,感觉解题的思路无法对上tdd的节奏。

之后大家开始探讨需求

小波: 返回boolean?
文野: 是的,三个参数
小波: 哪三个参数
文野: 车,开始时间,结束时间?
开始时间与结束时间是可设置的,所以就会出现1:00到5:00的情况
小波:实例化一下好吗
武可:如果开始比结束晚,是不是就是跨午夜?
如果结束时间超过晚间收费,就分段计算?
噢,你的例子就是这样的

更深入的探讨需求

武可: 怎么表示无收费时间段?
那如果开始结束一样,是不是全天收费?
文野: 如20:00~5:00,那么2016-4-23 22:30就是true,2016-4-23 16:00就是false
2016-4-23 22:30,这个是以时间类型提供的。
20:00,5:00这样的以字符串方式提供的。因为是这样保存到数据库的。

擅长归纳总结的老毕已经画出了牛逼的分析

对怎么TDD进行了展望

武可: TDD的话,从最简单的开始,我习惯认为无区间最简单,返回false
无时间段就看怎么定义了,比如两个空字符串或null?
第二个case 是简单区间比如1:00到3:00,输入2:00
文野: 我昨天第一步就是返回false,后来改成ture,然后就然后不下去了,呵呵。
这个问题的要点就是时间段可能在一天,可能是跨天的。然后如果使用时间类型比较,要把5:00转成有日期的时间。
比如转换成准确的开始时间与结束时间。
那这题就被我做成二三步了,呵呵。完全不会拆。

最后发现,说代码是可耻的,对TDD程序员来说尤为可耻

Talk is cheap show me the code

之后我就开始作死的文本框学习Ruby编程,顺便做这道题了。

武可: Ruby好难,直接抓瞎了
文野: 当前这种不定的时间改成指定的时间,步子确实够小的,呵呵。
武可: 因为不会写而已,不是程序需要
文野:我靠,我还以为我学到知识了呢。
武可:,有生第一个Ruby红灯
文野:象你现在小步产生的这些测试,其实是个思考的过程,这个最后上到生产上,会把这些测试重构掉吗?
如果重构掉了,别人看测试就体会不出思路了。
如果不重构掉,又显得非常冗长,甚至感觉对生产没有意义。
武可: 我会重构
写第二个才发现第一个测试写错了
那个例子应该返回false,刚才脑抽了
文野: 不考虑分钟了吗?还是暂时没做到这块?
你不考虑分钟了吗?
武可: 现在还不会呀
文野: 看得惊心动魄。
武可: 我想起来怎么截字符串了,顺便学会了转整形
感觉完全没有给@文野 答疑,是找人教我Ruby
周师傅救命10:00怎么变成两个数字
我还在纠结怎么把小时分钟从字符串抠出来

……

武可: 好了,写到需求里第一句话的逻辑了
一句话写了49次提交
……
武可:貌似写完了,不考虑各种边界情况
后面一句只有3次提交好像
而且没半夜12点什么事

文野同学的AHA一刻

武可: 怎么样,演示清楚小步了没?
文野: 小步我看到了,确实很震憾,很多从没这么想过。
我在看到45步,发现加了一行代码就跑了一下测试,很受教。
但我另外的感觉是走到这个步骤了,好像对业务要求帮助不大。
武可:比如?
文野:比如到45步了,焦点主要集中在start与end这个字符串解析,然后是数字比较上。
我先继续往下看完。过程很受益啊。
武可:不过TDD往往前面已经决定了需求,然后保证这个模块开发进程的,未必总是和业务相关
这里的小步,除了处理时间逻辑,还有一大原因是我不会Ruby
也就是这两天看书学来的,软件要解决业务复杂度和技术复杂度
所以小步很多是和技术复杂度相关的,毕竟TDD是最基础最小粒度的实践
文野:这个是的。
DDD里把软件复杂度分三块:业务复杂度,技术复杂度,遗留系统复杂度。
.
文野:呵呵,突然发现世界是从45往后开始的。
看到46,突然让我感触很深。
@武可 我最终写的业务逻辑就跟你是一样的。
我的逻辑是自己总结分析出来的,不是一步步演化出来的。
可能我想的就是你50~58步的内容,然后自己感觉游戏突兀
我看到你的过程,确实没有走弯路,而我当时思考这个过程时,其实是走了不少弯路的,最后才总结出来。
最后几个测试,一蹴而就,非常销魂。
我前面看第一遍时,还是觉得50步到58步出这个结果,感觉突兀了,但反复看了几遍,特别是56~58的转变,在配合测试,我突然感觉太棒了,确实就是如此。
自然的转变
如果加个重构的话,就是把开始时间与结束时间的比较,抽取出一个方法,就叫是否是同一天的,呵呵。
.
武可:本来以为要把结束时间加一天呢,还好没有
文野:对,思考,很容易进步加一天减一天的坑,然后觉得很复杂
最后几步我看了很多遍,从跟自己想的对比也觉得突兀,到感觉就是如此自然,呵呵。
最后才想明白,但我确实看到你的代码自然地演化出来了。
.
武可:觉得自然说明你被洗脑啦
文野:53~58,太精彩了。
以后碰到问题,再也不烧脑子去苦思冥想了,要学习这个小步演化。
48之前我都觉得是否偏了,没想到笔峰一转,自然浮现了。太精彩了。
.
武可:前面都是我在学Ruby
文野:前面如果不是学Ruby的话,可能更早得出开始与结束时间,但精华点确实就在48往后几步,然后再回想前面的测试与步骤,确实都是有用的。
我现在用C#做一次。

梁辰详细的点评:

文野做了一遍C#版本:http://cyber-dojo.org/review/show/2641C6A454?avatar=whale&was_tag=35&now_tag=36

  1. 第二个case中的时间段范围跨天,尽管这很可能是现实业务中最常见场景,但其实不是最简单的入门场景,不如把这个场景放入TODO list里,并找一个更简单的(比如 最简单的时间段,最简单的结算时间)。我那个实现里就是选取00:00~00:01,目的就是尽可能分解问题。要不然又是字符串解析,又是时间比较,再加上跨天等同时考虑,思路很快乱掉,这可能也是你开始所说的TDD无从下手的原因。要真的去拆分问题,又简入难。:)
    谢谢,点评下。

  2. step 7 通过if 让case pass是TDD中一个很不错的方法,一种常见方式就是 if 新条件 return hardcode 不过你的这个 if(!string.IsNullOrEmpty(start) && !string.IsNullOrEmpty(end)),过于复杂,不算是‘最直接’的方式。其实这个if条件的书写很关键,不是随意写出来的。很多情况下就体现了业务场景的切换。这里可以直接写start==20:00 end==5:00 time==21:00 就可以了。另外再结合一下问题1 如果你的case选择是start==20:00 end==5:00 time==20:00 就可以通过重构驱动出业务中的'开始时间和要判断的时间重合'的场景,也就是 start<=time<end中的那个等号。
    如果用IsNullOrEmpty做文章,其实这涉及到另一种硬编码pass case的方式:
    if 老的条件 return oldresult
    return hardcode
    结合这个问题就是
    if(start empty or null && end empty or null)
    return false
    return true
    这样就把step 1中的“没有时间段的概念”的场景驱动出来;不过个人感觉这个case不是太好,其实这更应该算错误处理,不是主线业务,上来选这个case也会增加复杂度。比如一个空 另一个非空算什么?不如把整个错误处理也都放到TODO里,以后再说,现尽快实现出关键的场景,而关键场景又是通过一些非关键但简单的场景演变驱动出来的。
    再跑下题,通过两个空输入表示无时间段不好,应该用更明确的表示方式,就像不能用null 表示不存在一样。这涉及到另外的设计问题。

  3. step 8 我是顺着step看的。看到这步,我觉得你应该在step 7中用:
    if(start empty or null && end empty or null)
    return false
    return true
    的方式, 这样你再提一个函数:
    boolean isInvalidPeriod(start, end) { return start empty or null && end empty or null }
    然后原来的代码就变成
    if(isInvalidPeriod(start, end))
    return false
    return true
    然后在用你在step8中的重构直接得出 return !isInvalidPeriod(start, end) 这样会比!string.IsNullOrEmpty(start) && !string.IsNullOrEmpty(end) 有表现力 :)

  4. step step12 这个重构有问题, baby step一下:
    if(dateTime<startDateTime) return true;
    重构:
    if(!(dateTime<startDateTime)) return false;
    然后:
    return !(dateTime<startDateTime)
    最后:
    return dateTime>=startDateTime 而不是 dateTime>startDateTime 是否有‘=’由业务决定,但是不管有无都应该有case,这其实是个重要的边界条件。

  5. step20 新增的case date_late_the_end 没有failed 这不太好,其实按照你的已有实现,这个case是重复的。不过你可能担心会漏掉边界检查, 其实这个case应该有,但取决于你date_earlier_the_end case是怎么pass的。
    超级小步的话是这样:
    回到step 13开始:
    新增加一个case:
    public void 1_00_should_in_time_range_from_20_00_to_5_00
    接着硬编码实现:
    if time == 1:00 && end == 5:00) return true
    接着你的date_late_the_end case就该登场了:
    public void _6_00_should_not_in_time_range_from_20_00_to_5_00
    然后在硬编码:
    if time == 6:00 && end == 5:00) return false
    if time == 1:00 && end == 5:00) return true
    这个时候再重构的时候,你会发现代码说time是1:00时是true,是6点时是false,true 还是 false 都与5:00这个时间’有关‘。而这个所谓的’有关‘才体现出它是‘<'的关系(其实这里还漏掉了=5:00的场景)。这才是TDD的一个演变、发现的过程。
    不要在一开始直接refactor成dateTime<endDateTime,因为那个时候无论从代码,还是case层面看不出为什么是'<'这个逻辑。你之所以这么写是因为这个问题比较简单,脑子里其实提前有答案了。所以这就有点你所说的‘知道最终结果,应套的感觉’
    最后再通过重构测试用例的名字,来体现date
    earlier 或 date_late的概念。这其实也是通过TDD理清业务的一个体现。

  6. step21 为什么把所有的测试时间都改成hh:30?这对case的业务表达有什么用意么?

  7. step22 step25 也属于没有fail的case,和问题5差不多。介于已有实现的话,新case可以是:
    range_in_same_day_and_date_earlier_the_start 和 range_in_same_day_and_date_late_the_end

  8. step27 你的case实际只针对跨天的情况。但你写代码时把不跨天的也一并改了。这需要有相应的case驱动。

  9. all_in_one_test 这样重构测试可以使代码很简洁。但是很容易给人感觉每个case选的很随意,突出不了业务的不同。可以提取case里的那些时间字符串常量。赋予它们一个有意义的名字:
    [TestCase("2016-4-23 20:00", "20:30", "4:30", false)] =>
    [TestCase(TIME_BEFORE_START, START, END, NOT_IN_RANGE)]
    [TestCase("2016-4-23 20:00", "20:30", "4:30", false)] =>
    [TestCase(TIME_EQUAL_TO_START, START, END, IN_RANGE)]

最后在谈下我的实现感受,我觉得TDD的过程绝不仅仅是让我们产生了一些简短的if 判断代码。它应该让我的代码更能表现业务。比如开始大家在讨论题目时都试图通过简单的文字来吧业务描述出来,但涉及到跨天的问题,就不是太容易了,描述语言中掺杂着业务和实现。但是我通过TDD,最后得到:如果计费时间段横跨两天,就把计费区段分成两段,每天一段,然后分别判断就可以了。这是在TDD之前没有想到的。
另外实现上要分出层次,我们上层业务代码定义各种时间段以产生计费的策略,而判断一个时间属不属于一个时间段则不是我们业务代码的职责,所以这题还应该提取出时间段的概念。不知道你们真实环境中有没有period这个类, 或者有没有TimeUtil TimeHelper之类的工具类做了很多period类该做的事情。

然后大家就疯了,各种秀代码,各种花样点评

申导的Python版

申健: 尝试了一下python, http://cyber-dojo.org/review/show/27E5C9
赵然: 发现申导的提交有个规律,不会同时修改product code和test code
Python 支持 a <= b < c,既直观又简洁
武可: 第16步好销魂…
24步跳了一下就被test抓到了
_is_across_day = lambda start_hour, end_hour: end_hour < start_hour
申健: good
就是这个lambda关键字有点长
武可: 已经玩的看不懂了

约总的Java 8版

姚若舟: 我终于在往返西安和上海的飞机上面把计费那个Kata做了一遍
大家有空给点反馈啊 https://github.com/JosephYao/additional_fee_kata
我晚点把大家的这些Kata整理到新一期的Kata接力里面去

结果又跳入了无返回值编程的大坑

文野: 约总的因为没看到步骤,所以没太看明白产出过程。
在代码层面上,感觉也略显复杂。
姚若舟: 呵呵,那是有意的。[Chuckle] 我现在尽量写没有返回值的代码
好,如果的确那里写复杂了,要和我讲哈。我其实第一个用 Java 8 新出的 java.time 包
过程可以看提交记录的
文野: 约总这样写是要体现CQR思路吗?
姚若舟: tell don't ask (无返回值)的代码,的确是 CQRS 的思路有类似的地方
另外,更多是代码职责的问题。尤其在生产代码中写的时候,我总是问为什么一个类要把内部的状态返回出来呢?
就像这里,为什么 AdditionalFeePeriod 这个类一定要返回 boolean ,然后让调用者来决定执行什么代码呢?
... ...
刘光聪: @姚若舟 无返回值,意味着某种副作用。我觉得方法的提供就是为了完成某种计算,当然不是仅仅将字段暴露出去那种坏味道。
... ...
刘光聪: @姚若舟 刚看了contains的实现,很合理。行为被参数化,封装性太好了,tell,don't ask原则的好例子。
从本质上将客户端的if-else的重复彻底消除了,达到最大化的复用。用户只需定制差异,或者变化的部分,用lambda,或者其他的方式。简单,漂亮

文野发明了一个新Kata

老毕: 嗯,看你们的Dojo,我感觉我的路数不太对。我都是先规划好测试的方案,才动手写。然后才驱动出代码。如果这个规划本身有漏洞,确实是会导致一些问题。
武可: TDD style是这样的:啥?好像做完了,让我琢磨琢磨我是咋写出来的
老毕: 你们的测试方法是一个一个地添加的。我是提前先把所有可能的测试路径都规划出来,对应一个个测试方法,然后才往这些测试方法里填东西
梁辰: 有点像那个冰壶的游戏,都是先有大方向,然后,球快滑到哪再拿个刷子刷哪。[Grin]
老毕: 看了你们这一步步出来的方式,感觉比提前做规划来得小步一些,也轻松一点似的。
武可: 看来这个例子真的不错啊,又有人get到了
仔细想想文野这道题挺合适Kata的
本身有点绕,一般都有想一阵才能想明白。又能较快做出来,避免一个小时翻译一到十的惨剧
而且还能延伸出几号至几号,周几到周几的坑人变种
@文野 你发明了一个新Kata

我们好像发明了一种新Dojo玩法

这次的突发练习和传统Dojo各方面都不一样。

  • 没面对面交流
  • 没同时进行
  • 没统一语言
  • 没时间限制

但是热烈的气氛似乎和面对面一起Dojo没什么差别,甚至讨论的更加深入一点。
看起来微信群聊和Cyber-dojo这样的工具,为我们操练代码提供了更多的可能性。

如果你有耐心看到这里,想必也是对TDD和代码操练满怀兴趣的。不妨扫码,发 “TDD”。入群一起玩起来吧。


「软件匠艺社区」旨在传播匠艺精神,通过分享好的「工作方式」和「习惯」以帮助程序员更加快乐高效地编程。
本帖已被设为精华帖!
共收到 5 条回复
351
abbey · #1 ·

Perfect

434
czqlan · #2 ·

牛逼

30
lvjian700 · #3 ·

awesome !!!

借机安利一下个人 repository:
https://github.com/lvjian700/ruby-boilerplate

ruby 的基于 rspec tdd 的项目模板。 必须是 100% 测试覆盖率。
不 TDD 很难达到 100% 测试覆盖率噢。

377
asj · #4 ·

#3楼 @lvjian700 100%覆盖率,听起来就很可怕啊

30
lvjian700 · #5 ·

#4楼 @asj 哈哈,不可怕。 已经被 100% 覆盖率虐了一年了。
如果是 TDD 出来的代码,100% 覆盖率是很容易的。

需要 登录 后回复方可回复, 如果你还没有账号你可以 注册 一个帐号。