Day 23 「启动!Outside-In 之路」Controller 与单元测试

台湾的职业运动中,最具代表性的应该就是棒球了。大家有去打击练习场玩过吗?现在的打击练习场,在业者持续改良转型下,已经慢慢转变成大人小孩都适点的综合型娱乐场所了。而说到棒球的打击,笔者也是略懂略懂,可以依个人喜好,选择进行 Inside-Out,或是 Outside-In 的攻击。

Outside-In 是一种强力拉回型的攻击方式,打者在球进到攻击范围内,算准挥棒的甜蜜点,在球棒加整到最快的瞬间击中球,将球送出。一般而言,力量较强的大炮型打者,比较喜欢这种方式。而 Inside-Out 不同,它强调把身体重心留在原地,蓄势待发,把球看清楚,等到攻击时机成熟,再一口气释放力量,把球送出。由於 Inside-Out 的攻击方式会把球看久一点,所以很多时候会把球送到打击方向的相反方向,形成球评主播口中常说的「反方向攻击」。常见於安打型球员。若论国内 Inside-Out 打击最具代表性球员,则非中信兄弟看板球星「火星恰」彭政闵莫属了。


Inside-out 教科书 - 彭政闵,图片截自 WikiPedia

如同棒球,写程序也可以 Inside-Out,也可以 Outside-In,依场景不同,开发者可以自由选择。

上一篇中我们分析了一个「申请奖学金」的实际案例,而从今天开始一连四篇,我们要来试看看 Outside-In 配合 Clean Architecture 的开发要怎麽进行。我们会从後端 API 服务中,位於 Interface Adatper 层的 Controller 开始,一层一层地往内把这个 Query 的程序与测试做出来。因为我们已经聊过 TDD 了,所以接下来每一层的程序,我们都会以 TDD 的方式进行。

测项

笔者在以 TDD 进行开发时,习惯先把可能会遇到的场景先列出来。这样做的好处,除了先帮助自己先厘清接下来要做的事情以外,也可以在开发的路上,有个可以参照的对象,比较不会不小心走歪路。

测项 结果
学生不存在 400
奖学金不存在 400
资料存取错误 500
其他异常 500
成功 200

请留意,这些测项是一开始我们对这个类别行为的规划,而不是规定。因此,随着待会开发的进行,我们会在需要的时候随时对其进行修改。

测项一:学生不存在

这是一个 Controller,其任务就是要「转化」前端来的申请单,找到合适的 service 并发请求,最後把结果(或错误)回给前端。

第一步,我们来写一个会 Fail 的测项,来确保待会儿绿灯时,这个 Controller「学生不存在」时的反应是如预期的,所以我们要跟 Dependency Injection 的 Service 串通好,待会有人来申请奖学金时,要丢出指定 Exception。

@Test
void student_NOT_exists() throws Exception {

    ApplicationForm applicationForm = new ApplicationForm(
            9527L,
            55688L
    );

    ApplyScholarshipService applyScholarshipService = Mockito.mock(ApplyScholarshipService.class);
    Mockito.doThrow(new StudentNotExistException("ANY_MESSAGE"))
            .when(applyScholarshipService)
            .apply(applicationForm);

    MockHttpServletRequestBuilder request = MockMvcRequestBuilders
            .post("/scholarship/apply")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(applicationForm));

    mockMvc.perform(request)
                .andExpect(status().is(400))
                .andExpect(content().json(objectMapper.writeValueAsString(ApiResponse.bad(987))));



}

我知道很丑。丑没关系,我们待会会重构。现在先跑一下,确保它会 Fail:

好,没问题,我们什麽 code 都没写,连路径都还没指定,会 404 很正常。这就代表这个测项有测到东西。

喔对了,这个测项还有另一个很重要的任务:「定路径」,这个测项一旦通过,这个功能在系统中的 url 也就定了,所以,第一个测项还蛮责任重大的,不建议同时再背负其他太复杂的逻辑。

写程序,亮绿灯

那就来写程序吧!这里我不会像 Kent Beck 在 Test Driven Development 书中那样一步跨那麽小,因为篇幅有限,我会直接把逻辑写出来。在其实工作中,一步要多大,各位可以自己决定,舒服就好。

