用 100% code coverage 讓自己設計品質好的軟體

前言

很多敘述都是說 100% code coverage 雖然可以達成,但是會花非常多時間,所以往往 project 的 code coverage 都設定在 80-90% 左右。

但這前提是 source code 非常亂的情況下,才是 true。

現在我習慣讓新 projects 達到 100% code coverage,並不會多花時間,反而減少了許多 bugs,而且整體上程式碼也變得非常好讀,甚至讓任何新人進來看都看得懂。

我用一個簡化的 python 例子來描述怎麼做到這件事。

100% 不合理的案例

現代化的軟體通常少不了與外部軟體的整合,所以我們可以設計一個簡單化的 application:

  1. 用 REST API 取得某個使用者的發文
  2. 把 raw data 轉成 application 內部 data
  3. 用一些邏輯去搜尋發文

就會寫出下面這樣的程式碼,看似簡單:


# complex_find.py
from dataclasses import dataclass

import requests


@dataclass(frozen=True)
class Post:
    id: int
    title: str
    body: str


def find_posts_with_keyword(user_id: int, keyword: str) -> list[Post]:
    # Get a list of posts with REST API
    base_url = "https://jsonplaceholder.typicode.com/posts"
    params: dict[str, int] = {"userId": user_id}
    response = requests.get(base_url, params)
    try:
        response.raise_for_status()
    except requests.HTTPError as e:
        raise RuntimeError(f"Failed to call API with params: {params}") from e

    # Filter posts with the keyword
    data_list = response.json()
    posts: list[Post] = []
    for data in data_list:
        if "id" not in data or "title" not in data or "body" not in data:
            raise ValueError(f"API data should contain 'id', 'title' and 'body': {data}")

        try:
            id = int(data["id"])
        except ValueError as e:
            raise ValueError(f"API data 'id' should be integer but got: {data['id']}") from e

        title = data["title"].strip()
        body = data["body"].strip()
        if keyword in title.lower() or keyword in body.lower():
            posts.append(Post(id=id, title=title, body=body))

    return posts


但在做 testing 的時候就會很痛苦,尤其是如果要達到 100% code coverage:


# tests/test_complex_find.py
import pytest
import requests
from pytest_mock import MockerFixture

from complex_find import find_posts_with_keyword


def test_find_posts_with_keyword() -> None:
    posts = find_posts_with_keyword(3, "quia")

    assert len(posts) == 5
    for post in posts:
        assert "quia" in post.title or "quia" in post.body


def test_find_posts_with_keyword_when_request_fails(mocker: MockerFixture) -> None:
    mock_response = mocker.create_autospec(requests.Response)
    mock_response.raise_for_status.side_effect = requests.HTTPError("fake error")
    mocker.patch.object(requests, "get", return_value=mock_response)

    with pytest.raises(RuntimeError, match="Failed to call API with params:"):
        find_posts_with_keyword(3, "quia")


def test_find_posts_with_keyword_when_required_fields_are_missing(
    mocker: MockerFixture,
) -> None:
    fake_data: list[dict[str, int]] = [{"id": 1}]

    mock_response = mocker.create_autospec(requests.Response)
    mock_response.json.return_value = fake_data
    mocker.patch.object(requests, "get", return_value=mock_response)

    with pytest.raises(
        ValueError, match="API data should contain 'id', 'title' and 'body':"
    ):
        find_posts_with_keyword(3, "quia")


def test_find_posts_with_keyword_when_id_is_not_integer(
    mocker: MockerFixture,
) -> None:
    fake_data: list[dict[str, str]] = [
        {"id": "x", "title": "some title", "body": "some body"}
    ]

    mock_response = mocker.create_autospec(requests.Response)
    mock_response.json.return_value = fake_data
    mocker.patch.object(requests, "get", return_value=mock_response)

    with pytest.raises(
        ValueError, match="API data 'id' should be integer but got: x"
    ):
        find_posts_with_keyword(3, "quia")

主要痛苦的原因:

  1. REST API 有點慢而且不穩定,為了每增加一行 error handling 的 coverage 就新增一個 test case 導致 project 在 CI/CD 的時候非常久,而且經常因為 REST API 的問題而 fail
  2. 為了讓特定 error handling 條件成真,需要用很多 mocking

最後的折衷就是退而求其次,可能 90% 就夠了,但你有發現上面的測試光是測 error handling 就花了 3 個 test cases 嗎? 反而最重要的 business logic 卻只有 1 個 test case。

就算沒有 100% code coverage,要新增 test cases 專門去測重要的東西時開發者也會猶豫,因為我們其實在測一個 "複雜" 的系統。

Clean code 版本

