Day 25 「行礼如仪?行将就木?」Service 与单元测试(下)

笔者写作年资不算长,但写到後来,还是多多少少能在动笔之前,感受一些主题的容易度,譬如理论的主题,对我来说比较好写,跟程序比较相关的主题就比较没那麽简单。倒也不是说逻辑很难,而是在已经用程序表达了一次意图後,又要用文字再阐述一次,而且不能只是把程序翻成中文重讲一遍,是要延伸一些深入的论述,这件事其实(至少对我来说)没那麽容易。

但没关系,写程序跟写作一样,本来就是要不断练习,要能够「行礼如仪」地写出足够量的文章,又不能只是不断复制贴上自己的文章(或程序码),不然就变成「行将就木」了。

日本作家桦泽紫苑的「AZ原则」,要我们以输出为目的输入,以达到更好的学习成效,我想就是支持我一直写作的动力之一吧!

我们来继续 Service 未完成的工作吧。

在取得 Student 与 Scholarship 的资料以後,这位「奖学金申请」专员,接下来要做的事,就是要真正去验证此申请是否合乎规定,如果是,那就要为申请者填写一份正式的申请书,并回头请「档案管理员」把这份申请书保存起来,工作才算完成。

我们来看看剩下的测项有哪些:

测项 行为
超过申请时间 Exception 374
资格不符 Exception 375
Repository 储存资料时发生错误 Exception 666

会长大的待办清单

等等,「超过申请时间」何时蹦出来的?一开始列测项时没说有这回事呀!难不成这待办清单还会长大?

是的。它本来就会长大。

在列测项时,我们的确是照着我们当时的最佳理解来进行的,但是人对同一件事的理解,随着你的接触越多,是会越来越完整的,这点 Kent Beck 在 TDD 一书也有提到。当我们对这个功能的需求了解多了,自然会知道一些原本没有料到的问题。而测试代表需求,当需求成长,那测项也会长大,这是自然而然的事。

所以,此为自然现象,请安心服用。

测项六:超过申请时间

我们来看看,如果超过申请时间,会发生什麽事情。首先先用测试来描述场景与需求:

@Test
void when_overtime_then_374() throws RepositoryAccessDataFailException {

    given_student_exists(12345L);
    given_scholarship_exists(98765L, scholarship(2021, 7, 31));
    given_today_is(2021, 8, 1);

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

    then_client_side_error_code_is(374);

}

这个测项也很简单,学生与奖学金都存在,奖学金的 deadline 是 7/31,但今天已经是 8/1 了,於是预期 service 会用 374 这个 error code 来表示「申请逾期」。

各位可以看到我这里步伐跨很大,直接把 Composed Method 写出来了,而不是用重构的。我故意的,我不是任性,我只是想要不厌其烦地再强调一次:「步伐要小一点是通则,但实际要多小,没有严格规定,你自己多练习,自己觉得舒服就好。」

写完测试来写程序吧:

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

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

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

        // 查验是否符合资格
        LocalDate deadline = scholarship.getDeadline();
        LocalDate now = LocalDate.now();
        if (now.isAfter(deadline)) {
            throw new ClientSideErrorException("application over time", 374);
        }

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

    }

来重构吧。这里的程序有抽象程度不对等的问题,操作细节与商业逻辑被摆在一起了。这个可以靠 Extract Method 的方法解决:

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

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

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

    // 查验是否符合资格
    checkDeadline(scholarship);

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

}

又回到「Composed Method」的样貌了。

暂停,移除重复功能

可能已经有人发现了,这里的 CheckDeadline,与先前的某一段示范 Mock Static 的程序码很像。是的,因为我用了同一个场景来举例,但你应该也同时发现了,这两个例子的实作方法并不相同。其实,在真实工作上,也常有机会遇到这种情形。

遇到类似情形,有人会选择实施「复制贴上大法」,这没什麽不好,但别忘了,重复乃万恶之渊薮,一旦测试通过了,还是要赶快重构来消重复才行。尤其这里根本不是类似,而是同一场景,其实更是要重复利用程序码才行,不过这边主要是为了要示范,所以笔者就故意两边都留下,方便读者对照。

真实生活遇到了的话,我是会建议各位做完新功能後,直接移除旧功能,或是抽共用,因为重复了,而我们不喜欢重复。

测项七:资格不符

逻辑再往下走,我们来看看资格不符时,会发生什麽事。我们假设这个奖学金的规定是「只有博士班」能申请,如此一来,大学生跑来申请肯定不行吧!我们来看看怎麽处理这样的逻辑。

一样先写测试:

private static final LocalDate july31 = LocalDate.of(2021, 7, 31);
  
@Test
void when_disqualified_then_375() throws RepositoryAccessDataFailException {

    Mockito.when(studentRepository.find(12345L))
            .thenReturn(Optional.of(new Student("Michael", "Jordan", "Bachelor")));

    given_scholarship_exists(98765L, scholarship(july31));

    given_today_is(july31);

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

    then_client_side_error_code_is(375);

}

这里我们假设今天是 7/31,那申请截止日同为 7/31 的奖学金理应没问题,但我们假设这个奖学金只有「博士生」能申请,那麽很明显就资格不符了。依照我们先前讲好的规范,资格不符是 Client 端的问题,并且要提示 375 的 error code。

