把单元换到集成时踩过的坑

技术栈大概是这样:

  • 后端:Go 1.21,gRPC + Gin
  • 数据库:PostgreSQL 14,Redis 7
  • 消息队列:Kafka 3.5
  • 测试框架:Go testing + testify + gomock

单元测试见效最快,成本最低,所以先从这里开始。

场景和背景

当时项目有六个核心服务:用户服务、订单服务、支付服务、库存服务、通知服务、推荐服务。每个服务都有独立的数据库,服务之间通过 gRPC 和 HTTP 两种协议通信。

测试覆盖率基本为零,CI 流水线只做了编译打包,部署到生产前没有自动化质量门禁。开发每次提测都得手动跑一遍主流程,有次改了订单状态流转逻辑,漏测了一个分支,线上导致订单卡在"待支付"状态。

技术栈大概是这样:

  • 后端:Go 1.21,gRPC + Gin
  • 数据库:PostgreSQL 14,Redis 7
  • 消息队列:Kafka 3.5
  • 测试框架:Go testing + testify + gomock

单元测试先落地

单元测试见效最快,成本最低,所以先从这里开始。选测试框架时看了几个选项:标准库的 testing 比较基础,但足够用;testify 提供了断言库和 mock,能少写不少样板代码;gomock 适合接口 mock,特别适合 gRPC 服务。

最后决定组合使用:testing 作为基础框架,testify 写断言,gomock 做接口 mock。

先给订单服务写几个单元测试示例。订单创建逻辑是这样的:

// order.go
package service

import (
	"context"
	"errors"
	"time"
)

type Order struct {
	ID          string
	UserID      string
	ProductID   string
	Amount      int64
	Status      string
	CreatedAt   time.Time
}

type OrderService struct {
	repo OrderRepository
	paymentClient PaymentClient
	stockClient StockClient
}

func (s *OrderService) CreateOrder(ctx context.Context, userID, productID string, amount int64) (*Order, error) {
	if amount <= 0 {
		return nil, errors.New("amount must be positive")
	}

	order := &Order{
		ID:        generateOrderID(),
		UserID:    userID,
		ProductID: productID,
		Amount:    amount,
		Status:    "created",
		CreatedAt: time.Now(),
	}

	if err := s.repo.Save(ctx, order); err != nil {
		return nil, err
	}

	return order, nil
}

对应的单元测试:

// order_test.go
package service

import (
	"context"
	"testing"
	"errors"
	
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
)

type MockOrderRepository struct {
	mock.Mock
}

func (m *MockOrderRepository) Save(ctx context.Context, order *Order) error {
	args := m.Called(ctx, order)
	return args.Error(0)
}

func TestCreateOrder_Success(t *testing.T) {
	mockRepo := new(MockOrderRepository)
	service := &OrderService{repo: mockRepo}

	mockRepo.On("Save", mock.Anything, mock.AnythingOfType("*service.Order")).Return(nil)

	order, err := service.CreateOrder(context.Background(), "user123", "prod456", 100)

	assert.NoError(t, err)
	assert.NotNil(t, order)
	assert.Equal(t, "user123", order.UserID)
	assert.Equal(t, "prod456", order.ProductID)
	assert.Equal(t, int64(100), order.Amount)
	assert.Equal(t, "created", order.Status)
	mockRepo.AssertExpectations(t)
}

func TestCreateOrder_InvalidAmount(t *testing.T) {
	mockRepo := new(MockOrderRepository)
	service := &OrderService{repo: mockRepo}

	order, err := service.CreateOrder(context.Background(), "user123", "prod456", 0)

	assert.Error(t, err)
	assert.Nil(t, order)
	assert.Contains(t, err.Error(), "amount must be positive")
	mockRepo.AssertNotCalled(t, "Save")
}

这里踩了个坑:最开始测试用例里直接用字符串比较时间,结果在 CI 环境里时区不一致导致测试失败。改成断言时间差小于 1 秒才算稳定。

另外一个问题是 mock 的使用边界。过度 mock 会让测试变脆弱,比如 mock 数据库返回的某个字段值,实际业务逻辑根本不依赖它,但测试断了这个字段,结果每次改数据库结构都要改测试。后来定了个规矩:只 mock 服务层真正依赖的接口行为,不要 mock 隔壁层的实现细节。

单元测试覆盖率目标是 70%,不是 100%。有些边界场景成本太高,或者业务逻辑简单到不值得测,就放过。三个月跑下来,订单服务的单元测试覆盖率从 0% 到 68%,每天至少发现一个回归问题。

集成测试的麻烦

