区分单元测试、集成测试这类问题,很多人下意识的判断标准是”这个测试碰了几个文件、几个函数”——碰的多就是集成测试,碰的少就是单元测试。这个标准是错的,真正的分界线是:这个测试有没有跨过一个真实的边界 (进程边界、网络边界、外部系统),以及要不要同时启动多个东西才能跑起来 。同一个函数,纯内存计算的部分单测就够了,一旦牵扯到真实数据库连接,哪怕只调了一个函数,也已经是集成测试的范畴。
单元测试:不跨边界,全程在内存里 单元测试测的是一段逻辑本身对不对,运行过程中不接触任何真实的外部依赖——不连数据库、不发真实网络请求、不读写文件。Go 里最常见的写法是表驱动测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 func TestCalculateDiscount (t *testing.T) { cases := []struct { name string price float64 vipLevel int want float64 }{ {"普通用户无折扣" , 100 , 0 , 100 }, {"VIP1享9折" , 100 , 1 , 90 }, {"VIP2享8折" , 100 , 2 , 80 }, } for _, c := range cases { t.Run(c.name, func (t *testing.T) { got := CalculateDiscount(c.price, c.vipLevel) if got != c.want { t.Errorf("got %v, want %v" , got, c.want) } }) } }
这类测试跑起来是毫秒级的,能在开发过程中频繁执行,是重构时的安全网——改完代码跑一遍单测,几秒钟就知道有没有破坏已有行为。它的局限也很明确:单测只能证明这段逻辑本身没问题,证明不了这段逻辑跟真实数据库、真实下游服务对接起来还对不对。
集成测试:跨了边界,但只测两个东西怎么配合 集成测试验证的是”两个真实组件接起来工作是否正确”,比如 repository 层跟真实数据库的交互:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 func TestUserRepository_FindByEmail (t *testing.T) { db := setupTestPostgres(t) repo := NewUserRepository(db) _, err := db.Exec("INSERT INTO users (email, name) VALUES ($1, $2)" , "a@test.com" , "Alice" ) if err != nil { t.Fatal(err) } user, err := repo.FindByEmail("a@test.com" ) if err != nil { t.Fatal(err) } if user.Name != "Alice" { t.Errorf("got %v, want Alice" , user.Name) } }
这类测试比单测慢得多(要起数据库、要做真实 I/O),但能抓到单测抓不到的问题——SQL 语句写错了、字段类型不匹配、事务边界没处理对,这些问题只有真的跟数据库打交道才会暴露。
微服务场景下,集成测试会撞上一个规模问题 如果集成测试测的是两个你自己维护的服务之间的调用(服务 A 调服务 B 的接口),要让这个测试跑起来,两个服务都得同时部署、同时跑着。服务数量一多,这个成本会迅速失控:N 个服务两两之间可能都有调用关系,要把所有组合都跑一遍集成测试,理论上限是 N² 级别的部署组合——环境准备的复杂度跟着服务数量的平方增长,而不是线性增长。这也是为什么纯靠集成测试撑起微服务架构的测试策略,团队规模一大就会开始叫苦:光是维护”哪些服务要一起起来才能跑测试”这件事本身就成了负担。
契约测试:不用两边同时跑,也能验证接口对不对 契约测试解决的正是这个规模问题。它的思路是把”验证 A 调 B 对不对”这件事拆成两半,各自独立验证:消费方(调用方)声明自己期望的请求和响应长什么样,这份期望被记录成一份”契约”文件;提供方(被调用方)拿着这份契约,在不需要消费方真的跑起来 的情况下,验证自己的接口实现是不是满足这份约定。两边各自在自己的 CI 里独立跑验证,不需要同时部署。
以 Go 生态里的 Pact(pact-go)为例,消费方这边的测试长这样——用一个 Pact 提供的本地 mock server 代替真实的 provider:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 func TestProductAPIClient (t *testing.T) { mockProvider, _ := consumer.NewV2Pact(consumer.MockHTTPProviderConfig{ Consumer: "ProductAPIConsumer" , Provider: "ProductAPI" , }) mockProvider. AddInteraction(). Given("A Product with ID 10 exists" ). UponReceiving("A request for Product 10" ). WithRequest("GET" , "/product/10" ). WillRespondWith(200 ). WithBodyMatch(&Product{}) mockProvider.ExecuteTest(t, func (config consumer.MockServerConfig) error { client := newClient(config.Host, config.Port) product, err := client.GetProduct("10" ) assert.NoError(t, err) assert.Equal(t, "10" , product.ID) return err }) }
provider 一方独立验证,不需要 consumer 真的跑起来,只需要这份契约文件:
1 2 3 4 5 6 7 8 9 10 func TestProductAPIProvider (t *testing.T) { go startServer() verifier := provider.HTTPVerifier{} err := verifier.VerifyProvider(t, provider.VerifyRequest{ ProviderBaseURL: "http://localhost:1234" , PactFiles: []string {"./pacts/ProductAPIConsumer-ProductAPI.json" }, }) assert.NoError(t, err) }
这带来的直接好处是:契约测试的成本跟着”接口数量”线性增长,不是跟着”服务组合数量”平方增长——每加一个新的调用关系,只是多一份契约,不需要多起一整套联调环境。代价也很明确:契约测试只保证”A 和 B 对这个接口的理解一致”,它不测真实的业务副作用(比如调用创建订单接口之后,数据库里是不是真的多了一条订单记录)——这部分职责还是留给集成测试或者更下游的功能测试。契约测试这个概念是 Martin Fowler 十几年前提出的,随着微服务和 API 数量爆炸式增长,最近这些年才真正被大量团队用起来。
端到端测试:最像真实用户,也最慢最脆 端到端测试模拟真实用户从头到尾走一遍完整流程——注册、登录、下单、支付,跑在一个尽可能接近生产环境的测试环境里。这类测试给的信心是最强的,因为它验证的是整个系统真的能对外提供服务,不是某个孤立组件。跟前面几种测试不一样,端到端测试不 mock 任何东西,直接拿 HTTP 客户端打向一个真的部署起来的服务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 func TestUserOrderFlow_E2E (t *testing.T) { baseURL := os.Getenv("E2E_BASE_URL" ) registerResp, err := http.Post(baseURL+"/register" , "application/json" , strings.NewReader(`{"email":"e2e-test@example.com","password":"Passw0rd!"}` )) assert.NoError(t, err) assert.Equal(t, 201 , registerResp.StatusCode) loginResp, _ := http.Post(baseURL+"/login" , "application/json" , strings.NewReader(`{"email":"e2e-test@example.com","password":"Passw0rd!"}` )) var loginResult struct { Token string `json:"token"` } json.NewDecoder(loginResp.Body).Decode(&loginResult) req, _ := http.NewRequest("POST" , baseURL+"/orders" , strings.NewReader(`{"product_id":10,"qty":1}` )) req.Header.Set("Authorization" , "Bearer " +loginResult.Token) orderResp, _ := http.DefaultClient.Do(req) assert.Equal(t, 201 , orderResp.StatusCode) var order struct { ID int `json:"id"` } json.NewDecoder(orderResp.Body).Decode(&order) checkResp, _ := http.Get(baseURL + "/orders/" + strconv.Itoa(order.ID)) assert.Equal(t, 200 , checkResp.StatusCode) }
四步环环相扣,任何一步失败都说明”注册到下单”这条真实业务链路断在了某个环节——这正是它信心强的来源,也正是它慢、脆的来源:代价也最大,环境要接近生产,数据要提前准备好,一个环节抖动(网络延迟、第三方依赖不稳定)整个测试就可能失败,排查起来因为链路长,定位具体哪个环节出问题很费时间。
测试数量在这几类测试之间该怎么分配 ,业界公认的经验法则是 Mike Cohn 提出的”测试金字塔”:单元测试占大头(大约 60%-70%),集成测试次之(20%-30%),端到端测试最少(10%-20%)——越往金字塔顶端走,单个测试给的信心越强,但速度越慢、维护成本越高,所以应该越少。反过来堆砌大量端到端测试、单元测试反而写得很少的结构,业内戏称为”冰淇淋筒”(倒过来的金字塔),是一个公认的反模式:每次改动都要跑一大堆慢吞吞又容易莫名其妙失败的端到端用例,反馈周期被拉得很长,重构的胆子也会跟着变小。
怎么选:先问跨不跨边界,再问要不要同时起环境 回到最开始的判断标准:一段逻辑要不要写单测,看它跨不跨真实边界;跨了边界的两个组件之间要不要写集成测试,看这段交互本身有没有历史踩过坑的地方;如果这段边界是微服务之间的接口调用,且服务数量已经让”一起部署起来跑测试”变得吃力,契约测试是更划算的选择;端到端测试留给真正串联起完整业务价值的关键路径,不是每个功能点都值得用它覆盖。测试类型选错不是写多了浪费时间那么简单——错配的测试策略会让整个团队在改动代码的时候,要么因为没测到位而心虚,要么因为测试跑得太慢而不敢改。