@PostMapping("/scholarship/apply")
public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {

    return ResponseEntity.status(400).body(ApiResponse.bad(987));

}

各位可以看到我不管三七二十一,见面就丢 400 出去,还附一个先前介绍过,我们与前端讲好的 error code:987。这是故意的,因为我就是要少做一点事,等新测项把真正的判断逻辑「逼」出来,我才要来做,才不会浪费时间。

重构

是的,测试太丑了。来重构吧!我先用 Extract Method 的方法,把操作细节隐藏起来,暴露抽象意图:

    @Test
    void student_NOT_exists() throws Exception {

        assume_student_not_exist(9527L);

        mockMvc.perform(request(
                        "/scholarship/apply"
                        , application_form(9527L, 55688L)))
                .andExpect(status().is(400))
                .andExpect(content().json(bad_response_content(987)));

    }

各位不妨假装自己不懂程序,就光是念出这个测项中的「英文单字」,应该就能推敲出这个测项在测什麽场景。如果可以,代表我们这个重构效果不错。

测项二:奖学金不存在

如果学生存在,但对方要申请的「奖学金」根本不存在,那我们也肯定能使这个申请成立。一样,我们由测试开始。有了重构过的第一个测试,我们期待第二个测试会好写一点:

@Test
void scholarship_NOT_exists() throws Exception {

    Mockito.doThrow(new ScholarshipNotExistException("ANY_MESSAGE"))
            .when(applyScholarshipService)
            .apply(application_form(9527L, 55688L));

    mockMvc.perform(request(
                    "/scholarship/apply"
                    , application_form(9527L, 55688L)))
            .andExpect(status().is(400))
            .andExpect(content().json(bad_response_content(369)));// 369: scholar not exists

}

运行结果应该要是错的,而且错得跟我们预期的一样,想要 369,结果却得到 987:

这个测项,我们做的事很少,只是 copy-paste 一下前一个测项,确保我们有测到东西。其实可以预期以後应该也不会多太多,这就是时常小幅重构的好处。

总之,现在我们可以来写程序了。

写程序,亮绿灯

此时,刚刚故意不写,拿来判断 Exception 种类的逻辑就被新的测项给「逼」出来了:

@PostMapping("/scholarship/apply")
public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {
    try {
        applyScholarshipService.apply(applicationForm);
    } catch (StudentNotExistException e) {
        return ResponseEntity.status(400).body(ApiResponse.bad(987));
    } catch (ScholarshipNotExistException e) {
        return ResponseEntity.status(400).body(ApiResponse.bad(369));
    }
    
    return null;
}

这里我们抓了两种 Exception,按照与前端的协议,一种给 369,一种给 987,这下不管是哪一个资料有误的 case,未来一旦被改错,我们也不怕了。

至於方法最後的 return null,因为我们还没测到那,就先留着吧!

重构

一样,测试太丑了,同一个方法的两行逻辑的「抽象程度」不同。这在 Uncle Bob 的 Clean Code 有特别提到,是会干扰阅读的。我们不喜欢,所以改掉它:

@Test
void scholarship_NOT_exists() throws Exception {

    assume_scholarship_not_exists(55688L);

    mockMvc.perform(request(
                    "/scholarship/apply"
                    , application_form(9527L, 55688L)))
            .andExpect(status().is(400))
            .andExpect(content().json(bad_response_content(369)));// 369: scholar not exists

}

测项三:资料存取错误

有时候我们会遇到资料库一些不可抗力的因素,譬如资料库突然故障,机房网路瞬断等等,这时也要给前端一个适当的通知,让它知道是後端出了问题,不是使用者的问题。这会有助於前端显示正确的讯息,或适度的隐藏。

先写一个测试描述场景:

@Test
void data_access_error() throws Exception {

    Mockito.doThrow(new DataAccessErrorException("ANY_MESSAGE"))
            .when(applyScholarshipService)
            .apply(application_form(9527L, 55688L));

    mockMvc.perform(request(
                    "/scholarship/apply"
                    , application_form(9527L, 55688L)))
            .andExpect(status().is(500))
            .andExpect(content().json(bad_response_content(666)));// 666: data access error

}

