聊完測試金字塔,讓我們回到單元測試。
在這篇中,我們會從單元測試的控制與撰寫開始,一路帶到單元測試與「單一職責原則(Single Responsibility Principle)」的關係。
我們來看一段雖然有點違反單一職責原則,但暫時不算太嚴重的程式,體驗一下測試該怎麼加。
我們回到我們的教務處網站範例,教務處打算要發獎學金了,來申請獎學金的同學只要本學期「至少 1/2 的學科有達 80 分以上,就發給全額獎學金 1 萬元,如果只有 1/3 達成,就發半額的 5 千元。」
這樣的需求對各位讀者來說應該算簡單,我們可以馬上來做看看:
首先,想要算獎學金,必須要先有成績單,於是我們先準備好成績單與各科目成績如下:
@Data
@AllArgsConstructor
public class Course {
private String name;
private int score;
}
@Data
public class Transcript {
private List<Course> courses;
public Transcript(Course... courses) {
this.courses = Arrays.asList(courses);
}
}
有了成績單以後,我們就可以拿著成績單,直接把需求指定的邏輯給做出來了,別忘了考慮「本學期未修課」的案例:
public int calculate(Transcript transcript) {
List<Course> courses = transcript.getCourses();
if (courses.isEmpty()) return 0; // 不修課跟人家領什麼獎學金!
int total = courses.size();
int achieved = 0;
for (Course course : courses) {
if (course.getScore() >= 80) {
achieved++;
}
}
double rate = (double) achieved / total;
if (rate >= (double) 1 / 2) {
return 10_000;
} else if (rate >= (double) 1 / 3) {
return 5_000;
} else {
return 0;
}
}
這裡,讀者也許已經注意到了,這裡寫程式的順序是「由下往上」的設計,也就是先準備底層物件,再做上層邏輯。這其實跟筆者平常的習慣是反過來的,但這是為了表現其實不一定要由上往下,也不一定要先寫測試。至於「由上往下」有什麼不一樣,我們晚點講 TDD 的主題時,會再多做說明。而對單元測試的初學者來說,我們不要一次改變太多寫程式的習慣,一步一步慢慢來,首先先從「程式都要盡量帶測試」開始。
節錄自電影「讓子彈飛」
程式寫完就來測試吧。我們首先處理「沒有修課的人」,沒修課你跟人家領什麼獎學金?所以預期他應該要得到零元。
@Test
void NO_courses() {
ScholarshipService service = new ScholarshipService();
int actual = service.calculate(new Transcript(/*nothing*/));
Assertions.assertEquals(0, actual);
}
再來,如果這學生很厲害,真的有達成「全額獎學金」的目標,那我們就要依約給他一萬元:
@Test
void full_scholarship() {
ScholarshipService service = new ScholarshipService();
int actual = service.calculate(new Transcript(
new Course("Algorithm", 70),
new Course("Computer Internet", 80),
new Course("Operating System", 90)
));
Assertions.assertEquals(10_000, actual);
}
接著,我們再依樣畫葫蘆,把半額獎學金跟未達標的測項補上就好。完整測項請參閱 https://github.com/bearhsu2/ithelp2021.git
以上,我們就介紹了如何透過控制依賴,來控制受測對象的程式邏輯,以達到分別測試各個不同邏輯分支的效果。接下來我們多花一點篇幅來探討,當程式變得複雜,會對我們的測試造成什麼影響。
上述的程式在筆者工作場合,Code Review 是不會過的,Code Smell 太重了。但是在需求不會修改的情況下,我覺得還可以,是因為命名還算清楚,篇幅也還算小,閱讀上沒什麼問題,測試也不難加。然而,需求哪有不會修改的呢?
有一天修改的需求來了。我們必須把學生分為「大學生」、「碩士生」,與「博士生」三種,全額獎學金的金額與資格判定方式都不同:
大學生 | 碩士生 | 博士生 | |
---|---|---|---|
全額獎學金 | 10,000 | 15,000 | 40,000 |
全額條件 | 至少 1/2 的學科有達 80 分以上 | 依學分加權平均後達 90 分以上 | 全部學科達 90 分以上 |
半額條件 | 至少 1/3 的學科有達 80 分以上 | 依學分加權平均後達 80 分以上 | 全部學科達 80 分以上 |
如何?是否困難度一下子就飆升了?先不說程式了,我們光來數看看測項數吧!要達到可信賴的測試保護力,至少一個邏輯分支要有一個測項保護,這不為過吧?而邏輯分支主要取決於變因。我們來看看這樣的需求,需要幾個測項:
變數 | 分支數 | 說明 |
---|---|---|
學生種類 | 3 | 大學、碩士、博士 |
獎學金資格 | 3 | 全額、半額、資格不符 |
上述可能發生的情況,再加上三種學生都有可能「沒修課」,至少你就要寫 3 x 3 + 3 = 12 個測項!
礙於篇幅,這裡我就不附上完整程式碼了,文末的 GitHub 連結中有完整範例,其中程式就將近 100 行,測試更是飆升到超過 200 行!讀者可以下載來看看,體會一下「老闆的簡單兩句話,我們要多做多少事」...
「到底為什麼這麼麻煩啊?我到底做錯了什麼事啊?」
Uncle Bob 在 Clean Code 談到函式時表示:「函式應該要做一件事。它應該要把這件事做好,並且只能做這件事。」書中也另外補充說明道:「一個函式應該要只能因為一個理由而被修改。」
為什麼?我們知道,「修改乃是 bug 發生的源頭」,線上運行得好好的程式,會突然出 bug,有 87% 是因為修改所致。當你一段函式要管非常多事情的時候,這段程式就很有可能因為某個需求的變動而被修改。原因很簡單:「因為他胖嘛~下雨淋到胖子很正常,因為他面積大呀!」
問題一段函式已經夠長了,我好不容易讀了半小時終於看懂他在幹嘛,並且找到修改點,這時你會做什麼事?要知道,一段程式不管誰寫的,只要你把它改壞,他馬上就變成你的了。這時為了自保,聰明如你,會做的事就不外乎兩個:
問題來了,不管你決定要做這兩件事的哪一件,你都又讓這段程式多做了一件事,也就是說,他又管了更多事,變得更複雜了。是不是很有既視感?是啊!我們每天在面對的程式,就都是長這樣啊!這是個很明顯的惡性循環啊!
有了職責的概念後,我們來看看剛剛這段程式管了哪些事?
隨便列就四個責任,真的蠻多的。
如果這時,三種學生再各自加上「僑生」、「體保生」、「身心障礙」等變因,你要再多列多少測項?如果你繼續一股腦兒往 calculate() 函式加邏輯,繼續加 if 或複製貼上,你應該可以預期一個「指數成長」的程式行數與測項數。壞消息還不只這個。你知道需求是不會停止成長的,所以,一旦你放任你的程式充斥這種「太胖所以容易淋濕」的程式碼,你就會讀得很痛苦,改得很痛苦,並且測得很痛苦。
可是你又不笨,對吧?讀跟改,你就避不了,但測可以往外推啊!於是解決難測的辦法,就是趕快寫完趕快丟給 QA 測,萬一 QA 沒抓到 bug,就請用戶幫忙測嚕。那就回到我們第一天講的,全公司大家都有意無意地在「努力降低品質」的窘境了。
程式管太多就不好測,不好測就要測很久,要測很久就很急,急就複製貼上,然後程式就管更多。你現在手上的專案就是這樣。如果想要打破這種惡性循環,那你就應該要在問題開始產生的初期,就把程式碼「重構」好,把權責區分好。結構好的程式,可以讓工作量呈現「線性成長」,而非「指數成長」。
怎麼重構,晚點我們有一些聊「重構」的章節,會回頭看看怎麼處理。但這裡先不急著討論,為什麼?因為如果你到時候真的很想重構,那此時此刻你應該做的事,是要先加上足夠的測試。有了測試的保護,你才有重構的勇氣,就不用向梁靜茹要了。
圖片來源:維基百科
請注意,這裡我們都沒有在講 TDD 喔!我們就純粹是要為一個「已經存在的程式」加上測試保護邏輯而已。
謎之聲:「胖不是病,但胖起來要人命。程式跟人都一樣。」
ithelp2021