Day 24「小步快跑」Service 与单元测试(上)

笔者前阵子蛮喜欢路跑的,但跑了很久,成绩却一直没有明显进步,为此感到因扰。後来有一天,一位朋友跟我说,我的步频太慢,导致跑步过程无意识做了太多不必要的动作,因此一跑长,表现就会下降。後来我上网找了一个 180 bpm 的歌单,练了几次後发现原本脚步笨重的问题不见了,速度也有所提升,後来参加几次,成绩真的有明显进步,真的太神奇了!


180 bpm

在做完 Controller 後,Service 应该有的接口样貌应该已有了很大的确定性。在 Clean Architecture 的分层里, Service 所在的 Use Case 层,就是为了 Controller 层而存在的。试想,你提供的接口,对方肯定是用得很顺利,就算有什麽不好用的接口,也早就改掉了不是吗?不然,Controller 怎麽做完它的工作的?

於是,Service 接下来要做的事,就只剩下:「想办法依 Controller 给的资料,完成自己该的的事,并在需要时通知 Controller。」

一开始,就像做 Controller 时一样,我们也来照需求分析 这个「申请奖学金」的 Service 工作。在 Clean Architecture 的规画中,Service 的工作就是个「自动化流程」的执行者,这个 ApplyScholarshipService 的流程就是:「控管流程,根据申请书的资料,向档案管理员索取资料,依规定审核後填写正式记录。」

为了列出测项,我们可以先条列出 Service 要做的事依序有哪些:

  1. 调阅学生资料
  2. 调阅奖学金规定的资料
  3. 查验是否符合资格
  4. 填写正式申请书
  5. 存档

於是乎,测项就可以条列如下:

测项 行为
找不到学生资料 Exception 987
Repository 取得学生资料时发生错误 Exception 666
找不到奖学金资料 Exception 369
Repository 取得奖学金资料时发生错误 Exception 666
资格不符 Exception 375
Repository 储存资料时发生错误 Exception 666
成功 void

同样地,在这里列的测项只是个计画,不是个严格的规定,等会做到一半,发现有需要时,还是可以调整。

测项一:完全 ok

有了上一篇写 Controller 的经验,我们这下写 Service 的单元测试应该是驾轻就熟。先来试写个测试,一样,丑没关系,先要有测到东西:

@Test
void all_ok() throws DataAccessErrorException, ClientSideErrorException {


    StudentRepository studentRepository = Mockito.mock(StudentRepository.class);

    ApplyScholarshipService applyScholarshipService
            = new ApplyScholarshipService(studentRepository);

    ApplicationForm applicationForm
            = new ApplicationForm(12345L, 98765L);

    applyScholarshipService.apply(applicationForm);

}
public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, DataAccessErrorException {

    // 调阅学生资料
    // 调阅奖学金规定的资料
    // 查验是否符合资格
    // 填写正式申请书
    // 存档

}

各位可以看到,这里程序啥都没做,测试就过了。意料中事,因为我们一开始就希望把这个方法设计成「没有出错,就不要通知我」的样子。

这里我觉得测试有点丑,但我想要再忍一下,我想等下一个测项做完再来重构,这样会比较知道哪些地方是重复的。

测项二:查学生时「找不到资料」

至此,各位有觉得奇怪吗?

为什麽这里要先做「全部 OK」的测项?

这的确是与 Controller 的实践方式不同。会这麽做的原因,除了为各位展示 TDD 的测项顺序你可以自己决定以外,也是想要演示一下「先从需要做少点事的测项开始」是什麽样子。

TDD 做多了以後,你会对测项的安排有个感觉,於是你会抓到属於自己的安排节奏,这不一定适合别人,但,老话一句,程序是你在写,只要你自己舒服就好。

现在我们要来进行第二个测项了。这里我稍微看了一下,先做其他哪个测项好像工作量都没差太多,於是我决定按时间顺序来。先加测试:

@Test
void when_student_not_exist_then_987() {

    StudentRepository studentRepository = Mockito.mock(StudentRepository.class);
    Mockito.when(studentRepository.find(12345L))
            .thenReturn(Optional.empty());


    ApplyScholarshipService applyScholarshipService
            = new ApplyScholarshipService(studentRepository);

    ApplicationForm applicationForm
            = new ApplicationForm(12345L, 98765L);

    ClientSideErrorException actualException = Assertions.assertThrows(ClientSideErrorException.class,
            () -> applyScholarshipService.apply(applicationForm));

    Assertions.assertEquals(987, actualException.getCode());

}
    

我们先假设学生 12345 并不存在,於是在拿学生资料时,Repository 给了我们一个「empty」。这里我们用了一个 Optional 的回传值,主要就是想表达,每一层的实作都应该把它的依赖隐藏起来,只透露出自己跟上层讲好的「行为」。同时被呼叫者的介面,应该要由呼叫者来决定,要以「对方」好用为优先。

Oh,这时我已经有点烦躁了,这个测试竟然有 20 行。为了早点重构它,我们赶快来写程序吧:

