---
date: 2026-05-11
tags: [testing, integration-test, nestjs, response-format]
severity: medium
---

# 写 L1 集成测试时 res.body 不是 controller 返回值——TransformInterceptor + AllExceptionsFilter 双 wrap

## 现象

写工单 #295 的 L1 集成测试（`/api/v1/health/detailed`），第一版断言：

```ts
const res = await request(app.getHttpServer()).get('/api/v1/health/detailed');
expect(res.status).toBe(200);
expect(res.body.status).toBe('healthy');           // ← undefined
expect(res.body.services.database.status).toBe('up');  // ← TypeError
```

5/5 全挂，全是 `Received: undefined`。但是 503 路径 controller 日志清楚地显示 `ServiceUnavailableException` 抛出、状态码 503 正确。响应本体在传输中被改了。

## 根因

`testing/backend/helpers/app.helper.ts:createTestApp()` 跟 `backend/src/main.ts`
对齐，全局装了两层：

```ts
app.useGlobalFilters(new AllExceptionsFilter());
app.useGlobalInterceptors(new TransformInterceptor());
```

它们把所有 HTTP 响应统一改写：

**成功路径**（`TransformInterceptor.intercept` 的 `.pipe(map(...))`）：

```json
{
  "success": true,
  "data": <controller 真实返回值>,
  "message": "success",
  "timestamp": "...",
  "path": "/api/v1/..."
}
```

**异常路径**（`AllExceptionsFilter.catch`）：

```json
{
  "success": false,
  "error": {
    "code": "SERVICE_UNAVAILABLE",
    "message": "<exception.message>",
    "details": { <payload 里非保留字段>, ... }
  },
  "statusCode": 503,
  "path": "...",
  "method": "..."
}
```

关键陷阱：`throw new HttpException(payload)` 时，`AllExceptionsFilter`
把 payload 的所有字段（除 `message/error/code/statusCode/details` 这 5 个
保留字段外）**全部并进 `error.details`**——而不是直接放在 body 顶层。

所以 controller 里 `throw new ServiceUnavailableException(detailedStatus)`
传入的 `detailedStatus.status`、`detailedStatus.services` 这些**在 res.body
里位于 `error.details.status`、`error.details.services`**。

## 正确写法

集成测试需要一个分流读取的小 helper：

```ts
function readPayload(body: any): any {
  return body.success === false ? body.error.details : body.data;
}

const res = await request(app.getHttpServer()).get('/api/v1/health/detailed');
const payload = readPayload(res.body);
expect(payload.status).toBe('healthy');           // ✅
expect(payload.services.database.status).toBe('up');  // ✅
```

不要用 `res.status >= 400 ? error.details : data` 那种按 HTTP code 分流——
更可靠的判断是 `body.success` 字段（这是 wrap 层的明确出口），HTTP code 是
表层，wrap 层是真实出口。

## 适用范围

- 项目里所有 `*.integration.test.ts` 测的是 controller HTTP 行为，都会撞这个 wrap
- E2E 测试（前端 Playwright MCP）请求的也是 wrap 后的格式——前端代码里的
  `apiClient.ts` 已经处理过解 wrap，但测试代码绕过了前端 apiClient 直接 fetch
  时同样要懂这个 wrap
- 例外：装了 `@SkipTransform()` decorator 的路由（如 SSE）走原始 stream

## 引用

- `backend/src/common/interceptors/transform.interceptor.ts`
- `backend/src/common/filters/http-exception.filter.ts`
- `testing/backend/helpers/app.helper.ts`（与 main.ts 对齐）
- 本次实例：`testing/backend/integration/system/health.integration.test.ts`