跑测试,确定会错,而且错在我们预期的地方:

写程序,亮绿灯

看来,我们越来越熟练了呢!那就直接加逻辑吧:

    @PostMapping("/scholarship/apply")
    public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {
        try {
            applyScholarshipService.apply(applicationForm);
        } catch (StudentNotExistException e) {
            return ResponseEntity.status(400).body(ApiResponse.bad(987));
        } catch (ScholarshipNotExistException e) {
            return ResponseEntity.status(400).body(ApiResponse.bad(369));
        } catch (DataAccessErrorException e) {
            return ResponseEntity.status(500).body(ApiResponse.bad(666));
        }
        return null;
    }

有没有发现,我们现在在做的事越来越小步?因为结构已经慢慢稳定下来,现在只要不加太大新功能,这个 Controller 差不多就长这样了。

重构

重构测试,让测试更具表现力:

@Test
void data_access_error() throws Exception {

    assume_data_access_would_fail(9527L, 55688L);

    mockMvc.perform(request(
                    "/scholarship/apply"
                    , application_form(9527L, 55688L)))
            .andExpect(status().is(500))
            .andExpect(content().json(bad_response_content(666)));// 666: data access error

}

再重构

在进入下一步前,我想整理另一块地方。我们刚刚建了三个 Exception,这使得 service 的签名变得非常长:

public void apply(ApplicationForm applicationForm) throws StudentNotExistException, ScholarshipNotExistException, DataAccessErrorException {
    // To be implemented...
}

我们不喜欢太长的签名,於是想来重构一下,让他简单一点。在 Teddy Chen 的「例外处理的逆袭」书中,有教我们一些「重构例外」的方法,各位有兴趣可以看看,这里,我们试着用其中一个手段来整理。

