← 中文资源

简体中文版本

模拟对象与行为验证

模拟对象替代协作者并让测试断言发生过的调用,能隔离单元,却也容易使测试与实现细节耦合。

阅读英文原文

概念概述

模拟对象与行为验证(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

实践要点

  1. A stub returns canned answers, but a mock additionally asserts interaction, so overuse shifts the test’s focus from behaviour to internal wiring.
  2. Tests that lock in call order and argument shapes resist refactoring, because any internal reorganisation fails tests even when the outcome is unchanged.
  3. 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))
}