編寫難於測試的程式碼的5種方式

2016-07-04    分類:其他、程式設計開發、首頁精華0人評論發表於2016-07-04

本文由碼農網 – 孫騰浩原創翻譯,轉載請看清文末的轉載要求,歡迎參與我們的付費投稿計劃

有一次,我在一個講座上聽到主持人問聽眾如何故意編寫難於測試的程式碼。在場的小夥伴都驚呆了,因為沒有任何人會故意寫這種糟糕的程式碼。我記得他們甚至給不出一個好的答案。

當然,這個問題的目的不在於教大家如何寫使同事欲哭無淚的爛程式碼。而是為了瞭解什麼樣的程式碼難於測試,來避免這些嚴重的問題。

這裡給出我對上面那個問題的答案(當然這只是我的個人觀點,每個人討厭的都不盡相同。)

1.用大量的靜態欄位

尤其是在不同類中共享靜態的集合類,比如下面這個:

public class Catalog {
    private static List<Person> people = new ArrayList<>();
    public void addPerson(Person person) {
        if (Calendar.getInstance().get(Calendar.YEAR) - person.getYearOfBirth() < 18) {
            throw new IllegalArgumentException("Only adults admitted.");
        }
        people.add(person);
    }
    public int getNrOfPeople() {
        return people.size();
    }
}

現在我們來看看測試程式碼:

public class CatalogTest {
    private final Catalog underTest = new Catalog();
    @Test(expected=IllegalArgumentException.class)
    public void addingAMinorShouldThrowException() {
        assertEquals(0, underTest.getNrOfPeople()); // precondition
        Person p = new Person(2015);
        underTest.addPerson(p);
    }
    @Test
    public void addingAnAdultShouldSucceed() {
        assertEquals(0, underTest.getNrOfPeople()); // precondition, or is it?
        Person p = new Person(1985);
        underTest.addPerson(p);
        assertEquals(1, underTest.getNrOfPeople());
    }
}

如果你執行這個兩個測試,你會發現期待丟擲異常的那個用例失敗了。這有些讓你懷疑人生了,但是JUnit可以自由安排用例執行順序而不依賴於編寫用例的順序。在這段程式碼中第二個測試用例先執行,它檢測集合是空的,然後成功註冊了一個adult。由於我們的集合是靜態的,第一個測試用例檢測到集合不是空的(我們在之前的測試用例已經放進去一個people了),所以失敗了。

一旦我們刪掉static關鍵字,兩個測試用例都成功執行。在每個測試用例執行前,JUnit會將測試用例中的欄位初始化(不僅僅是@Before註解方法中的欄位)。現在我們有一個例項成員,而不是一個繫結在類上的靜態people列表。這意味著每個測試用例執行前都會建立一個新的Catalog物件,包含一個新的列表。每次我們都有一個新的空people列表。

當然,在這個例子中我們很容易發覺並解決這個問題,但想象一個龐大的系統中,有眾多類操作的people列表。

靜態的可變集合(據我同事所說)就像一個垃圾桶,充斥著各種垃圾,真應該避免使用。

2.宣告眾多final類

一個類宣告final就阻止了被mock。下面就是嘗試用Mockito去mock一個final類會發生什麼:

org.mockito.exceptions.base.MockitoException: 
Cannot mock/spy class <CLASS_NAME>
Mockito cannot mock/spy following:
  - final classes
  - anonymous classes
  - primitive types
      at org.mockito.internal.runners.JUnit45AndHigherRunnerImpl...

一個類宣告瞭final會有嚴重後果(比如它不能被繼承和mock),所以很有理由不這樣做。

等等,不要放棄治療。面對一個final類,只需要一些其他努力。Powermock就可以mock這樣的類,不過我覺得這個類庫治標不治本。

另一種方法是建立一個非fianl的包裹類,包裹住final類然後mock這個包裹類。 當然,前提是你能避免可能的類簽名的改變。

3.總是在方法中建立物件,尤其是在建構函式中。

我認為這不需要過多解釋。在方法或建構函式中建立物件讓我們無法引入測試的複製品。將依賴關係完全硬編碼,讓我們無法mock,寫不出真正的單元測試(排除一切外部依賴,在獨立環境下對一個類快速測試)。

依賴應該被注入,主要通過建構函式。如果因為一些原因做不到這點,建立物件的工作應該放到一個protected的方法中,這樣一來繼承它的虛構類可以重寫該方法。

4.方法/測試的名字和內容永遠不一致

很多人認為長方法(long methods)是測試的頭號公敵。儘管很多情況下是這樣,但不絕對。想象一個小巧的程式碼很長的求平方根函式。它接受一個整型,返回一個浮點數。因為我們很清楚平方根怎麼求,所以不需要關心程式碼實現的細節。我們把這個方法當做黑盒,來測一些顯而易見的值(9,25,36)和一些不常見的值。

然而,如果這個方法名叫log(數學裡的log),那麼我們要寫的測試就驢脣不對馬嘴了。這簡直是在浪費時間,所寫的測試用例完全沒有用。

測試方法也同樣。一旦測試失敗,描述測試工作的測試名真的很有用了。比如測試名是throwsExceptionForNegativeNumbers,測試了一個正數並且沒有任何異常,這對我們明白我們在測試什麼很有幫助。

5.從來不把流操作分成若干指令

因為Java 8 的streams有流暢的介面,這並不意味著filter,map,flatMap和其他操作一個接著一個鏈式呼叫(或者巢狀呼叫)。

讓我們看個例子。每個football club(足球俱樂部)提供一個soccer players(足球運動員)的列表,返回25歲到30歲間的前鋒球員。

public Map<String, Integer> someMethodName(List<Player> players) {
    return players.stream().filter(player -> {
            int now = Calendar.getInstance().get(Calendar.YEAR);
            return (now - player.getYearOfBirth() < 30) && (now - player.getYearOfBirth() > 25);
        }).filter(player -> player.getPosition() == Position.ST)
        .collect(Collectors.groupingBy(Player::getPlaysFor, Collectors.summingLong(Player::getValue)));
}

這裡的問題是很難知道什麼樣的資料應該通過這樣的鏈式方法,所以要遍歷所有可能的資料路由。很難指出我們要用什麼樣的players(運動員)列表作為輸入 。

一長串的操作雖然很吸引人,但可讀性很差。一般來說,根據整潔程式碼規則,把它們拆分成程式碼塊,提取成變數或方法是個好主意。
經過一些提取,程式碼重構如下

public Map<String, Integer> someMethodName(List<Player> players) {
    Stream<Player> playersInAgeRange = players.stream().filter(isPlayerInAgeRange());
    Stream<Player> strikers = playersInAgeRange.filter(isPlayerStriker());
    return strikers.collect(Collectors.groupingBy(Player::getPlaysFor, Collectors.summingLong(Player::getValue)));
}
private Predicate<? super Player> isPlayerStriker() {
    return player -> player.getPosition() == Position.ST;
}
private Predicate<? super Player> isPlayerInAgeRange() {
    return player -> {
        int now = Calendar.getInstance().get(Calendar.YEAR);
        return (now - player.getYearOfBirth() < 30) && (now - player.getYearOfBirth() > 25);
    };
}

儘管程式碼有些長,但可讀性大大提高。

譯文連結:http://www.codeceo.com/article/5-ways-write-hard-to-test-code.html
英文原文:5 Easy Ways to Write Hard-to-Test Code
翻譯作者:碼農網 – 孫騰浩
轉載必須在正文中標註並保留原文連結、譯文連結和譯者等資訊。]

相關文章