rwadurian/backend/services/wallet-service/docs/E2E-TESTING-WSL2.md

18 KiB
Raw Blame History

E2E Testing with WSL2 and Docker PostgreSQL

本文档记录了在 Windows + WSL2 环境下使用真实 PostgreSQL 数据库进行 E2E 测试的经验和最佳实践。

环境架构

┌─────────────────────────────────────────────────────────┐
│                    Windows Host                          │
│  ┌─────────────────────────────────────────────────────┐│
│  │                     WSL2                             ││
│  │  ┌─────────────────────────────────────────────────┐││
│  │  │              Docker Engine                       │││
│  │  │  ┌───────────────────────────────────────────┐  │││
│  │  │  │    PostgreSQL Container (172.17.0.x)      │  │││
│  │  │  │    Port: 5432                             │  │││
│  │  │  └───────────────────────────────────────────┘  │││
│  │  └─────────────────────────────────────────────────┘││
│  │                                                      ││
│  │  Node.js Application (测试运行环境)                  ││
│  │  通过 Docker 网络 (172.17.0.x) 连接 PostgreSQL      ││
│  └─────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘

重要发现

1. 网络连接问题

问题: 在 WSL2 内运行的 Node.js 应用程序无法通过 localhost:5432 连接到 Docker 容器内的 PostgreSQL。

原因: WSL2 的 localhost 和 Docker 容器的网络是隔离的。即使 Docker 端口映射到 0.0.0.0:5432WSL2 内的应用仍然无法通过 localhost 访问。

