iT邦幫忙

2021 iThome 鐵人賽

DAY 5
5

聊完測試金字塔,讓我們回到單元測試。

在這篇中,我們會從單元測試的控制與撰寫開始,一路帶到單元測試與「單一職責原則(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% 是因為修改所致。當你一段函式要管非常多事情的時候,這段程式就很有可能因為某個需求的變動而被修改。原因很簡單:「因為他胖嘛~下雨淋到胖子很正常,因為他面積大呀!」

問題一段函式已經夠長了,我好不容易讀了半小時終於看懂他在幹嘛,並且找到修改點,這時你會做什麼事?要知道,一段程式不管誰寫的,只要你把它改壞,他馬上就變成你的了。這時為了自保,聰明如你,會做的事就不外乎兩個:

  1. 加個 if,防禦性編程
  2. 複製一份出來改,以免破壞原邏輯

問題來了,不管你決定要做這兩件事的哪一件,你都又讓這段程式多做了一件事,也就是說,他又管了更多事,變得更複雜了。是不是很有既視感?是啊!我們每天在面對的程式,就都是長這樣啊!這是個很明顯的惡性循環啊!

獎學金的範例,就管太多了...

有了職責的概念後,我們來看看剛剛這段程式管了哪些事?

  1. 學生的身份
  2. 學生有沒有修一門以上的課
  3. 學生符合哪種獎學金資格
  4. 每種資格該發多少錢

隨便列就四個責任,真的蠻多的。

如果這時,三種學生再各自加上「僑生」、「體保生」、「身心障礙」等變因,你要再多列多少測項?如果你繼續一股腦兒往 calculate() 函式加邏輯,繼續加 if 或複製貼上,你應該可以預期一個「指數成長」的程式行數與測項數。壞消息還不只這個。你知道需求是不會停止成長的,所以,一旦你放任你的程式充斥這種「太胖所以容易淋濕」的程式碼,你就會讀得很痛苦,改得很痛苦,並且測得很痛苦。

可是你又不笨,對吧?讀跟改,你就避不了,但測可以往外推啊!於是解決難測的辦法,就是趕快寫完趕快丟給 QA 測,萬一 QA 沒抓到 bug,就請用戶幫忙測嚕。那就回到我們第一天講的,全公司大家都有意無意地在「努力降低品質」的窘境了。

重構是個解方,但前提要有測試

程式管太多就不好測,不好測就要測很久,要測很久就很急,急就複製貼上,然後程式就管更多。你現在手上的專案就是這樣。如果想要打破這種惡性循環,那你就應該要在問題開始產生的初期,就把程式碼「重構」好,把權責區分好。結構好的程式,可以讓工作量呈現「線性成長」,而非「指數成長」。

怎麼重構,晚點我們有一些聊「重構」的章節,會回頭看看怎麼處理。但這裡先不急著討論,為什麼?因為如果你到時候真的很想重構,那此時此刻你應該做的事,是要先加上足夠的測試。有了測試的保護,你才有重構的勇氣,就不用向梁靜茹要了。


圖片來源:維基百科

請注意,這裡我們都沒有在講 TDD 喔!我們就純粹是要為一個「已經存在的程式」加上測試保護邏輯而已。

謎之聲:「胖不是病,但胖起來要人命。程式跟人都一樣。」

Reference

  1. Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship. Upper Saddle River, NJ: Prentice Hall, 2009.
  2. GitHub: https://github.com/bearhsu2/ithelp2021.git
tags: ithelp2021

上一篇
Day 04 「樹頭顧乎哉」測試金字塔 之 Unit Test v.s. Integration Test
下一篇
Day 06 「不聽話就換掉」測試與依賴:測資料 之 用 Mock 工具控制依賴
系列文
你就是都不寫測試才會沒時間:Kuma 的 30 天 Unit Test 手把手教學,從理論到實戰 (Java 篇)30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言