筆者前陣子蠻喜歡路跑的,但跑了很久,成績卻一直沒有明顯進步,為此感到因擾。後來有一天,一位朋友跟我說,我的步頻太慢,導致跑步過程無意識做了太多不必要的動作,因此一跑長,表現就會下降。後來我上網找了一個 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