解决方案: 使用 Docker 容器的实际 IP 地址(通常是 172.17.0.x

# 获取容器 IP
docker inspect <container_name> --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"

# 示例输出: 172.17.0.2

2. 数据库连接配置

# 错误配置 (在 WSL2 中无法工作)
DATABASE_URL="postgresql://user:pass@localhost:5432/dbname"

# 正确配置 (使用 Docker 容器 IP)
DATABASE_URL="postgresql://user:pass@172.17.0.2:5432/dbname"

3. Windows 和 WSL2 网络隔离 (重要发现!)

关键发现: Windows 和 WSL2 之间存在网络隔离,这是在 E2E 测试中遇到的核心挑战。

三种运行环境的网络访问方式

测试运行位置 数据库地址 是否可用
WSL2 内部 172.17.0.x (Docker 容器 IP) 可用
WSL2 内部 localhost:5432 不可用
Windows 原生 localhost:5432 不可用 (无 Docker Desktop)
Windows 原生 WSL2 IP (172.24.x.x) 不可用 (网络隔离)
CI/CD (Linux) localhost:5432 可用

详细说明

从 Windows 访问 WSL2 中的 Docker 容器:

  • Windows 无法通过 localhost:5432 访问 WSL2 中的 Docker 容器
  • Windows 无法通过 WSL2 IP (如 172.24.157.5:5432) 访问
  • 原因: WSL2 使用 NAT 网络模式,网络与 Windows 隔离
# 测试 Windows 到 WSL2 的网络连接
Test-NetConnection -ComputerName 172.24.157.5 -Port 5432
# 输出: TCP connect to (172.24.157.5 : 5432) failed
# 输出: Ping to 172.24.157.5 failed with status: DestinationNetworkUnreachable

从 WSL2 访问 Docker 容器:

  • 不能使用 localhost
  • 必须使用容器的实际 IP 地址 (172.17.0.x)
# 获取 WSL2 的 IP 地址
hostname -I
# 输出: 172.24.157.5 172.19.0.1 172.17.0.1 172.18.0.1

# 获取 Docker 容器的 IP 地址
docker inspect wallet-postgres-test --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"
# 输出: 172.17.0.2

解决方案对比

方案 优点 缺点 推荐
在 WSL2 原生文件系统运行测试 性能最好,网络直连 需要复制代码
使用 Docker Desktop (Windows) localhost 可用 需要额外安装
CI/CD 环境 (GitHub Actions) 环境一致,网络简单 本地无法测试
Mock 测试 无需真实数据库 测试不全面

Docker 设置步骤

1. 创建 PostgreSQL 容器

# 在 WSL2 中运行
docker run -d \
  --restart=always \
  --name wallet-postgres-test \
  -e POSTGRES_USER=wallet \
  -e POSTGRES_PASSWORD=wallet123 \
  -e POSTGRES_DB=wallet_test \
  -p 5432:5432 \
  postgres:15-alpine

2. 验证容器运行状态

# 检查容器状态
docker ps

# 检查 PostgreSQL 是否就绪
docker exec wallet-postgres-test pg_isready -U wallet

3. 获取容器 IP

docker inspect wallet-postgres-test --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"

4. 推送 Prisma Schema

# 在 WSL2 中运行
cd /mnt/c/Users/<username>/path/to/project
export DATABASE_URL='postgresql://wallet:wallet123@172.17.0.2:5432/wallet_test?schema=public'
npx prisma db push --force-reset

E2E 测试运行

环境变量配置

根据不同的运行环境,需要配置不同的 DATABASE_URL:

# .env.test 配置 (根据运行环境选择)

# 1. 在 WSL2 中运行测试 - 使用 Docker 容器 IP
DATABASE_URL="postgresql://wallet:wallet123@172.17.0.2:5432/wallet_test?schema=public"

# 2. 在 CI/CD (GitHub Actions, Linux) 中运行 - 使用 localhost
DATABASE_URL="postgresql://wallet:wallet123@localhost:5432/wallet_test?schema=public"

# 3. 通用配置
JWT_SECRET="test-jwt-secret-key-for-e2e-testing"
NODE_ENV=test
PORT=3001

注意: NestJS ConfigModule 会根据 NODE_ENV 加载对应的 .env.{NODE_ENV} 文件,所以设置 NODE_ENV=test 会自动加载 .env.test

运行测试

# 在 WSL2 中运行 (确保 .env.test 使用容器 IP)
npm run test:e2e
# 或
npx jest --config ./test/jest-e2e.json --runInBand --forceExit

常见问题排查

问题 1: 无法连接数据库

Error: P1001: Can't reach database server at `localhost:5432`

解决方案:

  1. 确认 Docker 容器正在运行: docker ps
  2. 获取容器 IP: docker inspect <container> --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"
  3. 更新 DATABASE_URL 使用容器 IP

问题 2: 容器 IP 地址变化

每次重启 Docker 容器后IP 地址可能会改变。

解决方案:

  • 使用 Docker network 创建固定网络
  • 或在测试脚本中动态获取 IP
CONTAINER_IP=$(docker inspect wallet-postgres-test --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}")
export DATABASE_URL="postgresql://wallet:wallet123@$CONTAINER_IP:5432/wallet_test?schema=public"

问题 3: Jest 测试挂起

可能原因:

  • 数据库连接超时
  • 未正确关闭数据库连接
  • 异步操作未完成

解决方案:

  • 添加 --forceExit 参数
  • afterAll 中确保调用 app.close()
  • 增加 Jest 超时时间
// jest-e2e.json
{
  "testTimeout": 30000,
  "verbose": true
}

问题 4: 测试断言失败 - 响应结构不匹配

错误现象:

expect(res.body.data).toHaveProperty('walletId');
// 失败: Received path: []
// 但实际 res.body.data 包含 { walletId: "1", ... }

原因: 在 E2E 测试中手动添加了 TransformInterceptor,但 AppModule 已通过 APP_INTERCEPTOR 全局提供,导致响应被双重包装。

解决方案: 不要在测试中重复添加已由 AppModule 全局提供的 Filter 和 Interceptor。

// ❌ 错误做法 - 重复添加
app.useGlobalFilters(new DomainExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor());

// ✅ 正确做法 - 只添加 ValidationPipe
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }),
);
// DomainExceptionFilter 和 TransformInterceptor 已由 AppModule 提供

问题 5: WSL2 跨文件系统性能极差

错误现象:

# 在 WSL2 中访问 /mnt/c/ 运行测试
npm install  # 超时
npx jest     # 超时 (120秒+)

原因: WSL2 的 /mnt/c/ 是通过 9P 协议挂载的 Windows 文件系统I/O 性能比原生 Linux 文件系统慢 10-100 倍。

解决方案: 将项目复制到 WSL2 原生文件系统 (~/):

