在測試驅動開發的實務中,有效運用模擬物件和偽物件能大幅提升測試效率與程式碼品質。偽物件提供簡化的測試環境,模擬實際依賴項的行為,讓開發者專注於驗證程式碼邏輯。然而,過度使用模擬物件可能導致測試與實作細節過度耦合,降低測試的可靠性。本文除了探討模擬物件的使用,更進一步介紹服務層模式,藉由將業務邏輯與 API 端點分離,提升程式碼的可維護性和可測試性。服務層作為領域模型和儲存函式庫之間的橋樑,有效簡化了應用程式架構,並促程式式碼的模組化。透過 Flask API 與服務層整合的案例,我們可以看到如何利用偽物件和服務層,構建更具彈性且易於測試的應用程式。
測試驅動開發(TDD)中的模擬物件與偽物件
在軟體開發中,測試驅動開發(TDD)是一種確保程式碼品質和可維護性的重要實踐。其中,模擬物件(Mocks)和偽物件(Fakes)是兩種常見的測試替身(Test Double),用於隔離被測試程式碼的依賴,使測試更加可靠和高效。
模擬物件(Mocks)與偽物件(Fakes)的區別
模擬物件(Mocks):主要用於驗證被測試程式碼與其依賴之間的互動。它們通常具有諸如
assert_called_once_with()的方法,用於檢查是否以正確的引數呼叫了特定的方法。模擬物件與倫敦學派(London-school)的TDD實踐相關聯。偽物件(Fakes):是一種可用的實作,用於替換真實的依賴,但僅適用於測試環境。它們不適用於生產環境。例如,一個記憶體中的儲存函式庫就是一個偽物件的例子。偽物件允許對系統的最終狀態進行斷言,而不是驗證過程中的行為,因此與經典風格(Classic-style)的TDD相關聯。
使用模擬物件的優缺點
def test_when_a_file_exists_in_the_source_but_not_the_destination():
source = {"sha1": "my-file"}
dest = {}
filesystem = FakeFileSystem()
reader = {"/source": source, "/dest": dest}
synchronise_dirs(reader.pop, filesystem, "/source", "/dest")
assert filesystem == [("COPY", "/source/my-file", "/dest/my-file")]
內容解密:
- 測試案例設計:此測試案例驗證當來源目錄中存在一個檔案,而該檔案在目標目錄中不存在時的行為。
FakeFileSystem的使用:透過使用FakeFileSystem,我們可以模擬檔案系統的操作,而無需實際進行檔案複製或移動,這使得測試更加快速和可靠。synchronise_dirs函式:此函式負責同步來源和目標目錄中的檔案。測試透過檢查filesystem的狀態來驗證是否正確地執行了複製操作。
為什麼避免過度使用模擬
雖然模擬物件可以使單元測試變得容易,但過度使用模擬會導致測試與實作細節耦合,進而使測試變得脆弱。此外,模擬通常不能改善程式碼的設計;它們只是使得在隔離狀態下測試程式碼成為可能。
TDD是一種設計實踐
我們認為TDD首先是一種設計實踐,其次才是測試實踐。測試記錄了我們的設計選擇,並在我們長時間未接觸程式碼後再次閱讀時能夠解釋系統。
圖示:TDD流程圖
@startuml
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle
title TDD模擬物件與偽物件應用與服務層設計
package "機器學習流程" {
package "資料處理" {
component [資料收集] as collect
component [資料清洗] as clean
component [特徵工程] as feature
}
package "模型訓練" {
component [模型選擇] as select
component [超參數調優] as tune
component [交叉驗證] as cv
}
package "評估部署" {
component [模型評估] as eval
component [模型部署] as deploy
component [監控維護] as monitor
}
}
collect --> clean : 原始資料
clean --> feature : 乾淨資料
feature --> select : 特徵向量
select --> tune : 基礎模型
tune --> cv : 最佳參數
cv --> eval : 訓練模型
eval --> deploy : 驗證模型
deploy --> monitor : 生產模型
note right of feature
特徵工程包含:
- 特徵選擇
- 特徵轉換
- 降維處理
end note
note right of eval
評估指標:
- 準確率/召回率
- F1 Score
- AUC-ROC
end note
@enduml此圖示展示了TDD的基本流程:編寫測試、執行測試(預期失敗)、編寫程式碼使測試透過、重構程式碼以改善設計,然後再次迴圈。
內容解密:
- TDD迴圈:圖表展示了TDD的核心迴圈,即不斷重複的測試、編寫程式碼和重構過程。
- 流程控制:每個階段的輸出決定了流程的下一步驟,從而確保程式碼始終保持可測試性和可維護性。
- 持續改進:透過不斷的重構,確保程式碼設計保持乾淨和高效。
第四章:我們的首個使用案例 - Flask API 與服務層
在第二章結束時,我們已經達到了如圖 4-1 所示的階段,該章節涵蓋了 Repository 模式。
圖 4-1:在我們驅動應用程式透過與 repositories 和領域模型對話之前
在本章中,我們將討論協調邏輯、業務邏輯和介面程式碼之間的差異,並引入服務層模式來處理我們的工作流程和定義系統的使用案例。
我們還將討論測試:透過將服務層與資料函式庫上的 repository 抽象結合,我們能夠寫出快速的測試,不僅是我們的領域模型,還有針對使用案例的整個工作流程。
圖 4-2 顯示了我們的目標:我們將新增一個 Flask API,它將與服務層對話,服務層將作為進入我們領域模型的入口點。由於我們的服務層依賴於 AbstractRepository,我們可以使用 FakeRepository 進行單元測試,但使用 SqlAlchemyRepository 執行我們的生產程式碼。
圖 4-2:服務層將成為進入我們應用程式的主要方式
在我們的圖表中,我們使用新的元件以粗體文字/線條(以及數位版本的黃色/橙色)突出顯示的慣例。
提示 本章的程式碼位於 GitHub 上的
chapter_04_service_layer分支中:git clone https://github.com/cosmicpython/code.git cd code git checkout chapter_04_service_layer或者要跟著編寫程式碼,請簽出第二章:
git checkout chapter_02_repository
將我們的應用程式連線到真實世界
像任何優秀的敏捷團隊一樣,我們正在努力嘗試將 MVP 推出並交給使用者以開始收集回饋。我們擁有分配訂單所需的領域模型核心和領域服務,並且我們擁有用於永久儲存的 repository 介面。
讓我們盡快將所有移動的部分連線起來,然後重構為更乾淨的架構。以下是我們的計劃:
- 使用 Flask 將 API 端點放在我們的
allocate領域服務前面。連線資料函式庫會話和我們的 repository。使用端對端測試和一些快速而骯髒的 SQL 來準備測試資料。 - 重構出一個服務層,可以作為抽象來捕捉使用案例,並且將位於 Flask 和我們的領域模型之間。建立一些服務層測試,並展示如何使用
FakeRepository。 - 對我們的服務層函式嘗試不同的引數型別;展示使用原始資料型別如何使服務層的客戶端(我們的測試和我們的 Flask API)與模型層解耦。
第一個端對端測試
沒有人對關於什麼算是端對端(E2E)測試、功能測試、驗收測試、整合測試或單元測試的術語爭論感興趣。不同的專案需要不同組合的測試,我們已經看到非常成功的專案只是將事物分成「快速測試」和「慢速測試」。
現在,我們想要寫一個或可能兩個測試,它們將執行「真實」的 API 端點(使用 HTTP)並與真實資料函式庫對話。讓我們稱它們為端對端測試,因為這是最容易理解的名字之一。
以下是第一次嘗試:
第一個 API 測試(test_api.py)
@pytest.mark.usefixtures('restart_api')
def test_api_returns_allocation(add_stock):
sku, othersku = random_sku(), random_sku('other')
earlybatch = random_batchref(1)
laterbatch = random_batchref(2)
otherbatch = random_batchref(3)
add_stock([
(laterbatch, sku, 100, '2011-01-02'),
(earlybatch, sku, 100, '2011-01-01'),
(otherbatch, othersku, 100, None),
])
data = {'orderid': random_orderid(), 'sku': sku, 'qty': 3}
url = config.get_api_url()
r = requests.post(f'{url}/allocate', json=data)
assert r.status_code == 201
assert r.json()['batchref'] == earlybatch
程式碼解析:
random_sku()、random_batchref()等函式:這些是輔助函式,使用uuid模組生成隨機字元。這樣可以防止不同測試和執行之間的相互幹擾。add_stock是輔助 fixture:它隱藏了使用 SQL 手動將行插入資料函式庫的細節。我們稍後在本章中將展示一種更好的方法。config.py:這是一個模組,用於儲存組態資訊。每個人以不同的方式解決這些問題,但您需要一些方法來啟動 Flask(可能在容器中)並與 Postgres 資料函式庫對話。如果您想了解我們是如何做到的,請檢視附錄 B。
直接實作
以最直接的方式實作,您可能會得到類別似以下的內容:
Flask 應用的第一版(flask_app.py)
from flask import Flask, jsonify, request
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import config
import model
import orm
import repository
orm.start_mappers()
get_session = sessionmaker(bind=create_engine(config.get_postgres_uri()))
app = Flask(__name__)
@app.route("/allocate", methods=['POST'])
def allocate_endpoint():
session = get_session()
batches = repository.SqlAlchemyRepository(session).list()
line = model.OrderLine(
request.json['orderid'],
request.json['sku'],
request.json['qty'],
)
batchref = model.allocate(line, batches)
程式碼解析:
orm.start_mappers():初始化 ORM 對映。get_session:建立一個會話工廠,用於與資料函式庫互動。allocate_endpoint:處理/allocate端點的 POST 請求。它從請求中提取資料,建立OrderLine物件,並呼叫model.allocate函式來分配批次。
透過這種方式,我們完成了第一個端對端測試和直接實作,為後續的重構和擴充套件奠定了基礎。接下來,我們將進一步重構和最佳化程式碼,以達到更好的架構和可維護性。
引入服務層並使用 FakeRepository 進行單元測試
在前面的章節中,我們已經實作了一個基本的 Flask API 來處理訂單分配。然而,隨著需求的增加,我們的 Flask 應用程式開始變得越來越複雜。為了改善程式碼的可維護性和可測試性,我們將引入一個服務層(Service Layer),有時也稱為協調層(Orchestration Layer)或使用案例層(Use-Case Layer)。
為什麼需要服務層?
我們的 Flask 應用程式目前承擔了太多的職責,包括從儲存函式庫中取得資料、驗證輸入資料、處理錯誤和提交事務等。這些邏輯並不特定於 Web API 端點,如果我們要建立命令列介面(CLI),也需要相同的邏輯。因此,將這些邏輯提取到一個單獨的服務層中是有意義的。
服務層的優點
- 提高可測試性:透過將業務邏輯與 API 端點分離,我們可以使用更快的單元測試來驗證服務層的正確性。
- 增強可維護性:服務層使得我們的程式碼更加模組化,更容易理解和維護。
使用 FakeRepository 進行單元測試
在第 3 章中,我們準備了一個 FakeRepository 類別,它是一個記憶體中的批次集合。這個類別將在我們的服務層測試中發揮重要作用,讓我們能夠使用快速的單元測試來驗證服務層的功能。
FakeRepository 的實作
class FakeRepository(repository.AbstractRepository):
def __init__(self, batches):
self._batches = set(batches)
def add(self, batch):
self._batches.add(batch)
def get(self, reference):
return next(b for b in self._batches if b.reference == reference)
def list(self):
return list(self._batches)
服務層的單元測試
def test_returns_allocation():
line = model.OrderLine("o1", "COMPLICATED-LAMP", 10)
batch = model.Batch("b1", "COMPLICATED-LAMP", 100, eta=None)
repo = FakeRepository([batch])
result = services.allocate(line, repo, FakeSession())
assert result == "b1"
def test_error_for_invalid_sku():
line = model.OrderLine("o1", "NONEXISTENTSKU", 10)
batch = model.Batch("b1", "AREALSKU", 100, eta=None)
repo = FakeRepository([batch])
with pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"):
services.allocate(line, repo, FakeSession())
在上述測試中,我們使用 FakeRepository 來模擬真實的儲存函式庫,並驗證 services.allocate 函式的行為。
服務層的實作
我們的 services 模組(services.py)將定義一個 allocate 服務層函式,它將位於 API 層的 allocate_endpoint 函式和領域模型的 allocate 領域服務函式之間。
# services.py
def allocate(line, repo, session):
# 從儲存函式庫取得批次
batches = repo.list()
# 驗證 SKU 是否有效
if not is_valid_sku(line.sku, batches):
raise InvalidSku(f'Invalid sku {line.sku}')
try:
# 分配訂單到批次
batchref = model.allocate(line, batches)
except model.OutOfStock as e:
# 處理缺貨錯誤
raise OutOfStock(str(e))
# 提交事務
session.commit()
return batchref
def is_valid_sku(sku, batches):
return sku in {b.sku for b in batches}
程式碼解說
allocate函式:這是服務層的核心函式,負責協調訂單分配的整個流程。- 從儲存函式庫取得所有批次。
- 驗證訂單中的 SKU 是否有效。
- 如果 SKU 有效,嘗試將訂單分配到合適的批次。
- 如果分配過程中出現缺貨錯誤,則丟擲
OutOfStock例外。 - 最後,提交事務並傳回分配的批次參考。
is_valid_sku函式:輔助函式,用於檢查給定的 SKU 是否存在於批次列表中。錯誤處理:在分配過程中,如果遇到無效的 SKU 或缺貨情況,會丟擲相應的例外。這使得上層應用可以根據具體情況進行錯誤處理。
服務層的設計與實作
在前面的章節中,我們已經成功地將業務邏輯從Flask應用程式中分離出來,形成了一個獨立的服務層。這一層負責協調領域模型和儲存層之間的互動,使得我們的程式碼更加模組化和可測試。
虛擬Session的實作
為了測試服務層的功能,我們需要一個虛擬的資料函式庫Session。以下是一個簡單的FakeSession類別實作:
class FakeSession:
def __init__(self):
self.committed = False
def commit(self):
self.committed = True
內容解密:
FakeSession類別模擬了資料函式庫Session的基本行為,特別是commit方法。- 當
commit方法被呼叫時,committed屬性被設為True,以便於測試中驗證是否正確提交了變更。
服務層功能的典型步驟
服務層函式通常遵循以下步驟:
- 從儲存層擷取必要的物件。
- 根據目前的狀態檢查或斷言請求的有效性。
- 呼叫領域服務進行必要的業務邏輯處理。
- 如果一切正常,儲存或更新任何變更的狀態。
以下是一個典型的服務層函式範例:
def allocate(line: OrderLine, repo: AbstractRepository, session) -> str:
batches = repo.list()
if not is_valid_sku(line.sku, batches):
raise InvalidSku(f'Invalid sku {line.sku}')
batchref = model.allocate(line, batches)
session.commit()
return batchref
內容解密:
allocate函式從儲存層擷取批次列表。- 檢查訂單行的SKU是否有效。
- 呼叫領域模型的
allocate函式進行分配。 - 提交變更到資料函式庫Session。
對抽象的依賴
服務層函式依賴於抽象的儲存層介面(如AbstractRepository),而不是具體的實作。這使得我們的程式碼更加靈活,可以輕易地替換不同的儲存層實作。
def allocate(line: OrderLine, repo: AbstractRepository, session) -> str:
# ...
內容解密:
allocate函式依賴於AbstractRepository抽象介面。- 這種設計允許我們在測試中使用
FakeRepository,在實際應用中使用SqlAlchemyRepository。
Flask應用程式的簡化
由於服務層的引入,我們的Flask應用程式變得更加簡潔,主要負責處理與Web相關的任務,如請求引數解析、回應狀態碼等。
@app.route("/allocate", methods=['POST'])
def allocate_endpoint():
session = get_session()
repo = repository.SqlAlchemyRepository(session)
line = model.OrderLine(
request.json['orderid'],
request.json['sku'],
request.json['qty'],
)
try:
batchref = services.allocate(line, repo, session)
return jsonify({'batchref': batchref}), 201
except (model.OutOfStock, services.InvalidSku) as e:
return jsonify({'message': str(e)}), 400
內容解密:
allocate_endpoint函式處理分配請求。- 從請求中提取必要資訊,呼叫服務層的
allocate函式。 - 根據結果傳回適當的JSON回應和狀態碼。
端對端測試的簡化
透過服務層的引入,我們可以將端對端測試簡化為只測試快樂路徑和不快樂路徑。
@pytest.mark.usefixtures('restart_api')
def test_happy_path_returns_201_and_allocated_batch(add_stock):
# ...
@pytest.mark.usefixtures('restart_api')
def test_unhappy_path_returns_400_and_error_message():
# ...
內容解密:
- 端對端測試現在只關注於測試API的快樂路徑和錯誤處理。
- 這使得我們的測試套件更加高效和易於維護。