笔者前阵子蛮喜欢路跑的,但跑了很久,成绩却一直没有明显进步,为此感到因扰。後来有一天,一位朋友跟我说,我的步频太慢,导致跑步过程无意识做了太多不必要的动作,因此一跑长,表现就会下降。後来我上网找了一个 180 bpm 的歌单,练了几次後发现原本脚步笨重的问题不见了,速度也有所提升,後来参加几次,成绩真的有明显进步,真的太神奇了!
180 bpm
在做完 Controller 後,Service 应该有的接口样貌应该已有了很大的确定性。在 Clean Architecture 的分层里, Service 所在的 Use Case 层,就是为了 Controller 层而存在的。试想,你提供的接口,对方肯定是用得很顺利,就算有什麽不好用的接口,也早就改掉了不是吗?不然,Controller 怎麽做完它的工作的?
於是,Service 接下来要做的事,就只剩下:「想办法依 Controller 给的资料,完成自己该的的事,并在需要时通知 Controller。」
一开始,就像做 Controller 时一样,我们也来照需求分析 这个「申请奖学金」的 Service 工作。在 Clean Architecture 的规画中,Service 的工作就是个「自动化流程」的执行者,这个 ApplyScholarshipService 的流程就是:「控管流程,根据申请书的资料,向档案管理员索取资料,依规定审核後填写正式记录。」
为了列出测项,我们可以先条列出 Service 要做的事依序有哪些:
於是乎,测项就可以条列如下:
测项 | 行为 |
---|---|
找不到学生资料 | Exception 987 |
Repository 取得学生资料时发生错误 | Exception 666 |
找不到奖学金资料 | Exception 369 |
Repository 取得奖学金资料时发生错误 | Exception 666 |
资格不符 | Exception 375 |
Repository 储存资料时发生错误 | Exception 666 |
成功 | void |
同样地,在这里列的测项只是个计画,不是个严格的规定,等会做到一半,发现有需要时,还是可以调整。
有了上一篇写 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 中找到细节。
有时候取资料有误不是因为资料不存在,而是资料来源有问题,或是系统与资料的连线有问题。无论如何,我们都得处理。这里的处理,采用「拦下来转抛」的策略。先写测试:
@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);
}
好,我想我们找到一些 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 这位「承办专员」要做的事还有:
不过,碍於篇幅,我们先到这边,下回再来继续完成後面的事情。
谜之声:「Contoller 好聊难测,Service 好测难聊。」
ithelp2021
<<: 追求JS小姊姊系列 Day9 -- 如果时间能重来,我不想跟工具人聊天(上)
>>: #8-选单华丽开起来!超简单!(animation-delay)
如果欢迎讯息写死在程序里,临时想换还要把程序打开来改,改完还要测试,不如就直接让它能在群组里设定吧...
前言 取得资料後,也大概分析了差不多,就可以着手进入讯号灯的世界。 什麽是讯号灯? 讯号灯可以当作红...
想法来源 在过去,我们团队中的人使用的CI/CD 设定档都是在每个专案中各自写一份,而当有需求要调整...
来实作一把主角技能写入快捷键吧 首先一样先改code ActionBattle_Actor的init...
前言 大家好,这是我第一次参加铁人赛 其实我一直都有想把自己会东西记录下来分享给大家 今年刚好参加完...