現代軟體開發流程中,測試扮演著至關重要的角色,然而測試速度往往成為瓶頸。本文將介紹如何利用 eatmydata 和 tmpfs 等工具來加速測試流程,同時也將探討如何設計測試友善的程式碼,特別是在處理程式執行和網路程式設計方面。透過依賴注入和模擬物件等技巧,可以有效地簡化測試程式碼,提高測試覆寫率,並提升整體開發效率。此外,理解 Python 中位元組、字串和 Unicode 的差異對於處理文字資料至關重要,特別是在處理多語言環境和特殊字元時,可以避免潛在的錯誤。
加速測試的技術與方法
在軟體開發過程中,測試是確保程式碼品質的重要環節。然而,一些測試可能會因為某些因素而變得緩慢,例如頻繁的檔案寫入操作。本章將介紹幾種加速測試的方法,包括使用 eatmydata 和 tmpfs。
使用 eatmydata 加速測試
eatmydata 是一個用於加速測試的工具,它可以攔截 fsync() 等系統呼叫,從而提高測試速度。eatmydata 的工作原理是透過動態連結器的 LD_PRELOAD 變數,在載入程式時優先載入 libeatmydata.so 函式庫,從而覆寫標準 C 函式庫中的相關函式。
設定 eatmydata
要在 tox.ini 中設定 eatmydata,可以新增以下設定:
[testenv]
setenv =
LD_PRELOAD = libeatmydata.so
commands =
python write_stuff.py
這種設定方式可以讓 tox 在執行測試時使用 eatmydata 加速測試。
使用 tmpfs 加速測試
tmpfs 是一種記憶體檔案系統,它可以將檔案儲存在記憶體中,而不是硬碟上。這樣可以大大提高檔案操作的效率。
在容器中使用 tmpfs
在容器中,可以使用 tmpfs 來加速測試。例如,在 Kubernetes 中,可以使用 emptyDir 卷並將 emptyDir.medium 設定為 Memory。在 Docker 或 nerdctl 中,可以使用 --tmpfs 引數來掛載 tmpfs。
設定 tmpfs
假設容器中已經掛載了 tmpfs 到 /app/tmpdir,可以透過設定 TMPDIR 環境變數來讓 tempfile 模組使用 /app/tmpdir:
$ TMPDIR=/app/tmpdir tox
這樣就可以讓測試使用 tmpfs 加速檔案操作。
測試流程控制程式碼
測試流程控制程式碼通常是一個微妙的工作,需要謹慎設計。因為流程控制程式碼與作業系統的介面很廣泛,所以很容易出現錯誤。此外,流程控制程式碼也可能因為外部命令的行為而受到影響。
簡化測試
為了簡化測試,可以使用依賴注入(dependency injection)模式。這種模式可以讓測試程式碼更容易被測試。例如,可以將 subprocess.run 函式作為引數傳遞給被測試的函式。
測試友善的程式碼設計:處理程式執行與網路程式設計
在開發涉及程式執行、網路通訊等系統層級功能的程式碼時,測試往往變得困難且複雜。本文將探討如何設計測試友善的程式碼,以提升程式的可靠性和可維護性。
依賴注入與測試友善設計
在處理程式執行時,直接使用 subprocess.run 可能導致測試困難。透過依賴注入(Dependency Injection),可以將程式執行的實作細節提升到介面層級,使程式碼更容易測試。
程式碼範例:原始版本 vs. 測試友善版本
# 原始版本
def error_lines(container_name):
ret_value = subprocess.run(
["docker", "logs", container_name],
capture_output=True,
text=True,
check=True,
)
for line in ret_value.stdout.splitlines():
if 'error' in line:
yield line
# 測試友善版本
def error_lines(runner, container_name):
ret_value = runner(
["docker", "logs", container_name],
)
for line in ret_value.stdout.splitlines():
if 'error' in line:
yield line
內容解密:
- 原始版本的
error_lines函式直接使用subprocess.run,使得測試困難。 - 測試友善版本將
runner作為引數傳入,允許在測試時替換為模擬(mock)實作。 - 這種設計提升了程式碼的靈活性和可測試性。
單元測試與模擬實作
使用 unittest.mock 可以輕鬆地為 error_lines 函式編寫單元測試。
測試範例
def test_error_lines():
runner = mock.MagicMock()
runner.return_value.stdout = textwrap.dedent("""\
hello
error: 5 is not 6
goodbye
""")
lines = list(error_lines(runner, "cool-container"))
assert lines == ["error: 5 is not 6"]
args, kwargs = runner.call_args
assert kwargs == {}
assert len(args) == 1
[single_arg] = args
assert single_arg == ["docker", "logs", "cool-container"]
內容解密:
- 使用
mock.MagicMock()建立模擬的runner物件。 - 設定
runner.return_value.stdout以模擬命令輸出的內容。 - 驗證
error_lines函式的輸出和runner的呼叫引數。
重構 Shell 指令碼邏輯
當將複雜的 Shell 指令碼移植到 Python 時,應考慮將資料處理邏輯與系統呼叫分離,以提高可測試性。
重構範例:殺死包含特定名稱的程式
def get_pids(lines):
for line in lines:
if 'conky' not in line:
continue
parts = line.split()
pid_part = parts[1]
pid = int(pid_part)
yield pid
def ps_aux(runner):
ret_value = runner(["ps", "aux"])
return ret_value.stdout.splitlines()
def kill(pids, *, killer):
for pid in pids:
killer(pid, signal.SIGTERM)
def main():
runner = functools.partial(
subprocess.run,
capture_output=True,
text=True,
check=True,
)
killer = os.kill
kill(get_pids(ps_aux(runner)), killer=killer)
內容解密:
get_pids函式負責解析程式列表,提取 PID,是純粹的資料處理邏輯,易於測試。ps_aux和kill分別封裝了與系統相關的操作,儘管較難測試,但邏輯相對簡單。- 這種分離使得大部分邏輯可以在不涉及系統呼叫的情況下進行測試。
網路程式設計的測試策略
在撰寫涉及底層網路操作的程式碼(如使用 socket)時,將 socket 物件的建立與使用分離,可以提高可測試性。
建議:
- 編寫接受 socket 物件作為引數的函式。
- 在測試時,使用模擬的 socket 物件或自定義的 fake socket 類別來模擬各種網路狀況。
使用 attrs 函式庫建立可測試的模擬物件
在軟體開發中,測試是確保程式碼品質的重要步驟。為了有效地進行測試,開發者經常需要建立模擬物件(fake objects)來模擬真實環境中的行為。本文將介紹如何使用 attrs 函式庫來建立一個模擬網路通訊的類別,並進一步探討如何利用這個模擬物件進行單元測試。
建立模擬網路通訊類別
首先,我們需要安裝 attrs 函式庫。可以在虛擬環境中使用 pip install attrs 指令來安裝。
import attr
@attr.s
class FakeSimpleSocket:
_chunk_size = attr.ib()
_received = attr.ib(init=False, factory=list)
_to_send = attr.ib()
def connect(self, addr):
pass
def send(self, blob):
actually_sent = blob[:self._chunk_size]
self._received.append(actually_sent)
return len(actually_sent)
def recv(self, max_size):
chunk_size = min(max_size, self._chunk_size)
received, self._to_send = (self._to_send[:chunk_size],
self._to_send[chunk_size:])
return received
內容解密:
@attr.s修飾符: 使用 attrs 函式庫的@attr.s修飾符來定義FakeSimpleSocket類別,使得類別屬性的定義更加簡潔。_chunk_size、_received、_to_send屬性: 分別代表資料傳輸的區塊大小、已接收的資料以及待傳送的資料。其中,_received在物件初始化時為空列表。connect、send、recv方法: 模擬網路通訊中的連線、傳送和接收資料的操作。其中,send方法根據_chunk_size決定每次傳送的資料量,而recv方法則根據_chunk_size和max_size決定每次接收的資料量。
使用模擬物件進行單元測試
接下來,我們可以使用 FakeSimpleSocket 類別來測試一個簡單的 HTTP GET 請求函式 get_get。
import json
def get_get(sock):
sock.connect(('httpbin.org', 80))
sock.send(b'GET /get HTTP/1.0\r\nHost: httpbin.org\r\n\r\n')
res = sock.recv(1024)
return json.loads(res.decode('ascii').split('\r\n\r\n', 1)[1])
def test_get_get():
result = dict(url='http://httpbin.org/get')
headers = b'HTTP/1.0 200 OK\r\nContent-Type: application/json\r\n\r\n'
output = headers + json.dumps(result).encode("ascii")
fake_sock = FakeSimpleSocket(_to_send=output, _chunk_size=1)
value = get_get(fake_sock)
assert value == result
內容解密:
get_get函式: 傳送 HTTP GET 請求並解析回應內容。test_get_get測試函式: 使用FakeSimpleSocket類別建立一個模擬的 socket 物件,並測試get_get函式的行為。assert陳述式: 驗證get_get函式的回傳值是否符合預期。
使用 httpx.Client 測試 HTTP 使用者端
對於使用 httpx 函式庫作為 HTTP 使用者端的程式碼,可以透過將 httpx.Client 物件作為引數傳遞給相關函式,使其更容易進行測試。
import httpx
import json
def put_httpbin(client):
resp = client.put("https://httpbin.org/put", json=dict(a=1, b=2))
resp.raise_for_status()
resp_value = resp.json()
data = json.loads(resp_value["data"])
return data["a"] + data["b"]
def test_put_httpbin_fake():
with httpx.Client(app=make_app()) as client:
value = put_httpbin(client)
assert value == 3
內容解密:
put_httpbin函式: 使用httpx.Client物件傳送 HTTP PUT 請求到 httpbin.org,並計算回應中的資料值。test_put_httpbin_fake測試函式: 建立一個模擬的 WSGI 應用程式,並使用httpx.Client物件對其進行測試。make_app函式: 建立一個簡單的 WSGI 應用程式,用於模擬 httpbin.org 的行為。
文字操作:Python 中的位元組、字串和 Unicode
在自動化類別 Unix 系統時,經常需要進行文字操作。許多程式的組態都是透過文字檔案來完成的,文字也是許多系統的輸入和輸出格式。因此,許多自動化任務最終都圍繞著文字操作展開。雖然像 sed、grep 和 awk 這樣的工具在某些場合下很有用,但 Python 是一種功能強大的工具,能夠進行複雜的文字操作。
6.1 位元組、字串和 Unicode
在處理文字或類別似文字的資料流時,如果不理解文字的微妙之處,很容易寫出在遇到外國人名或表情符號時表現出奇怪行為的程式碼。這些問題不再是理論上的;來自世界各地的使用者堅持用他們自己的方式拼寫他們的名字,有些人甚至在 Git 提交資訊中使用表情符號。為了確保程式碼的強健性,不會在半夜因某人的表情符號使用者名稱登入而出現問題,瞭解文字的本質是非常重要的。
Python 3 中有兩種不同的型別來表示文字檔案中的資料:位元組和字串。位元組對應於 RFC 通常所指的八位元組流,即一個由 8 位元組成的數值序列,或者說是一個範圍在 0 到 256 之間(包含 0 但不包含 256)的數字序列。當這些值都小於 128 時,這個序列被稱為 ASCII(美國資訊交換標準程式碼),並且為這些數字賦予了 ASCII 所定義的含義。當這些值都在 32 到 128 之間(包含 32 但不包含 128)時,這個序列被稱為可列印的 ASCII 或 ASCII 文字。前 32 個字元有時被稱為控制字元,鍵盤上的 Ctrl 鍵就是對此的參照;它的原始目的是能夠輸入這些字元。
ASCII 只涵蓋了美國使用的英語字母。要表示(幾乎)任何語言的文字,需要使用 Unicode。Unicode 碼點是 0 到 232 之間的一些數字(包含 0 但不包含 232)。每個 Unicode 碼點都被賦予了一個含義。標準的連續版本保留了已分配的含義,並為更多的數字分配了新的含義。
UTF-8 編碼
正確地說,只有 Unicode 才被視為文字,而 Python 的字串正是用來表示 Unicode 的。將位元組轉換為字串或反之亦然,需要使用編碼。目前最流行的編碼是 UTF-8。令人混淆的是,將位元組轉換為文字被稱為解碼,而將文字轉換為位元組被稱為編碼。
記住編碼和解碼之間的區別對於操作文字資料至關重要。記住這一點的一個方法是,由於 UTF-8 是一種編碼,從字串轉換為 UTF-8 編碼的資料是編碼,而從 UTF-8 編碼的資料轉換為字串是解碼。
UTF-8 有一個有趣的特性。當給定一個恰好是 ASCII 的 Unicode 字串時,它會產生與碼點值相同的位元組序列。這意味著在視覺上,編碼和解碼的形式看起來是相同的。
>>> "hello".encode("utf-8")
b'hello'
>>> "hello".encode("utf-16")
b'\xff\xfeh\x00e\x00l\x00l\x00o\x00'
使用 UTF-16 的例子表明,這並不是編碼的一個簡單屬性。UTF-8 的另一個特性是,如果位元組不是 ASCII,並且 UTF-8 解碼成功,那麼它們不太可能是用不同的編碼方式編碼的。UTF-8 的設計使其具有自同步特性;從一個隨機的位元組開始,可以在檢查有限數量的位元組後與字串同步。自同步特性的設計目的是允許從截斷和損壞中還原,但作為一個額外的好處,它允許可靠地檢測無效字元,從而檢測字串是否為 UTF-8 編碼。
解碼錯誤處理
嘗試使用 UTF-8 解碼是一種安全的操作。對於只包含 ASCII 的文字,它能正確處理;對於 UTF-8 編碼的文字,它能正常工作;對於既不是 ASCII 也不是 UTF-8 編碼的文字(無論是不同編碼的文字還是像 JPEG 這樣的二進位制格式),它會乾淨地失敗。對於 Python 來說,“乾淨地失敗”意味著丟擲一個異常。
>>> snowman = '\N{snowman}'
>>> snowman.encode('utf-16').decode('utf-8')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte
對於隨機資料,這種解碼也往往會失敗。
>>> struct.pack('B'*12, *(random.randrange(0, 256) for i in range(12))).decode('utf-8')
錯誤是隨機的,因為輸入是隨機的。下面是一些可能的錯誤示例。
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe2 in position 4: invalid continuation byte
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x98 in position 2: invalid start byte
多執行幾次這個例子是一個好的練習;它很少會成功。
6.2 字串
Python 的字串物件很微妙。從某種角度來看,它似乎是一個字元序列,而一個字元就是一個長度為 1 的字串。
>>> a = "hello"
>>> for i, x in enumerate(a):
... print(i, x, len(x))
...
0 h 1
1 e 1
2 l 1
3 l 1
4 o 1
hello 字串有五個元素,每個元素都是一個長度為 1 的字串。由於字串是一種序列,因此通常的序列操作都適用於它。
你可以透過指定兩個端點來建立切片。
>>> a[2:4]
'll'
或者,你可以只指定終點來建立切片。
>>> a[:2]
'he'
或者,你可以只指定起點來建立切片。
>>> a[3:]
'lo'
字串操作的詳細說明
在上述範例中,我們展示了 Python 中基本的字串操作,包括遍歷字串、建立切片等。這些操作是理解和使用 Python 字串的基礎。透過使用 enumerate 函式,我們可以同時獲得字串中每個字元的索引和值,這在很多情況下非常有用。
建立切片是另一種常見的操作。透過指定不同的起點和終點,我們可以從原始字串中提取出我們感興趣的部分。這種靈活性使得 Python 的字串處理變得非常方便和強大。