在測試驅動開發的實務中,有效運用模擬物件和偽物件能大幅提升測試效率與程式碼品質。偽物件提供簡化的測試環境,模擬實際依賴項的行為,讓開發者專注於驗證程式碼邏輯。然而,過度使用模擬物件可能導致測試與實作細節過度耦合,降低測試的可靠性。本文除了探討模擬物件的使用,更進一步介紹服務層模式,藉由將業務邏輯與 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")]

內容解密:

  1. 測試案例設計:此測試案例驗證當來源目錄中存在一個檔案,而該檔案在目標目錄中不存在時的行為。
  2. FakeFileSystem的使用:透過使用FakeFileSystem,我們可以模擬檔案系統的操作,而無需實際進行檔案複製或移動,這使得測試更加快速和可靠。
  3. 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的基本流程:編寫測試、執行測試(預期失敗)、編寫程式碼使測試透過、重構程式碼以改善設計,然後再次迴圈。

內容解密:

  1. TDD迴圈:圖表展示了TDD的核心迴圈,即不斷重複的測試、編寫程式碼和重構過程。
  2. 流程控制:每個階段的輸出決定了流程的下一步驟,從而確保程式碼始終保持可測試性和可維護性。
  3. 持續改進:透過不斷的重構,確保程式碼設計保持乾淨和高效。

第四章:我們的首個使用案例 - 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 介面。

讓我們盡快將所有移動的部分連線起來,然後重構為更乾淨的架構。以下是我們的計劃:

  1. 使用 Flask 將 API 端點放在我們的 allocate 領域服務前面。連線資料函式庫會話和我們的 repository。使用端對端測試和一些快速而骯髒的 SQL 來準備測試資料。
  2. 重構出一個服務層,可以作為抽象來捕捉使用案例,並且將位於 Flask 和我們的領域模型之間。建立一些服務層測試,並展示如何使用 FakeRepository
  3. 對我們的服務層函式嘗試不同的引數型別;展示使用原始資料型別如何使服務層的客戶端(我們的測試和我們的 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

程式碼解析:

  1. random_sku()random_batchref() 等函式:這些是輔助函式,使用 uuid 模組生成隨機字元。這樣可以防止不同測試和執行之間的相互幹擾。
  2. add_stock 是輔助 fixture:它隱藏了使用 SQL 手動將行插入資料函式庫的細節。我們稍後在本章中將展示一種更好的方法。
  3. 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)

程式碼解析:

  1. orm.start_mappers():初始化 ORM 對映。
  2. get_session:建立一個會話工廠,用於與資料函式庫互動。
  3. 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}

程式碼解說

  1. allocate 函式:這是服務層的核心函式,負責協調訂單分配的整個流程。

    • 從儲存函式庫取得所有批次。
    • 驗證訂單中的 SKU 是否有效。
    • 如果 SKU 有效,嘗試將訂單分配到合適的批次。
    • 如果分配過程中出現缺貨錯誤,則丟擲 OutOfStock 例外。
    • 最後,提交事務並傳回分配的批次參考。
  2. is_valid_sku 函式:輔助函式,用於檢查給定的 SKU 是否存在於批次列表中。

  3. 錯誤處理:在分配過程中,如果遇到無效的 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,以便於測試中驗證是否正確提交了變更。

服務層功能的典型步驟

服務層函式通常遵循以下步驟:

  1. 從儲存層擷取必要的物件。
  2. 根據目前的狀態檢查或斷言請求的有效性。
  3. 呼叫領域服務進行必要的業務邏輯處理。
  4. 如果一切正常,儲存或更新任何變更的狀態。

以下是一個典型的服務層函式範例:

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的快樂路徑和錯誤處理。
  • 這使得我們的測試套件更加高效和易於維護。