这里我们发现,「学生不存在」与「奖学金不存在」都是「用户的问题」,而目前为止对此种错误的处理都大同小异,只有回覆的代码不同,所以我想要来把这两个错误合并,并以「错误代码」来细分就好:

  @PostMapping("/scholarship/apply")
    public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {
        try {
            applyScholarshipService.apply(applicationForm);
        } catch (ClientSideErrorException e) {
            return ResponseEntity.status(400).body(ApiResponse.bad(e.getCode()));
        } catch (DataAccessErrorException e) {
            return ResponseEntity.status(500).body(ApiResponse.bad(666));
        }
        return null;
    

这里我们可以看到,两个跟「使用者出错」有关的 Exception 已经合并,service 的签名也可以缩短,而前端照样可以由 code 来区分错误的细节。至於 server 端的错误,目前还没有长太大,所以就暂时这样了,等未来真有困扰再说也行。

测项四:未知错误

天有不测风云,写程序哪有永远对的。在运行途中,service 难保不会出什麽预料之外的乱子。而因为我们这里对已知可能发生的问题都已用了 Checked Exception 来接,还可能发生问题的话,肯定只剩代表 bug 的 Runtime Exception 了。

我们来写个测项吧:

@Test
void unknown_error() throws Exception {

    given_some_bug_exists(9527L, 55688L);

    mockMvc.perform(request(
                    "/scholarship/apply"
                    , application_form(9527L, 55688L)))
            .andExpect(status().is(500))
            .andExpect(content().json(bad_response_content(999)));

}

    

来跑看看:

果真错了。

这里我刻意一下子就「又写又抽方法」,我只是想表现:各位在进行时,可以自己决定自己步伐要多大,不一定要照别人或我的方式,只要你自己觉得舒服自在,且能短时间内频繁绿灯就好。

写程序,亮绿灯

来吧,我们来让我们的测试也亮绿灯:

@PostMapping("/scholarship/apply")
public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {
    try {
        applyScholarshipService.apply(applicationForm);
    } catch (ClientSideErrorException e) {
        return ResponseEntity.status(400).body(ApiResponse.bad(e.getCode()));
    } catch (DataAccessErrorException e) {
        return ResponseEntity.status(500).body(ApiResponse.bad(666));
    } catch (Exception e) {
        return ResponseEntity.status(500).body(ApiResponse.bad(999));
    }
    return null;
}

重构

看看,好像没什麽特别糟糕的味道,那就跳过不重构吧!

测项五:完全正确 => 测试,程序,重构

终於,来到最後一个测项了。这时,要的做事与前面大同小异,我想应该可以不用细谈中间过程了吧!

总之,当所有事都正确,理应回传 200 给前端,而且内容可以是空的:

@Test
void all_ok() throws Exception {

    mockMvc.perform(request(
                    "/scholarship/apply"
                    , application_form(9527L, 55688L)))
            .andExpect(status().is(200))
            .andExpect(content().json(objectMapper.writeValueAsString(ApiResponse.empty())));

}


此时程序也完成:

@PostMapping("/scholarship/apply")
public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {
    try {
        applyScholarshipService.apply(applicationForm);
    } catch (ClientSideErrorException e) {
        return ResponseEntity.status(400).body(ApiResponse.bad(e.getCode()));
    } catch (DataAccessErrorException e) {
        return ResponseEntity.status(500).body(ApiResponse.bad(666));
    } catch (Exception e) {
        return ResponseEntity.status(500).body(ApiResponse.bad(999));
    }

    return ResponseEntity.status(200).body(ApiResponse.empty());

}

跑一下测试,全绿灯,且测试时间非常短:

非常划算,可以多跑几次 XD

这时可以再检查一下,如果有什麽看不顺眼的就重构,没有就大功告成罗!

我个人是对现在「Service 会知道与 Client 沟通的 error code」这件事比较感冒啦,如果是平常工作时,我会再针对这一点修改一下,不过,这还是看你自己习惯。正如我一再强调的:「没有标准答案,你舒服就好。」

结论

我们花了不短的篇幅,演示了怎麽用 TDD 的方式,一步步建构後端 API 服务的门面:Controller。

Controller 位於 Clean Architecture 的 Interface Adapter 层,直接与最外层的 Framework 与 Internet 相接。如果我们要把这一层写得非常乾净,与 Framework 完全隔绝,那会非常麻烦,付出的成本也许会超出收益。

再者,现在的 Framework 功能都很强大,例如我们专案中使用的 Spring Boot,妥善地利用,它可以为我们解决很多与外面世界沟通的事情,譬如 url 路径与 Http 方法的 Mapping。但於此同时,测试的独立性也就被牺性了,因为这会使程序很难与框架分开测试。

幸好,也正因为现在的框架都很厉害,它们大多都提供了很方便的测试套件,因此,在 Controller 的测试,我们可以直接使用框架提供的套件来帮助我们测试,本篇中的测试就属於此类。

方便归方便,再往内的 Service 层就不建议如此了。越是核心的元件,就越要远离框架,才能保持我们的核心逻辑的自由度,不会被「细节」给绑架了。毕竟,解决商业上问题的是「核心逻辑」,不是框架等细节。

谜之声:「框架是细节,网路是细节,Web 是细节,Dababase 也是细节。」

Reference

  1. GitHub Repository:https://github.com/bearhsu2/ithelp2021.git
  2. Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship. Upper Saddle River, NJ: Prentice Hall, 2009.
  3. Kent Beck, Test Driven Development : By Example, Addison-Wesley Signature Series, 2002
tags: ithelp2021

<<:  Data layer implementation (2)

>>:  08. Laravel Sail x Xdebug x Coverage

17.unity显示/隐藏物件(SetActive)

想要制作一个假背包,利用按钮显示背包,再按下按钮关闭背包。 要使用GameObject.SetAct...

[Day 30]30天挑战成功!同场加码Azure Machine Learning!

今天是最後一天啦!但学习的路还尚未中断!笔者今天想聊聊不一样的,看到标题应该可以猜到,我们今天要来聊...

Arduino 扩充版 W5100 - EEPROM 烧录

Arduino W5100 是一块含有网路及EEPROM功能的扩充版. 笔者在之前的文章中曾提过可以...

Day-14 Pytorch 的 Gradient 计算

之前我们看过用 Python 计算 Gradient 必须要手动计算偏微分之後,才有办法算出 那如...

Day 19 CSS <icon font 字体图标>

1. 字体图标使用场景 主要用於显示网页中通用或常用的一些小图标 因为精灵图有许多优点 但缺点也很明...