# 复制到 WSL2 原生文件系统后
npm install  # ~40秒 (vs 超时)
npx jest     # ~7秒 (vs 超时)

性能对比:

操作 /mnt/c/ (Windows) ~/ (WSL2 原生)
npm install 超时 40秒
Jest E2E 测试 超时 6.7秒
TypeScript 编译 极慢 正常

测试数据清理

async function cleanupTestData() {
  try {
    await prisma.ledgerEntry.deleteMany({ where: { userId: BigInt(testUserId) } });
    await prisma.depositOrder.deleteMany({ where: { userId: BigInt(testUserId) } });
    await prisma.settlementOrder.deleteMany({ where: { userId: BigInt(testUserId) } });
    await prisma.walletAccount.deleteMany({ where: { userId: BigInt(testUserId) } });
  } catch (e) {
    console.log('Cleanup error (may be expected):', e);
  }
}

自动化脚本

创建 scripts/test-e2e.sh:

#!/bin/bash

# 获取容器 IP
CONTAINER_IP=$(docker inspect wallet-postgres-test --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null)

if [ -z "$CONTAINER_IP" ]; then
    echo "Error: PostgreSQL container not running. Starting..."
    docker start wallet-postgres-test || docker run -d \
        --restart=always \
        --name wallet-postgres-test \
        -e POSTGRES_USER=wallet \
        -e POSTGRES_PASSWORD=wallet123 \
        -e POSTGRES_DB=wallet_test \
        -p 5432:5432 \
        postgres:15-alpine

    sleep 5
    CONTAINER_IP=$(docker inspect wallet-postgres-test --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}")
fi

echo "Using PostgreSQL at: $CONTAINER_IP"

export DATABASE_URL="postgresql://wallet:wallet123@$CONTAINER_IP:5432/wallet_test?schema=public"
export JWT_SECRET="test-jwt-secret-key-for-e2e-testing"
export NODE_ENV=test

# 推送 schema (如果需要)
npx prisma db push --skip-generate

# 运行测试
npx jest --config ./test/jest-e2e.json --runInBand --forceExit

最佳实践

  1. 使用独立的测试数据库: 不要在开发或生产数据库上运行 E2E 测试
  2. 每次测试前后清理数据: 确保测试隔离性
  3. 使用唯一的测试 ID: 避免与其他数据冲突
  4. 正确处理异步操作: 确保所有 Promise 都被等待
  5. 关闭数据库连接: 在 afterAll 中关闭应用和数据库连接
  6. 使用 --forceExit: 防止 Jest 挂起

参考命令

# 查看所有 Docker 容器
docker ps -a

# 查看容器日志
docker logs wallet-postgres-test

# 进入 PostgreSQL 容器
docker exec -it wallet-postgres-test psql -U wallet -d wallet_test

# 重置数据库
docker exec wallet-postgres-test psql -U wallet -c "DROP DATABASE IF EXISTS wallet_test; CREATE DATABASE wallet_test;"

性能问题

WSL2 跨文件系统访问慢

问题: 在 WSL2 中访问 /mnt/c/ (Windows 文件系统) 比访问 Linux 原生文件系统慢很多。

现象:

  • npm run buildnpx jest 启动很慢
  • 编译 TypeScript 需要很长时间
  • 测试可能因为 I/O 超时

解决方案:

  1. 首选: 将项目放在 WSL2 的原生文件系统中 (~/projects/ 而不是 /mnt/c/)
  2. 或者: 从 Windows 直接运行测试,但需要配置正确的数据库连接
# 复制项目到 WSL2 原生文件系统
cp -r /mnt/c/Users/<user>/project ~/project-wsl
cd ~/project-wsl
npm install
npm run test:e2e

单元测试 vs E2E 测试

由于上述性能问题,建议:

  1. 单元测试: 使用 Mock不需要真实数据库可以在 Windows 上快速运行
  2. E2E 测试: 使用真实数据库,适合 CI/CD 环境或原生 Linux 环境

当前状态总结

全部测试通过!

测试类型 数量 状态 运行时间
单元测试 69 通过 5.2s
E2E 测试 (真实数据库) 23 通过 6.7s

