概念概述
模拟对象与行为验证(Mock Objects and Behaviour Verification)指的是:模拟对象替代协作者并让测试断言发生过的调用,能隔离单元,却也容易使测试与实现细节耦合。
从更严格的定义看,A mock object is a test double that records the calls it receives and can be programmed with expectations, so a test verifies interactions rather than only final state. 该概念在工程实践中的价值,在于它能帮助团队用统一的语言描述一个具体的质量属性或设计约束,从而在评审与协作中减少歧义。
为什么需要它
It is used to isolate code from slow or unreliable collaborators such as networks, clocks, and third-party services. 它与 Golden Master Testing、Test Flakiness Metrics、Accessibility Tree Testing 等概念经常一起出现,用于解决相近的工程问题。理解它的适用场景,有助于在多种备选方案之间做出有依据的取舍,而不是凭直觉选择技术方案。
常见误解
- mocks and stubs are the same thing
- mocking every collaborator always makes tests more robust
需要避免的反模式
- mocking so many collaborators that the test mirrors the implementation
- asserting an exact call sequence that a harmless refactor breaks
实践要点
- A stub returns canned answers, but a mock additionally asserts interaction, so overuse shifts the test’s focus from behaviour to internal wiring.
- Tests that lock in call order and argument shapes resist refactoring, because any internal reorganisation fails tests even when the outcome is unchanged.
- Prefer mocks at architectural boundaries and use real implementations for pure logic, so the test still says something about the system’s observable behaviour.
参考来源
https://martinfowler.com/articles/mocksArentStubs.html
代码示例
import httpx
import pytest
def fetch_invoice(base_url: str, invoice_id: str) -> dict:
with httpx.Client(base_url=base_url) as client:
resp = client.get(f'/invoices/{invoice_id}')
resp.raise_for_status()
return resp.json()
def test_fetch_invoice_parses_payload():
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured['url'] = str(request.url)
captured['auth'] = request.headers.get('Authorization')
return httpx.Response(200, json={'id': 'inv-1', 'total': 4200})
client = httpx.Client(
transport=httpx.MockTransport(handler),
base_url='https://api.example.test',
headers={'Authorization': 'Bearer test-token'},
)
with client:
resp = client.get('/invoices/inv-1')
data = resp.json()
assert data['total'] == 4200
assert captured['url'].endswith('/invoices/inv-1')
assert captured['auth'] == 'Bearer test-token'
@pytest.mark.parametrize('status', [500, 502, 503])
def test_fetch_invoice_raises_on_server_error(status):
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status)
client = httpx.Client(transport=httpx.MockTransport(handler))
with pytest.raises(httpx.HTTPStatusError):
client.get('https://api.example.test/invoices/inv-2')
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
)
type StubResponse struct {
Status int
Body string
}
func NewStubServer(responses map[string]StubResponse, hits *int64) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if hits != nil {
atomic.AddInt64(hits, 1)
}
response, ok := responses[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(response.Status)
_, _ = io.WriteString(w, response.Body)
}))
}
func main() {
var hits int64
server := NewStubServer(map[string]StubResponse{
"/users/1": {Status: http.StatusOK, Body: `{"id":1,"name":"ada"}`},
"/health": {Status: http.StatusOK, Body: `{"status":"ok"}`},
}, &hits)
defer server.Close()
for _, requestPath := range []string{"/users/1", "/health", "/missing"} {
response, err := http.Get(server.URL + requestPath)
if err != nil {
panic(err)
}
body, err := io.ReadAll(response.Body)
response.Body.Close()
if err != nil {
panic(err)
}
fmt.Printf("%s -> %d %s\n", requestPath, response.StatusCode, string(body))
}
fmt.Println("total requests:", atomic.LoadInt64(&hits))
}