用 100% code coverage 讓自己設計品質好的軟體
前言
很多敘述都是說 100% code coverage 雖然可以達成,但是會花非常多時間,所以往往 project 的 code coverage 都設定在 80-90% 左右。
但這前提是 source code 非常亂的情況下,才是 true。
現在我習慣讓新 projects 達到 100% code coverage,並不會多花時間,反而減少了許多 bugs,而且整體上程式碼也變得非常好讀,甚至讓任何新人進來看都看得懂。
我用一個簡化的 python 例子來描述怎麼做到這件事。
100% 不合理的案例
現代化的軟體通常少不了與外部軟體的整合,所以我們可以設計一個簡單化的 application:
- 用 REST API 取得某個使用者的發文
- 把 raw data 轉成 application 內部 data
- 用一些邏輯去搜尋發文
就會寫出下面這樣的程式碼,看似簡單:
# 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")
主要痛苦的原因:
- REST API 有點慢而且不穩定,為了每增加一行 error handling 的 coverage 就新增一個 test case 導致 project 在 CI/CD 的時候非常久,而且經常因為 REST API 的問題而 fail
- 為了讓特定 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 塊:
- REST API
- Data validation
- 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)
- 實驗性的功能,且預期頻繁修改 (生命極短暫)
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 不僅讓自己重新思考系統的架構,也讓寫測試變得容易許多,提高維護性。
留言
發佈留言