public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, DataAccessErrorException {

    // 调阅学生资料
    studentRepository.find(applicationForm.getStudentId())
            .orElseThrow(() -> new ClientSideErrorException("cannot find student", 987));

    // 调阅奖学金规定的资料
    // 查验是否符合资格
    // 填写正式申请书
    // 存档

}

这里用了 orElseThrow 的语法,使得 service 可以比较方便用比较少的程序码,达到「找不到学生时,丢 Exception」的目的。这也就是我们一直强调的,「让使用者决定介面」。

好了,我等不及了,我们快来重构吧。这两个测试「细节」太多了,我只想在测试的第一层看到「抽象业务逻辑」,好消息是我们现在有了足够多的重复程序码,方便我们决定该抽什麽出去。於是我决定同时重构这两个测试:


@Test
void all_ok() throws DataAccessErrorException, ClientSideErrorException {

    given_student_exists(12345L);

    when_apply_with_form_then_NO_error(application_form(12345L, 98765L));

}

@Test
void when_student_not_exist_then_987() {

    given_student_NOT_exists(12345L);

    when_apply_with_form_and_error_happens(application_form(12345L, 98765L));

    then_error_code_is(987);

}


抽出去的 private methods 碍於篇幅,就没有放上来了。读者可在文末附上的 GitHub Repository 中找到细节。

测项三:Repository 取得学生资料时发生错误

有时候取资料有误不是因为资料不存在,而是资料来源有问题,或是系统与资料的连线有问题。无论如何,我们都得处理。这里的处理,采用「拦下来转抛」的策略。先写测试:

    @Test
    void when_DB_fail_on_getting_student_then_666() throws RepositoryAccessDataFailException {

        studentRepository = Mockito.mock(StudentRepository.class);
        Mockito.when(studentRepository.find(12345L))
                .thenThrow(new RepositoryAccessDataFailException());

        applyScholarshipService = new ApplyScholarshipService(studentRepository);

        dataAccessErrorException = Assertions.assertThrows(DataAccessErrorException.class,
                () -> applyScholarshipService.apply(application_form(12345L, 98765L)));

        Assertions.assertEquals(666, dataAccessErrorException.getCode());

    }

很丑,没关系,我们很快会回来重构,现在先把红灯转绿再说:

    public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {

        // 调阅学生资料
        Student result;
        try {
            result = studentRepository.find(applicationForm.getStudentId())
                    .orElseThrow(() -> new ClientSideErrorException("cannot find student", 987));
        } catch (RepositoryAccessDataFailException e) {
            throw new ServerSideErrorException("failed to retrieve student data", 666);
        }
       

        // 查验是否符合资格
        // 填写正式申请书
        // 存档
      

    }

快来重构吧!这段丑死了!

首先这个 DataAccessErrorException 的名字我突然不喜欢了。它与另一个 ClientSideErrorException 的名字没有对比性,这会使 Controller 写不漂亮。同时,这个测试也不像其他测试一样只曝露抽象逻辑,这里我也想改掉:

@Test
void when_DB_fail_on_getting_student_then_666() throws RepositoryAccessDataFailException {

    assume_repository_would_fail_on_getting_student(12345L);

    when_apply_and_fail_on_server_side(application_form(12345L, 98765L));

    then_server_side_error_code_should_be(666);

}

呼,舒爽多了,可以继续了。

测项四:找不到奖学金资料

我们还需要奖学金的资料,才能进行下一步动作,这时如果奖学金不存在,那就麻烦了,这代表客户端送了不对的数值来,Controller 必须回报,而 Service 则负责丢出夹带足够资讯的 Exception。

首先,用测试描述此情况:

@Test
void when_scholarship_not_exist_then_369() {

    ScholarshipRepository scholarshipRepository = Mockito.mock(ScholarshipRepository.class);
    Mockito.when(scholarshipRepository.findOptional(98765L))
            .thenReturn(Optional.empty());

    when_apply_with_form_and_client_side_error_happens(application_form(12345L, 98765L));

    then_client_side_error_code_is(369);

}

我们去跟 Repository 要 98765 这个奖学金的资料,如果找不到,我们期待它回一个 empty 给我们,这样我们就可以用 Java 8 的 fluent API 来处理了,这里跟前一段 Student 的案例相仿。我们透过测试来确保这个介面会好用,而不是下一个工程师使用时的咒骂。

来写程序吧:

public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {

    // 调阅学生资料
    try {
        studentRepository.find(applicationForm.getStudentId())
                .orElseThrow(() -> new ClientSideErrorException("cannot find student", 987));
    } catch (RepositoryAccessDataFailException e) {
        throw new ServerSideErrorException("failed to retrieve student data", 666);
    }

    // 调阅奖学金规定的资料
    scholarshipRepository.findOptional(applicationForm.getScholarshipId())
            .orElseThrow(() -> new ClientSideErrorException("cannot find scholarship", 369));


    // 查验是否符合资格
    // 填写正式申请书
    // 存档

}

测试有个「抽象程度不同」的问题,重构之:

@Test
void when_scholarship_not_exist_then_369() throws RepositoryAccessDataFailException {

    given_student_exists(12345L);
    
    given_scholarship_NOT_exists(98765L);

    when_apply_with_form_and_client_side_error_happens(application_form(12345L, 98765L));

    then_client_side_error_code_is(369);

}