单元测试解决的是单个服务内部的问题,但微服务真正麻烦的地方在服务之间的交互。集成测试有两种思路:真实环境集成和模拟环境集成。

真实环境集成是把所有服务都跑起来,用真实的数据库、Redis、Kafka。这种方式测出来最准,但成本高:每次跑测试要启动六个服务,加上依赖的基础设施,一套环境得 20 分钟左右。而且数据清理麻烦,测试之间互相干扰。

模拟环境集成是把这些依赖都 mock 掉,只测服务之间的调用关系。比如订单服务调用支付服务,就把支付服务 mock 掉,只验证订单服务发出的请求格式和错误处理。

我们走的是中间路线:用 Docker Compose 跑一个轻量的集成环境,包含数据库、Redis、Kafka,但服务本身用测试替身。这样既能测到真实的依赖行为,又不用启动全部服务。

订单服务和库存服务的集成测试配置:

# docker-compose.test.yml
version: '3.8'

services:
  postgres:
    image: postgres:14
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
    ports:
      - "5433:5432"

  redis:
    image: redis:7
    ports:
      - "6380:6379"

  kafka:
    image: confluentinc/cp-kafka:7.5.0
    environment:
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
    depends_on:
      - zookeeper

  zookeeper:
    image: confluentinc/cp-zookeeper:7.5.0
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181

集成测试代码:

// order_integration_test.go
package service_test

import (
	"context"
	"testing"
	"time"
	
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/suite"
	"yourproject/service"
)

type OrderIntegrationTestSuite struct {
	suite.Suite
	db           *sql.DB
	redis        *redis.Client
	orderService *service.OrderService
}

func (s *OrderIntegrationTestSuite) SetupSuite() {
	// 启动 Docker Compose 环境
	// 等待服务就绪
	// 初始化数据库连接
	// 初始化 Redis 连接
}

func (s *OrderIntegrationTestSuite) TearDownSuite() {
	// 清理测试数据
	// 关闭连接
}

func (s *OrderIntegrationTestSuite) SetupTest() {
	// 每个测试前清空数据库
	s.db.Exec("TRUNCATE TABLE orders")
	s.redis.FlushDB()
}

func (s *OrderIntegrationTestSuite) TestOrderCreationFlow() {
	ctx := context.Background()

	// 创建订单
	order, err := s.orderService.CreateOrder(ctx, "user123", "prod456", 100)
	assert.NoError(s.T(), err)
	assert.NotNil(s.T(), order)

	// 验证数据库持久化
	var dbOrder service.Order
	err = s.db.QueryRow("SELECT id, user_id, product_id, amount, status FROM orders WHERE id = $1", order.ID).Scan(
		&dbOrder.ID, &dbOrder.UserID, &dbOrder.ProductID, &dbOrder.Amount, &dbOrder.Status,
	)
	assert.NoError(s.T(), err)
	assert.Equal(s.T(), order.ID, dbOrder.ID)

	// 验证缓存
	cachedOrder, err := s.redis.Get(ctx, "order:"+order.ID).Result()
	assert.NoError(s.T(), err)
	assert.Contains(s.T(), cachedOrder, order.ID)
}

