章節概述#

本章實作 TestSuite,讓多個測試能被組合在一起執行並取得集體結果。這是 Composite 模式的經典應用——單一測試和測試群組對外暴露相同的介面。實作過程中,作者引入了 Collecting Parameter 模式來解決介面一致性的問題。

動機:消除重複的測試呼叫#

在之前的章節末尾,我們執行所有測試的方式相當粗糙:

print TestCaseTest("testTemplateMethod").run().summary()
print TestCaseTest("testResult").run().summary()
print TestCaseTest("testFailedResultFormatting").run().summary()
print TestCaseTest("testFailedResult").run().summary()

這些程式碼充滿了重複。但重複不只是壞味道,它其實是尚未發現的設計元素的信號。我們需要的是將多個測試組合起來、一起執行並取得集體結果的能力。

先寫測試:TestSuite 的目標介面#

def testSuite(self):
    suite = TestSuite()
    suite.add(WasRun("testMethod"))
    suite.add(WasRun("testBrokenMethod"))
    result = suite.run()
    assert("2 run, 1 failed" == result.summary())

這個測試描述了我們期望的 API:建立一個 TestSuite,加入測試,執行後取得結果摘要。

實作 TestSuite#

add() 方法很直覺——將測試加入一個 list:

class TestSuite:
    def __init__(self):
        self.tests = []

    def add(self, test):
        self.tests.append(test)

run() 方法稍微棘手。我們希望所有測試共用同一個 TestResult,因此需要:

def run(self):
    result = TestResult()
    for test in self.tests:
        test.run(result)
    return result

介面一致性問題:Composite 的核心約束#

Composite 模式的主要約束是:集合體必須和個別項目回應相同的訊息。如果 TestSuite.run() 接受一個 result 參數,那麼 TestCase.run() 也必須接受同樣的參數。

作者考慮了三個方案:

  1. Python 預設參數機制 — 不可行,因為預設值在編譯時求值,我們不想重複使用同一個 TestResult
  2. 拆成兩個方法(一個分配 TestResult、一個執行測試)— 想不出好名字,暗示這不是好策略
  3. 由呼叫端分配 TestResult — 採用此方案

這個模式稱為 Collecting Parameter:由呼叫端建立收集器物件,傳入被呼叫端來累積結果。

套用 Collecting Parameter 模式#

測試修改為:

def testSuite(self):
    suite = TestSuite()
    suite.add(WasRun("testMethod"))
    suite.add(WasRun("testBrokenMethod"))
    result = TestResult()
    suite.run(result)
    assert("2 run, 1 failed" == result.summary())

run() 不再回傳值,TestSuiteTestCase 的介面一致了:

# TestSuite
def run(self, result):
    for test in self.tests:
        test.run(result)

# TestCase
def run(self, result):
    result.testStarted()
    self.setUp()
    try:
        method = getattr(self, self.name)
        method()
    except:
        result.testFailed()
    self.tearDown()
classDiagram
    class TestSuite {
        -tests[]
        +add(test)
        +run(result)
    }
    class TestCase {
        -name
        +run(result)
        +setUp()
        +tearDown()
    }
    class TestResult {
        +testStarted()
        +testFailed()
        +summary()
    }
    TestSuite o-- TestCase : contains
    TestSuite o-- TestSuite : contains
    TestCase ..> TestResult : reports to
    TestSuite ..> TestResult : reports to

修復既有測試#

介面改變後,四個既有測試因為使用了舊的無參數 run() 而失敗。修復方式是在每個測試中建立 TestResult 並傳入:

def testTemplateMethod(self):
    test = WasRun("testMethod")
    result = TestResult()
    test.run(result)
    assert("setUp testMethod tearDown " == test.log)

def testResult(self):
    test = WasRun("testMethod")
    result = TestResult()
    test.run(result)
    assert("1 run, 0 failed" == result.summary())

def testFailedResult(self):
    test = WasRun("testBrokenMethod")
    result = TestResult()
    test.run(result)
    assert("1 run, 1 failed" == result.summary())

def testFailedResultFormatting(self):
    result = TestResult()
    result.testStarted()
    result.testFailed()
    assert("1 run, 1 failed" == result.summary())

用 setUp 消除重複#

每個測試都在分配 TestResult——這正是 setUp() 要解決的問題。重構後:

def setUp(self):
    self.result = TestResult()

def testTemplateMethod(self):
    test = WasRun("testMethod")
    test.run(self.result)
    assert("setUp testMethod tearDown " == test.log)

def testResult(self):
    test = WasRun("testMethod")
    test.run(self.result)
    assert("1 run, 0 failed" == self.result.summary())

def testFailedResult(self):
    test = WasRun("testBrokenMethod")
    test.run(self.result)
    assert("1 run, 1 failed" == self.result.summary())

def testFailedResultFormatting(self):
    self.result.testStarted()
    self.result.testFailed()
    assert("1 run, 1 failed" == self.result.summary())

def testSuite(self):
    suite = TestSuite()
    suite.add(WasRun("testMethod"))
    suite.add(WasRun("testBrokenMethod"))
    suite.run(self.result)
    assert("2 run, 1 failed" == self.result.summary())

清理測試呼叫端#

有了 TestSuite,測試的執行程式碼也變得乾淨了:

suite = TestSuite()
suite.add(TestCaseTest("testTemplateMethod"))
suite.add(TestCaseTest("testResult"))
suite.add(TestCaseTest("testFailedResultFormatting"))
suite.add(TestCaseTest("testFailedResult"))
suite.add(TestCaseTest("testSuite"))
result = TestResult()
suite.run(result)
print result.summary()

補充: 這裡仍有重複——手動逐一加入每個測試方法。如果能從 TestCase 類別自動建構 TestSuite,就能消除這個重複。作者將此項留給讀者自行練習。

待辦清單更新#

  • Invoke test method
  • Invoke setUp first
  • Invoke tearDown afterward
  • Invoke tearDown even if the test method fails
  • Run multiple tests
  • Report collected results
  • Log string in WasRun
  • Report failed tests
  • Catch and report setUp errors
  • Create TestSuite from a TestCase class

本章小結#

注意: 作者承認本章違反了一次規則——在測試尚未通過的情況下就寫了部分實作。如果你在閱讀時就發現了,給自己加分。理論上應該有一個簡單的 Fake Implementation 可以先讓測試通過,但作者當時沒想到。

本章完成了以下工作:

  • TestSuite 撰寫測試
  • 實作 TestSuite,使用 Composite 模式讓單一測試和測試集合共用相同介面
  • 引入 Collecting Parameter 模式,由呼叫端分配 TestResult 並傳入
  • 修改 run() 的介面以維持一致性,然後修復所有受影響的測試
  • 利用 setUp() 提取出共同的初始化邏輯