← 中文资源

简体中文版本

集成测试(Integration Testing)

集成测试让真实组件通过实际接口协作,覆盖数据库、消息队列和 HTTP 边界,验证单元测试刻意用替身跳过的那部分装配逻辑。

阅读英文原文

验证什么

集成测试让两个或多个真实组件通过实际接口协作:应用与数据库、生产者与消息代理、浏览器与 API。单元测试用替身替换这些协作者,因此发现不了列类型错误、缺失迁移、队列名配置错误或序列化不匹配。集成测试正是为了捕获这类装配缺陷。

窄集成与宽集成

宽集成测试启动许多服务、接近生产环境,但速度慢且难以定位故障。窄测试把边界控制得很小:一个服务加真实数据库,或一个消费者加真实消息代理。与端到端测试相比,窄集成测试运行更快,能直接指向损坏的接缝。多数团队需要许多窄测试和少量宽路径测试。

契约测试不能替代

契约测试证明消费者的期望与提供者接口一致,却不能证明提供者能连上数据库、凭证有效或事务已经提交。接口漂移用契约测试,真实基础设施行为用集成测试。

实践建议与权衡

用容器或临时环境提供依赖,按测试隔离数据并在运行之间重置状态,使用真实夹具而不是空 schema,断言持久化状态和发出的消息而不只是响应码。如果套件能在几分钟内完成,就在每个拉取请求上运行;否则放在合并或部署前。集成测试消耗基础设施和时间,也是不稳定测试的高发区。例如,重试三次才通过的套件可能掩盖代码或测试框架中的竞态。只在配置和真实协议带来风险的地方使用,其余保持快速。

代码示例

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))
}
package main

import (
	"errors"
	"testing"
)

var errNotFound = errors.New("order not found")

func ShippingCost(weightKg int, express bool, tier string) (int, error) {
	if weightKg <= 0 {
		return 0, errors.New("weight must be positive")
	}
	if weightKg > 30 {
		return 0, errors.New("weight exceeds courier limit")
	}

	base := 8
	if weightKg > 10 {
		base += (weightKg - 10) * 2
	}
	if express {
		base *= 2
	}
	if tier == "gold" {
		base -= 4
	}
	return base, nil
}

func TestShippingCost(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name    string
		weight  int
		express bool
		tier    string
		want    int
		wantErr error
	}{
		{name: "light standard", weight: 2, express: false, tier: "", want: 8},
		{name: "light express", weight: 2, express: true, tier: "", want: 16},
		{name: "heavy adds per kg", weight: 15, express: false, tier: "", want: 18},
		{name: "heavy express gold", weight: 15, express: true, tier: "gold", want: 32},
		{name: "gold floor at zero", weight: 1, express: false, tier: "gold", want: 4},
		{name: "zero weight rejected", weight: 0, express: false, tier: "", wantErr: errors.New("weight must be positive")},
		{name: "over courier limit", weight: 31, express: false, tier: "", wantErr: errors.New("weight exceeds courier limit")},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			got, err := ShippingCost(tt.weight, tt.express, tt.tier)
			if tt.wantErr != nil {
				if err == nil || err.Error() != tt.wantErr.Error() {
					t.Fatalf("ShippingCost(%d, %v, %q) error = %v, want %v",
						tt.weight, tt.express, tt.tier, err, tt.wantErr)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if got != tt.want {
				t.Errorf("ShippingCost(%d, %v, %q) = %d, want %d",
					tt.weight, tt.express, tt.tier, got, tt.want)
			}
		})
	}
}