Day 05 「乖,听话给你吃糖果!」测试与依赖:测资料 之 用资料控制依赖

聊完测试金字塔,让我们回到单元测试。

在这篇中,我们会从单元测试的控制与撰写开始,一路带到单元测试与「单一职责原则(Single Responsibility Principle)」的关系。

透过被依赖对象,控制测试目标逻辑

我们来看一段虽然有点违反单一职责原则,但暂时不算太严重的程序,体验一下测试该怎麽加。

我们回到我们的教务处网站范例,教务处打算要发奖学金了,来申请奖学金的同学只要本学期「至少 1/2 的学科有达 80 分以上,就发给全额奖学金 1 万元,如果只有 1/3 达成,就发半额的 5 千元。」

这样的需求对各位读者来说应该算简单,我们可以马上来做看看:

首先,想要算奖学金,必须要先有成绩单,於是我们先准备好成绩单与各科目成绩如下:

@Data
@AllArgsConstructor
public class Course {
    private String name;
    private int score;
}
@Data
public class Transcript {
    private List<Course> courses;

    public Transcript(Course... courses) {
        this.courses = Arrays.asList(courses);
    }
}

有了成绩单以後,我们就可以拿着成绩单,直接把需求指定的逻辑给做出来了,别忘了考虑「本学期未修课」的案例:

 public int calculate(Transcript transcript) {

    List<Course> courses = transcript.getCourses();
    if (courses.isEmpty()) return 0; // 不修课跟人家领什麽奖学金!

    int total = courses.size();
    int achieved = 0;
    for (Course course : courses) {
        if (course.getScore() >= 80) {
            achieved++;
        }
    }
    double rate = (double) achieved / total;

    if (rate >= (double) 1 / 2) {
        return 10_000;
    } else if (rate >= (double) 1 / 3) {
        return 5_000;
    } else {
        return 0;
    }
}

这里,读者也许已经注意到了,这里写程序的顺序是「由下往上」的设计,也就是先准备底层物件,再做上层逻辑。这其实跟笔者平常的习惯是反过来的,但这是为了表现其实不一定要由上往下,也不一定要先写测试。至於「由上往下」有什麽不一样,我们晚点讲 TDD 的主题时,会再多做说明。而对单元测试的初学者来说,我们不要一次改变太多写程序的习惯,一步一步慢慢来,首先先从「程序都要尽量带测试」开始。


节录自电影「让子弹飞」

程序写完就来测试吧。我们首先处理「没有修课的人」,没修课你跟人家领什麽奖学金?所以预期他应该要得到零元。

@Test
void NO_courses() {
    ScholarshipService service = new ScholarshipService();

    int actual = service.calculate(new Transcript(/*nothing*/));

    Assertions.assertEquals(0, actual);
}

再来,如果这学生很厉害,真的有达成「全额奖学金」的目标,那我们就要依约给他一万元:

@Test
void full_scholarship() {

    ScholarshipService service = new ScholarshipService();

    int actual = service.calculate(new Transcript(
            new Course("Algorithm", 70),
            new Course("Computer Internet", 80),
            new Course("Operating System", 90)

    ));

    Assertions.assertEquals(10_000, actual);
}

接着,我们再依样画葫芦,把半额奖学金跟未达标的测项补上就好。完整测项请参阅 https://github.com/bearhsu2/ithelp2021.git

以上,我们就介绍了如何透过控制依赖,来控制受测对象的程序逻辑,以达到分别测试各个不同逻辑分支的效果。接下来我们多花一点篇幅来探讨,当程序变得复杂,会对我们的测试造成什麽影响。

瞬间飙高的程序复杂度。

上述的程序在笔者工作场合,Code Review 是不会过的,Code Smell 太重了。但是在需求不会修改的情况下,我觉得还可以,是因为命名还算清楚,篇幅也还算小,阅读上没什麽问题,测试也不难加。然而,需求哪有不会修改的呢?

有一天修改的需求来了。我们必须把学生分为「大学生」、「硕士生」,与「博士生」三种,全额奖学金的金额与资格判定方式都不同:

大学生 硕士生 博士生
全额奖学金 10,000 15,000 40,000
全额条件 至少 1/2 的学科有达 80 分以上 依学分加权平均後达 90 分以上 全部学科达 90 分以上
半额条件 至少 1/3 的学科有达 80 分以上 依学分加权平均後达 80 分以上 全部学科达 80 分以上

如何?是否困难度一下子就飙升了?先不说程序了,我们光来数看看测项数吧!要达到可信赖的测试保护力,至少一个逻辑分支要有一个测项保护,这不为过吧?而逻辑分支主要取决於变因。我们来看看这样的需求,需要几个测项:

变数 分支数 说明
学生种类 3 大学、硕士、博士
奖学金资格 3 全额、半额、资格不符

上述可能发生的情况,再加上三种学生都有可能「没修课」,至少你就要写 3 x 3 + 3 = 12 个测项!