func TestOrderIntegrationSuite(t *testing.T) {
	suite.Run(t, new(OrderIntegrationTestSuite))

这里又踩了个坑:Docker Compose 的启动顺序不可靠。有个测试经常失败,原因是订单服务启动时,数据库还没就绪,导致连接失败。后来加了个健康检查,启动前先轮询数据库端口,直到能连上才算通过。

另一个问题是 Kafka 的问题。测试环境里 Kafka 的消息消费有延迟,有时候测试已经断言了结果,但消费者还没处理完消息,导致假失败。改成给测试加一个等待机制,直到消息被消费或者超时。

契约测试的必要性

服务之间改接口是家常便饭。某次支付服务重构,把支付回调的字段从 transaction_id 改成了 payment_id,结果订单服务没同步改,线上支付成功后订单状态一直没更新。

契约测试就是用来解决这种问题的:每个服务公开一份"契约",说明它接受什么请求、返回什么响应,然后消费者和提供者都按这个契约来。

我们用的是 Pact,但 Go 生态里用的人不多,最后自己搞了一个轻量的契约测试框架。核心思路是:服务启动时导出 OpenAPI 文档,然后消费者基于这个文档生成测试用例。

订单服务的契约定义:

// order_contract.go
package service

import (
	"github.com/getkin/kin-openapi/openapi3"
)

var OrderContract = &openapi3.T{
	OpenAPI: "3.0.0",
	Info: &openapi3.Info{
		Title:   "Order Service API",
		Version: "1.0.0",
	},
	Paths: openapi3.Paths{
		"/orders": &openapi3.PathItem{
			Post: &openapi3.Operation{
				Summary: "Create order",
				RequestBody: &openapi3.RequestBody{
					Required: true,
					Content: map[string]*openapi3.MediaType{
						"application/json": {
							Schema: openapi3.NewSchema().
								WithProperty("user_id", openapi3.NewStringSchema()).
								WithProperty("product_id", openapi3.NewStringSchema()).
								WithProperty("amount", openapi3.NewIntegerSchema()),
						},
					},
				},
				Responses: openapi3.Responses{
					"200": &openapi3.Response{
						Description: "Order created",
						Content: map[string]*openapi3.MediaType{
							"application/json": {
								Schema: openapi3.NewSchema().
									WithProperty("id", openapi3.NewStringSchema()).
									WithProperty("status", openapi3.NewStringSchema()),
							},
						},
					},
				},
			},
		},
	},
}

契约测试用例:

// order_contract_test.go
package service_test

import (
	"testing"
	"net/http"
	"net/http/httptest"
	
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestOrderContract_CreateOrder(t *testing.T) {
	// 基于 OpenAPI 文档生成测试用例
	reqBody := `{"user_id":"user123","product_id":"prod456","amount":100}`
	req := httptest.NewRequest("POST", "/orders", strings.NewReader(reqBody))
	req.Header.Set("Content-Type", "application/json")
	
	w := httptest.NewRecorder()
	handler.Handle(w, req)
	
	assert.Equal(t, http.StatusOK, w.Code)
	
	var resp map[string]interface{}
	err := json.Unmarshal(w.Body.Bytes(), &resp)
	require.NoError(t, err)
	
	assert.Contains(t, resp, "id")
	assert.Contains(t, resp, "status")
	assert.Equal(t, "created", resp["status"])
}

这套流程跑起来后,接口变更基本不会炸了。每次改接口时,CI 会自动检查契约是否兼容,不兼容就直接打回。

测试金字塔的实际样子

测试金字塔是个老概念:底部是大量单元测试,中间是适量集成测试,顶部是少量端到端测试。但实际落地时,发现这个比例要根据项目调整。

项目里的测试分布大概是:60% 单元测试,30% 集成测试,10% 端到端测试。单元测试跑得快,每次提交都能覆盖;集成测试稍微慢点,合并到 main 分支时跑;端到端测试每天跑一次,或者发布前跑。

有一次测试出了个怪现象:单元测试全过,集成测试全过,但端到端测试一直失败。最后发现是端到端测试的数据库连接池配置太小,并发量大时连接耗尽。这说明测试本身也需要测试,不然测半天测出个假结果。

graph TD A[测试金字塔] --> B[单元测试 60%] A --> C[集成测试 30%] A --> D[端到端测试 10%] B --> E[快速反馈<br/>每次提交] C --> F[验证集成<br/>合并到 main] D --> G[验证主流程<br/>发布前]

实际效果和反思

这套测试体系跑起来三个月,效果很明显:

  • 部署信心增强:从每次发布都提心吊胆,到现在基本可以自动发布
  • 回归问题减少:大概有 70% 的回归问题在 CI 环境里就被拦截了
  • 文档质量提升:契约测试倒逼接口文档保持更新
  • 团队协作变顺:改接口前先看契约,而不是改完再通知

但也不是没有问题:

  • 测试维护成本高:代码重构时,测试往往比业务代码改得还多
  • 测试本身有 bug:有时候测半天,最后发现是测试本身写错了
  • 测试环境不稳定:Docker Compose 偶尔会抽风,导致 false positive

另一个反思是:测试覆盖率不是越高越好。为了追求覆盖率,写了不少测试用例,但实际价值很低。比如一些简单的 getter/setter 方法,测试了也没什么意义。后来改了策略:只测试业务逻辑,不测 trivial code。

结尾

微服务测试这件事,说到底是在复杂度和质量之间找平衡。测试写多了,开发效率会下降;测试写少了,线上故障又会变多。

这个平衡点在哪里,每个项目都不一样。我们这次是"先做起来再说",根据实际情况调整。有些测试一开始觉得有必要,后来发现太慢或者不稳定,就砍掉了;有些一开始觉得没必要,后来踩了坑,又补上了。

技术方案都在变,但原则大概不变:测试是为了降低风险,不是为了证明代码是对的。如果一个测试不能帮你发现实际问题,那它可能就不该存在。

可用性说明:本文发布于 2021 年 4 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。

版权声明: 本文首发于 指尖魔法屋-把单元换到集成时踩过的坑https://blog.thinkmoon.cn/post/114-microservice-testing-unit-integration-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!