# 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:5432`,WSL2 内的应用仍然无法通过 localhost 访问。 **解决方案**: 使用 Docker 容器的实际 IP 地址(通常是 172.17.0.x)。 ```bash # 获取容器 IP docker inspect --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" # 示例输出: 172.17.0.2 ``` ### 2. 数据库连接配置 ```bash # 错误配置 (在 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 隔离 ```powershell # 测试 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) ```bash # 获取 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 容器 ```bash # 在 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. 验证容器运行状态 ```bash # 检查容器状态 docker ps # 检查 PostgreSQL 是否就绪 docker exec wallet-postgres-test pg_isready -U wallet ``` ### 3. 获取容器 IP ```bash docker inspect wallet-postgres-test --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" ``` ### 4. 推送 Prisma Schema ```bash # 在 WSL2 中运行 cd /mnt/c/Users//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`: ```bash # .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`。 ### 运行测试 ```bash # 在 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 --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"` 3. 更新 DATABASE_URL 使用容器 IP ### 问题 2: 容器 IP 地址变化 每次重启 Docker 容器后,IP 地址可能会改变。 **解决方案**: - 使用 Docker network 创建固定网络 - 或在测试脚本中动态获取 IP ```bash 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 超时时间 ```json // 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。 ```typescript // ❌ 错误做法 - 重复添加 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 原生文件系统 (`~/`): ```bash # 复制到 WSL2 原生文件系统后 npm install # ~40秒 (vs 超时) npx jest # ~7秒 (vs 超时) ``` **性能对比**: | 操作 | /mnt/c/ (Windows) | ~/ (WSL2 原生) | |-----|-------------------|----------------| | npm install | 超时 | 40秒 | | Jest E2E 测试 | 超时 | 6.7秒 | | TypeScript 编译 | 极慢 | 正常 | ## 测试数据清理 ```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`: ```bash #!/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 挂起 ## 参考命令 ```bash # 查看所有 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 build` 和 `npx jest` 启动很慢 - 编译 TypeScript 需要很长时间 - 测试可能因为 I/O 超时 **解决方案**: 1. **首选**: 将项目放在 WSL2 的原生文件系统中 (`~/projects/` 而不是 `/mnt/c/`) 2. **或者**: 从 Windows 直接运行测试,但需要配置正确的数据库连接 ```bash # 复制项目到 WSL2 原生文件系统 cp -r /mnt/c/Users//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 原生文件系统可以完美解决性能和网络问题! ```bash # 1. 复制项目到 WSL2 原生文件系统 mkdir -p ~/wallet-service-test cp -r /mnt/c/Users//project/src ~/wallet-service-test/ cp -r /mnt/c/Users//project/test ~/wallet-service-test/ cp -r /mnt/c/Users//project/prisma ~/wallet-service-test/ cp /mnt/c/Users//project/package*.json ~/wallet-service-test/ cp /mnt/c/Users//project/tsconfig*.json ~/wallet-service-test/ cp /mnt/c/Users//project/nest-cli.json ~/wallet-service-test/ cp /mnt/c/Users//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 测试: ```yaml # .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) ``` ## 调试技巧 ### 验证数据库连接 ```javascript // 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 应用启动 ```javascript // 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); ```