碍於篇幅,这里我就不附上完整程序码了,文末的 GitHub 连结中有完整范例,其中程序就将近 100 行,测试更是飙升到超过 200 行!读者可以下载来看看,体会一下「老板的简单两句话,我们要多做多少事」...

「到底为什麽这麽麻烦啊?我到底做错了什麽事啊?」

瘦到雨都淋不到:单一职责原则

Uncle Bob 在 Clean Code 谈到函式时表示:「函式应该要做一件事。它应该要把这件事做好,并且只能做这件事。」书中也另外补充说明道:「一个函式应该要只能因为一个理由而被修改。」

为什麽?我们知道,「修改乃是 bug 发生的源头」,线上运行得好好的程序,会突然出 bug,有 87% 是因为修改所致。当你一段函式要管非常多事情的时候,这段程序就很有可能因为某个需求的变动而被修改。原因很简单:「因为他胖嘛~下雨淋到胖子很正常,因为他面积大呀!」

问题一段函式已经够长了,我好不容易读了半小时终於看懂他在干嘛,并且找到修改点,这时你会做什麽事?要知道,一段程序不管谁写的,只要你把它改坏,他马上就变成你的了。这时为了自保,聪明如你,会做的事就不外乎两个:

  1. 加个 if,防御性编程
  2. 复制一份出来改,以免破坏原逻辑

问题来了,不管你决定要做这两件事的哪一件,你都又让这段程序多做了一件事,也就是说,他又管了更多事,变得更复杂了。是不是很有既视感?是啊!我们每天在面对的程序,就都是长这样啊!这是个很明显的恶性循环啊!

奖学金的范例,就管太多了...

有了职责的概念後,我们来看看刚刚这段程序管了哪些事?

  1. 学生的身份
  2. 学生有没有修一门以上的课
  3. 学生符合哪种奖学金资格
  4. 每种资格该发多少钱

随便列就四个责任,真的蛮多的。

如果这时,三种学生再各自加上「侨生」、「体保生」、「身心障碍」等变因,你要再多列多少测项?如果你继续一股脑儿往 calculate() 函式加逻辑,继续加 if 或复制贴上,你应该可以预期一个「指数成长」的程序行数与测项数。坏消息还不只这个。你知道需求是不会停止成长的,所以,一旦你放任你的程序充斥这种「太胖所以容易淋湿」的程序码,你就会读得很痛苦,改得很痛苦,并且测得很痛苦。

可是你又不笨,对吧?读跟改,你就避不了,但测可以往外推啊!於是解决难测的办法,就是赶快写完赶快丢给 QA 测,万一 QA 没抓到 bug,就请用户帮忙测噜。那就回到我们第一天讲的,全公司大家都有意无意地在「努力降低品质」的窘境了。

重构是个解方,但前提要有测试

程序管太多就不好测,不好测就要测很久,要测很久就很急,急就复制贴上,然後程序就管更多。你现在手上的专案就是这样。如果想要打破这种恶性循环,那你就应该要在问题开始产生的初期,就把程序码「重构」好,把权责区分好。结构好的程序,可以让工作量呈现「线性成长」,而非「指数成长」。

怎麽重构,晚点我们有一些聊「重构」的章节,会回头看看怎麽处理。但这里先不急着讨论,为什麽?因为如果你到时候真的很想重构,那此时此刻你应该做的事,是要先加上足够的测试。有了测试的保护,你才有重构的勇气,就不用向梁静茹要了。


图片来源:维基百科

请注意,这里我们都没有在讲 TDD 喔!我们就纯粹是要为一个「已经存在的程序」加上测试保护逻辑而已。

谜之声:「胖不是病,但胖起来要人命。程序跟人都一样。」

Reference

  1. Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship. Upper Saddle River, NJ: Prentice Hall, 2009.
  2. GitHub: https://github.com/bearhsu2/ithelp2021.git
tags: ithelp2021

<<:  [Day05] Web API 专案架构

>>:  Varying - fragment shader 之资料

[前端暴龙机,Vue2.x 进化 Vue3 ] Day1.在认识vue之前(一)

[讲古时间]: 回忆过去~ 痛苦的相思忘不了~ 哈哈,记得刚出社会时,虽然说是张白纸,不过可能太白...

作业系统L7-死结

作业系统L7-死结 死结特性 互斥:一次一个行程占用资源 占用与等候:至少一个行程占用资源且正等待其...

Day03 - 纯 Html - 复杂型别 object

Day03 - 纯 Html 复杂型别 object 复杂型别定义 复杂型别我拆成三篇 object...

Day28 Java 注解

●Java 自定义注解 创建自定义注解类似於编写接口,不同之处在於interface关键字以@符号为...

[Day 1] Google Data Analytics Professional Certificate 介绍

《30天带你上完 Google Data Analytics Certificate 课程》系列将...