如果要達到 100% code coverage 要怎麼做? 在做過許多 projects 後我觀察到雖然有各種技巧,但通常最簡單又避不開的其實就是 clean code 說的:

把程式碼切小塊一點

這也是阻擋 100% code coverage 的主因,因為一個複雜的系統要怎麼切比較好牽扯到很多面向,和 clean architecture 裡面的 components 怎麼切有點像,例如說:

  • 程式碼的穩定性 (像是 REST API 不穩定,核心程式碼很穩定)
  • 程式碼修改的頻率 (像是有些規則因為使用者要求經常變動,但資料處理的部分卻不太變化)
  • 程式碼的 responsibility (例如取得資料和驗證資料是兩回事)

實務上也會因為一些奇怪的原因而把原本一個檔案切成好幾個,例如說:

  • 因為不確定使用者喜不喜歡一個小地方,先切出來變成 beta 功能
  • Linter 很討厭某個 third-party library 的語法,但這個 library 又太好用,只好切出來專屬於 library 的程式碼並把 file disable
  • 懷疑部分程式碼是 performance bottleneck 切出來做更詳細的測試

原因百百種,但切出來各做測試通常更容易達到 100% code coverage,其實同時間也解決了很多維護上的困擾。

以上面的例子而言,通常我會切成 3 塊:

  1. REST API
  2. Data validation
  3. Application logic

# api_client.py
import requests

from internal.validate import to_posts
from models.post import Post


def find_posts(user_id: int) -> list[Post]:
    base_url = "https://jsonplaceholder.typicode.com/posts"
    params: dict[str, int] = {"userId": user_id}

    response = requests.get(base_url, params)
    try:
        response.raise_for_status()
    except requests.HTTPError as e:
        raise RuntimeError(f"Failed to call API with params: {params}") from e

    data_list = response.json()
    return to_posts(data_list)

# internal/validate.py
from typing import Any

from models.post import Post


def to_posts(data_list: Any) -> list[Post]:
    posts: list[Post] = []
    for data in data_list:
        if "id" not in data or "title" not in data or "body" not in data:
            raise ValueError(
                f"API data should contain 'id', 'title' and 'body': {data}"
            )

        try:
            id = int(data["id"])
        except ValueError as e:
            raise ValueError(
                f"API data 'id' should be integer but got: {data['id']}"
            ) from e

        title = data["title"].strip()
        body = data["body"].strip()
        posts.append(Post(id=id, title=title, body=body))
    return posts

# search.py
from models.post import Post


def find_posts_by_keyword(posts: list[Post], keyword: str) -> list[Post]:
    return list(
        filter(lambda x: keyword in x.title.lower() or keyword in x.body.lower(), posts)
    )

在做 refactoring 之前我也沒料想到我一開始把 data validation 和 application logic 混在一起增加 bugs 的可能性,也增加維護上的困擾,例如某天我只想改 data validation 的部分卻要連 application logic 的 test cases 一起修改。

切成這三塊之後我們就可以看到其實最重要的 application logic 就凸顯出來了。其他 REST API 或 data validation 其實沒有那麼重要,而且也容易因為外部變動而跟著更改。

用切成小塊的程式碼後要達到 100% code coverage 就變得很簡單。


# tests/unit/test_api_client.py
import pytest
import requests
from pytest_mock import MockerFixture

from api_client import find_posts


def test_find_posts_with_successful_response(mocker: MockerFixture) -> None:
    mock_response = mocker.create_autospec(requests.Response)
    mock_response.json.return_value = [
        {"id": "1", "title": "some title", "body": "some body"}
    ]
    mocker.patch.object(requests, "get", return_value=mock_response)

    posts = find_posts(3)
    assert len(posts) > 0


def test_find_posts_with_failed_response(mocker: MockerFixture) -> None:
    mock_response = mocker.create_autospec(requests.Response)
    mock_response.raise_for_status.side_effect = requests.HTTPError("fake error")
    mocker.patch.object(requests, "get", return_value=mock_response)

    with pytest.raises(RuntimeError, match="Failed to call API with params:"):
        find_posts(3)

# tests/unit/internal/test_validate.py
import pytest

from internal.validate import to_posts
from models.post import Post


def test_to_posts_with_valid_data() -> None:
    data_list: list[dict[str, int | str]] = [
        {"id": 1, "title": "some title", "body": "some body"}
    ]
    posts = to_posts(data_list)
    assert posts == [Post(id=1, title="some title", body="some body")]


def test_to_posts_with_missing_fields() -> None:
    data_list: list[dict[str, int | str]] = [{"id": 1, "body": "some body"}]
    with pytest.raises(
        ValueError, match="API data should contain 'id', 'title' and 'body':"
    ):
        to_posts(data_list)