已完成

  • PostgreSQL Docker 容器创建和运行
  • 数据库 Schema 推送成功
  • 单独脚本可以成功连接数据库 (从 WSL2 内部)
  • 单元测试 (69 个) 全部通过
  • E2E 真实数据库测试 (23 个) 全部通过! 🎉

网络问题分析

  • Windows → WSL2 Docker: 网络不可达 (NAT 隔离)
  • WSL2 → Docker via localhost: 不可用
  • WSL2 → Docker via 容器 IP (172.17.0.x): 可用
  • 从 WSL2 原生文件系统运行测试: 性能极佳

解决方案总结

关键发现: 将项目复制到 WSL2 原生文件系统可以完美解决性能和网络问题!

# 1. 复制项目到 WSL2 原生文件系统
mkdir -p ~/wallet-service-test
cp -r /mnt/c/Users/<user>/project/src ~/wallet-service-test/
cp -r /mnt/c/Users/<user>/project/test ~/wallet-service-test/
cp -r /mnt/c/Users/<user>/project/prisma ~/wallet-service-test/
cp /mnt/c/Users/<user>/project/package*.json ~/wallet-service-test/
cp /mnt/c/Users/<user>/project/tsconfig*.json ~/wallet-service-test/
cp /mnt/c/Users/<user>/project/nest-cli.json ~/wallet-service-test/
cp /mnt/c/Users/<user>/project/.env.test ~/wallet-service-test/

# 2. 安装依赖 (~40秒)
cd ~/wallet-service-test
npm install

# 3. 生成 Prisma Client 并推送 Schema
export DATABASE_URL='postgresql://wallet:wallet123@172.17.0.2:5432/wallet_test?schema=public'
npx prisma generate
npx prisma db push --skip-generate

# 4. 运行 E2E 测试 (~7秒)
export JWT_SECRET='test-jwt-secret-key-for-e2e-testing'
export NODE_ENV=test
npx jest --config ./test/jest-e2e.json --runInBand --forceExit

推荐方案

  1. 本地开发: 将项目复制到 WSL2 原生文件系统运行真实数据库 E2E 测试
  2. CI/CD: 在 GitHub Actions 中直接使用 localhost 连接 PostgreSQL 服务
  3. 日常开发: 单元测试可在 Windows 上直接运行,无需数据库

CI/CD 集成建议

在 GitHub Actions 中运行真实数据库 E2E 测试:

# .github/workflows/e2e-tests.yml
name: E2E Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  e2e:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15-alpine
        env:
          POSTGRES_USER: wallet
          POSTGRES_PASSWORD: wallet123
          POSTGRES_DB: wallet_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5          

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Push Prisma schema
        run: npx prisma db push
        env:
          DATABASE_URL: postgresql://wallet:wallet123@localhost:5432/wallet_test?schema=public

      - name: Run E2E tests
        run: npm run test:e2e
        env:
          DATABASE_URL: postgresql://wallet:wallet123@localhost:5432/wallet_test?schema=public
          JWT_SECRET: test-jwt-secret
          NODE_ENV: test

测试文件结构

test/
├── jest-e2e.json           # E2E 测试配置
├── app.e2e-spec.ts         # 真实数据库 E2E 测试
└── (其他测试文件)

src/
├── domain/
│   ├── aggregates/*.spec.ts    # 领域聚合单元测试
│   └── value-objects/*.spec.ts # 值对象单元测试
└── application/
    └── services/*.spec.ts      # 应用服务集成测试 (Mock)

调试技巧

验证数据库连接

// test-db.js
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();

async function main() {
  console.log('DATABASE_URL:', process.env.DATABASE_URL);
  console.log('Connecting...');
  await prisma.$connect();
  console.log('Connected!');
  const result = await prisma.$queryRaw`SELECT 1 as test`;
  console.log('Query result:', result);
  await prisma.$disconnect();
}

main().catch(console.error);

验证 NestJS 应用启动

// test-nest-startup.js
const { Test } = require('@nestjs/testing');
const { AppModule } = require('./dist/app.module');

async function main() {
  const moduleFixture = await Test.createTestingModule({
    imports: [AppModule],
  }).compile();

  const app = moduleFixture.createNestApplication();
  await app.init();
  console.log('App initialized successfully!');
  await app.close();
}

main().catch(console.error);