聊完測試金字塔(Test Pyramid),我們把焦點拉回單元測試(Unit Test)本身。這篇會從如何控制測試的依賴開始,逐步帶到單元測試與「單一職責原則」(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;
}
}這個例子是「由下往上」設計:先準備底層物件,再往上實作邏輯。這跟筆者的習慣是相反的,但對單元測試初學者來說,重點是先養成「程式都要附上測試」的習慣,其他寫程式風格之後再慢慢調整。
從測項看依賴控制#
先處理最單純的「沒修課」情境,預期回傳 0:
@Test
void NO_courses() {
ScholarshipService service = new ScholarshipService();
int actual = service.calculate(new Transcript());
Assertions.assertEquals(0, actual);
}接著是達成全額獎學金的情境,依約應該得到 1 萬:
@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);
}半額與不符合資格的情境只要依樣畫葫蘆即可。透過控制 Transcript 這個依賴的內容,就能讓受測對象走進不同的邏輯分支,達到分別驗證的目的。
程式複雜度暴衝的時刻#
上述程式雖然 Code Smell 偏重,但還算可讀,測試也不難寫。問題是需求總會變動。
假設新需求把學生分為大學生、碩士生與博士生,三種身份的全額金額與資格判定方式都不同:
- 大學生:全額 10,000,至少 1/2 學科達 80 分;半額條件為至少 1/3 學科達 80 分。
- 碩士生:全額 15,000,依學分加權平均達 90 分;半額條件為加權平均達 80 分。
- 博士生:全額 40,000,全部學科達 90 分;半額條件為全部學科達 80 分。
光是邏輯分支就直接擴散。粗估一下需要的測項數:
- 學生身份分支:3(大學、碩士、博士)
- 獎學金資格分支:3(全額、半額、不符)
加上每種身份都可能「沒修課」,至少需要 3 × 3 + 3 = 12 個測項。如果繼續一股腦把邏輯往 calculate() 裡面塞,行數與測項數很快就會走向指數成長。
單一職責原則#
Uncle Bob 在 Clean Code 提到:函式應該只做一件事,把這件事做好,並且只能做這件事。書中還補充:「一個函式應該只能因為一個理由被修改。」
理由很現實:修改是 bug 的主要來源。當一個函式同時管很多事,它就有很多被修改的理由,被改壞的機率自然也升高。
而當函式很長、不容易看懂時,後來的工程師通常會選擇兩種「自保」做法:
- 多加一個 if,做防禦性程式設計
- 複製一份出來改,避免破壞既有邏輯
無論選哪一種,這段程式都會多承擔一件事,變得更胖、更難改、更難測,形成惡性循環。
獎學金範例承擔了哪些職責#
回頭看上面的 calculate(),它至少同時管了:
- 學生的身份
- 是否有修課
- 學生符合哪一級獎學金
- 每一級該發多少錢
四件事擠在一個函式裡,已經偏多。如果再追加「僑生」、「體保生」、「身心障礙」等變因,行數與測項數還會繼續指數膨脹。
重構需要測試先行#
不好測就會想偷懶,偷懶就會繼續複製貼上,程式只會更糟。打破這個循環的方法是「重構」,把職責拆開讓工作量回到線性成長。
但在動手重構之前,得先補上足夠的測試保護,才能放心改動。
重構的勇氣來自測試。先寫好測試,再考慮拆分職責;沒有測試保護的重構,只是在賭運氣。
這篇都還沒談到 TDD(測試驅動開發)。我們純粹是在替「已存在的程式」加上測試保護,為下一步的重構鋪路。
原文出處#
- 原書/iThome:https://ithelp.ithome.com.tw/articles/10260317