来写个 code 使测试通过吧:

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

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

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

    // 查验是否符合资格
    checkDeadline(scholarship);

    // 查验是否符合资格
    if (!student.getDegree().equals("PhD")) {
        throw new ClientSideErrorException("this scholarship is for PhD students only", 375);
    }
    // 填写正式申请书
    // 存档

}

重构测试与程序。我们一样不想曝露太多细节:

@Test
void when_disqualified_then_375() throws RepositoryAccessDataFailException {

    given_student_exists(12345L);

    given_scholarship_exists(98765L, scholarship(july31));

    given_today_is(july31);

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

    then_client_side_error_code_is(375);

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

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

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

    // 查验是否符合资格
    checkDeadline(scholarship);

    // 查验是否符合资格
    checkProgramIsPhD(student);
    // 填写正式申请书
    // 存档

}

这里其实有个蛮明显的坏味道:Feature Envy。我们晚点会重构掉。为什麽不现在做?因为这会跟下一篇要讲的 Entity 有关,於是这里就先不动了。

测项八:写入申请单失败

Service 的最後一项工作,就是写一份正式的申请书,并且请档案管理员代为归档。这时如果档案管理员处理有误,Service 的责任就是转包为 Controller 看得懂的错误,并且往外发出去。先写测试:

@Test
void when_DB_fail_on_writing_application_to_DB_then_666() throws RepositoryAccessDataFailException {


    given_student_exists(12345L, "PhD");

    given_scholarship_exists(98765L, scholarship());

    given_today_is(july31);

    Mockito.doThrow(new RepositoryAccessDataFailException())
            .when(applicationRepository).create(any(Application.class));

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

    then_server_side_error_code_should_be(666);

}

程序:

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

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

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

        // 查验是否符合资格
        checkDeadline(scholarship);

        // 查验是否符合资格
        checkProgramIsPhD(student);
        // 填写正式申请书
        Application application = applicationForm.toApplication();


        // 存档
        try {
            this.applicationRepository.create(application);
        } catch (RepositoryAccessDataFailException e) {
            throw new ServerSideErrorException("failed to create application", 666);
        }

    }

很好,我们越来越熟练了。接着重构程序:

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

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

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

    // 查验是否符合资格
    checkDeadline(scholarship);

    // 查验是否符合资格
    checkProgramIsPhD(student);

    // 填写正式申请书
    Application application = applicationForm.toApplication();

    // 存档
    createApplication(application);

}

重构测试:

@Test
void when_DB_fail_on_writing_application_to_DB_then_666() throws RepositoryAccessDataFailException {

    given_student_exists(12345L, "PhD");

    given_scholarship_exists(98765L, scholarship());

    given_today_is(july31);

    assume_DB_would_fail_on_creating_application_data();

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

    then_server_side_error_code_should_be(666);

}


图片截自网路

...等等!思考题来了!

这里我们发现了一个新的问题:当此人重复申请的时候,应该怎麽处理?我们一开始的确是没想到,不过既然现在发现了,那还是得处理。

当然,我们可以先问 Repository 此申请是否已经存在,但是,在这个微服务当道的年代,可能同时有几百台机器也在线服务着,前一刻询问时还不存在的资料,有可能几百毫秒後要写入时就被另一台机器抢先写入了。

这个问题其实不难,也不容易,因为能影响决策的因素太多了,譬如储存装置是否是 RDB、是本机去存还是委托其他机器存、资料的设计本身是否就有可供分辨的栏位、要 Consistent 还是要 Eventually Consistent 就好…等等,所以,应该要综合考虑真实世界的完整 Context 才行。

这里我将会故意留一个思考题给各位,邀请各位来想想,在怎样的 Context 下,你会怎麽处理这个问题。

结论

我们用了两篇文章来描述在 Clean Architecture 中,位於 Use Case 层的 Service 如何进行它「自动化」的工作,并且我们使用了 TDD 的方式来一步步建构出完整逻辑,最後,我们也留了一个思考题,邀请读者思考一个与「实际 Context」有高度相关,但其实各位在工作中应该蛮常会遇到的问题。

至此,我们还剩下 Repository 与 Entity 还没完成。我们将在下一篇里接着讨论这两个角色的实作准则。

下回见!

Reference

  1. 桦泽紫苑,最高学以致用法:让学习发挥最大成果的输出大全,春天出版集团,2020
  2. Kent Beck, Test Driven Development : By Example, Addison-Wesley Signature Series, 2002
  3. 「搞笑谈软工」聊 Composed Method:http://teddy-chen-tw.blogspot.com/2012/05/implementation-patterns-composed-method.html
tags: ithelp2021

<<:  [D10] 影像杂讯与滤波(1)

>>:  Day 10:为你的 Hexo 增加页面:标签、分类与自订页面

Mobile Number Tracker Online

It is impossible to track a phone's exact location...

Day23 - 【概念篇】Keycloak使用基本概念 - 第一部分: Realm

本系列文之後也会置於个人网站 Realm,中文或许会翻作「域」,但基本很像是程序开发上,语言层面提...

Day 4 - 虚拟机的设置

Day 4 - 虚拟机的设置 今天我会讲Android Studio虚拟机的设置,那我们废话不多说,...

Dat27 Hook概观介绍

经过昨天对Hook有个初步的认识之後,下方介绍几个Hook的范例。 Hook 的规则 建议只在最上层...

[Python]使用Pillow,将图片由RGB转灰阶(Grayscale)

RGB -> Gray scale Gray scale(灰阶影像) from PIL imp...