把单元换到集成时踩过的坑
技术栈大概是这样:
- 后端: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 分支时跑;端到端测试每天跑一次,或者发布前跑。
有一次测试出了个怪现象:单元测试全过,集成测试全过,但端到端测试一直失败。最后发现是端到端测试的数据库连接池配置太小,并发量大时连接耗尽。这说明测试本身也需要测试,不然测半天测出个假结果。
实际效果和反思
这套测试体系跑起来三个月,效果很明显:
- 部署信心增强:从每次发布都提心吊胆,到现在基本可以自动发布
- 回归问题减少:大概有 70% 的回归问题在 CI 环境里就被拦截了
- 文档质量提升:契约测试倒逼接口文档保持更新
- 团队协作变顺:改接口前先看契约,而不是改完再通知
但也不是没有问题:
- 测试维护成本高:代码重构时,测试往往比业务代码改得还多
- 测试本身有 bug:有时候测半天,最后发现是测试本身写错了
- 测试环境不稳定:Docker Compose 偶尔会抽风,导致 false positive
另一个反思是:测试覆盖率不是越高越好。为了追求覆盖率,写了不少测试用例,但实际价值很低。比如一些简单的 getter/setter 方法,测试了也没什么意义。后来改了策略:只测试业务逻辑,不测 trivial code。
结尾
微服务测试这件事,说到底是在复杂度和质量之间找平衡。测试写多了,开发效率会下降;测试写少了,线上故障又会变多。
这个平衡点在哪里,每个项目都不一样。我们这次是"先做起来再说",根据实际情况调整。有些测试一开始觉得有必要,后来发现太慢或者不稳定,就砍掉了;有些一开始觉得没必要,后来踩了坑,又补上了。
技术方案都在变,但原则大概不变:测试是为了降低风险,不是为了证明代码是对的。如果一个测试不能帮你发现实际问题,那它可能就不该存在。
可用性说明:本文发布于 2021 年 4 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。
版权声明: 本文首发于 指尖魔法屋-把单元换到集成时踩过的坑(https://blog.thinkmoon.cn/post/114-microservice-testing-unit-integration-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。