测项五:Repository 取得奖学金资料时发生错误

好,我想我们找到一些 pattern 了,这会使我们後面的事情「有迹可循」。但无论如何,我们还是一步一步来,先写测试吧:

    @Test
    void when_DB_fail_on_getting_scholarship_then_666() throws RepositoryAccessDataFailException {

        given_student_exists(12345L);

        Mockito.when(scholarshipRepository.findOptional(98765L))
                .thenThrow(new RepositoryAccessDataFailException());

        when_apply_and_fail_on_server_side(application_form(12345L, 98765L));

        then_server_side_error_code_should_be(666);

    }

可以看出,虽然找学生没问题,但找奖学金时却出了问题。此时我们应该要预期 Service 的行为是「拦下来转抛」,并给出 666 这个 error code,而且 Exception 必须是 Server Side Error 的类型,这样 Controller 才有足够讯息判断该做什麽事。

有了测试,就来写程序吧:

public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {

    // 调阅学生资料
    try {
        studentRepository.find(applicationForm.getStudentId())
                .orElseThrow(() -> new ClientSideErrorException("cannot find student", 987));
    } catch (RepositoryAccessDataFailException e) {
        throw new ServerSideErrorException("failed to retrieve student data", 666);
    }

    // 调阅奖学金规定的资料
    try {
        scholarshipRepository.findOptional(applicationForm.getScholarshipId())
                .orElseThrow(() -> new ClientSideErrorException("cannot find scholarship", 369));
    } catch (RepositoryAccessDataFailException e) {
        throw new ServerSideErrorException("failed to retrieve scholarship data", 666);
    }


    // 查验是否符合资格
    // 填写正式申请书
    // 存档

}

这里我们一样可以透过「抽取方法」,来使测试的抽象程度一致,像这样:

@Test
void when_DB_fail_on_getting_scholarship_then_666() throws RepositoryAccessDataFailException {

    given_student_exists(12345L);

    assume_DB_would_fail_on_getting_scholarship_data(98765L);

    when_apply_and_fail_on_server_side(application_form(12345L, 98765L));

    then_server_side_error_code_should_be(666);

}

除此之外,主程序也有些可以整理的地方。这里在同一个方法里有两个 try-catch clause,这一来使得方法一下子变很长,二来也使读者要一口气消化过多的「实作细节」,才能理解其代表的「抽象逻辑」。我们来透过抽取方法的手段,来代替读者消化这些细节吧:

public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {

    // 调阅学生资料
    Student student = findStudent(applicationForm);

    // 调阅奖学金规定的资料
    Scholarship scholarship = findScholarship(applicationForm);


    // 查验是否符合资格
    // 填写正式申请书
    // 存档
}

这样,细节就藏起来了,程序又回到「短短的」的样子了。

暂停,休息一下

眼尖的朋友可以发现,我还蛮常用函式来隐藏细节的。没错,这其实是模仿「重构与模式」书中,作者 Joshua Kerievsky 常用的一个模式:「复合函式(Composed Method)」。在一个方法中,如果我们希望把细节隐藏起来,而只表现出抽像逻辑,就可以考虑使用这个模式。

到目前为止,我们透过 Repository 把资料取出来了。而且感谢 Repository 的帮忙,Service 完全不需要管 Database 的样貌,它甚至不在乎资料是否是存在 Database 中。有了清楚的分层,根据 Uncle Bob 的说法,Service 可以更专心处理它的份内工作:自动化流程。

然而,我们事情还没做完。接下来 Service 这位「承办专员」要做的事还有:

  1. 验证申请资格
  2. 填写正式申请书
  3. 保存正式申请书

不过,碍於篇幅,我们先到这边,下回再来继续完成後面的事情。

谜之声:「Contoller 好聊难测,Service 好测难聊。」

Reference

  1. Joshua Kerievsky, Refactoring to Patterns, Addison-Wesley Professional, 2004
tags: ithelp2021

<<:  追求JS小姊姊系列 Day9 -- 如果时间能重来,我不想跟工具人聊天(上)

>>:  #8-选单华丽开起来!超简单!(animation-delay)

【PHP Telegram Bot】Day26 - 入群欢迎机器人(2):设定欢迎讯息

如果欢迎讯息写死在程序里,临时想换还要把程序打开来改,改完还要测试,不如就直接让它能在群组里设定吧...

【D16】制作讯号灯#1:讯号灯是什麽?

前言 取得资料後,也大概分析了差不多,就可以着手进入讯号灯的世界。 什麽是讯号灯? 讯号灯可以当作红...

[Day 16] Linter 管理 - 中央集权

想法来源 在过去,我们团队中的人使用的CI/CD 设定档都是在每个专案中各自写一份,而当有需求要调整...

[Day23] 实作 - 技能

来实作一把主角技能写入快捷键吧 首先一样先改code ActionBattle_Actor的init...

【PHP Telegram Bot】Day01 - 开赛

前言 大家好,这是我第一次参加铁人赛 其实我一直都有想把自己会东西记录下来分享给大家 今年刚好参加完...