def test_to_posts_with_wrong_id_type() -> None:
    data_list: list[dict[str, int | str]] = [
        {"id": "x", "title": "some title", "body": "some body"}
    ]
    with pytest.raises(ValueError, match="API data 'id' should be integer but got: x"):
        to_posts(data_list)

# tests/unit/test_search.py
from models.post import Post
from search import find_posts_by_keyword


def test_find_posts_by_keyword_when_keyword_in_title_and_body() -> None:
    posts: list[Post] = [
        Post(id=1, title="some quia", body="quia some"),
        Post(id=2, title="something else", body="something else"),
    ]
    found_posts = find_posts_by_keyword(posts, "quia")

    assert found_posts == [posts[0]]


def test_find_posts_by_keyword_when_keyword_in_title_only() -> None:
    posts: list[Post] = [
        Post(id=1, title="some quia", body="x"),
        Post(id=2, title="something else", body="something else"),
    ]
    found_posts = find_posts_by_keyword(posts, "quia")

    assert found_posts == [posts[0]]


def test_find_posts_by_keyword_when_keyword_not_found() -> None:
    posts: list[Post] = [
        Post(id=1, title="x", body="x"),
        Post(id=2, title="something else", body="something else"),
    ]
    found_posts = find_posts_by_keyword(posts, "quia")

    assert found_posts == []

雖然程式碼看起來變多,但我們終於可以好好的測試最重要的核心邏輯了 (search) ! 每個 test file 都變得很清楚,要修改也變得容易許多。

而且因為我們把 REST API 完全 mock 掉,以上 unit testing 會變得又穩定又快,幾乎不會 fail。

但我們還是需要測試實際使用 REST API 的時機,因此我們可以用額外一組 integration test 來達成。


# tests/integration/test_app.py
from api_client import find_posts
from search import find_posts_by_keyword


def test_app() -> None:
    posts = find_posts(3)
    filtered_posts = find_posts_by_keyword(posts, "minus")

    assert len(filtered_posts) == 1
    assert "minus" not in filtered_posts[0].title
    assert "minus" in filtered_posts[0].body

可以看到 integration test 不用太複雜,因為目的只是驗證 API 可以用而已,我們就不需要額外在測其他 failure 的部分,除非 API 有提供這種功能。

切小塊 ≠ 小 function

我們切小塊的目的只是把一開始多面向的程式碼切成 "單一面向",例如 REST API function 就真的只做 REST API,data validation 就只做 validation,不做其他的事。

但這不代表 function 就一定都差不多行數,因為每個面向都有自己要煩惱的事。

最簡單的例子就是一些條列式的規則處理,如果一個 function 在 "同個領域中" 根據 100 種規則輸出不同的資料,那代表至少有 100 行程式碼,但也沒必要說為了讓 function 變小一點而拆成好幾個小 functions。

100% ≠ 每行都測到

我們只想讓該測的地方 100%,不該測的應該就 0%。

該測的地方就是指對 project 重要的地方,哪怕沒測到任何一行,實際上有可能走到,就算機率再小,就是隱藏的 bug。

不該測的地方其實不少見,例如:

  • Interface (像是一整個 file 只有 Python Protocol)
  • Third-party 產生的 generated files (像是 Qt 的 moc)
  • 實驗性的功能,且預期頻繁修改 (生命極短暫)
這種不該測的就該直接把整個 file 從 coverage tool exclude 掉。

Best practices 自動出現

在達成 100% code coverage 後,一堆程式碼上的好習慣就自然被套用了,例如:

  • SRP (single responsibility principle)
  • DRY (don't repeat yourself)
  • KISS (keep it simple, stupid)
  • YAGNI (you ain't gonna need it)

通常每個 file 都只有 1~5 個 functions,都專心做一件事,達到 SRP。

在切檔案的時候通常就會把重複的部分切出來變成一個檔案,達到 DRY。

又因為切出來後我們用更多的 function names 來表示在做什麼,也不需要 comments,達到 KISS。

切出來後原本覺得會需要的東西,因為要專門寫他的 unit test 時就會思考到底有沒有必要,就算真的留著未來要刪除也很單純,達到 YAGNI。

結論

現代的 projects 通常是多面向、多方面整合的一堆程式碼的集合體,如何根據不同面向 (case-by-case) 切開複雜的程式碼,通常就已經邁向 100% code coverage 一大步。

100% code coverage 不僅讓自己重新思考系統的架構,也讓寫測試變得容易許多,提高維護性。

留言

此網誌的熱門文章

[試算表] 追蹤台股 Google Spreadsheet (未實現損益/已實現損益)

互動式教學神經網路反向傳播 Interactive Computational Graph

[Side Project] Google Apps Script 實作 Google Sheet 抽股票的篩選工具