完成自动化测试套件实施(W1-W11)

W1-W3: 基线修复与测试基础设施搭建
- 修复 Jenkins JDK 21 兼容性,统一 E2E 目录,修复 storageState 冲突
- 搭建后端测试基类 BaseContractTest + Testcontainers PostgreSQL
- 创建 TestDataFactory 链式构造,完善 Vitest 基座与 Playwright fixtures
- 建立 docker-compose.test.yml 与测试数据隔离方案

W4-W5: 单元测试补齐(阶段 2)
- 补齐 gym-member/gym-groupCourse/gym-checkIn/gym-payment 核心模块单元测试
- 补齐 gym-coach/manage-sys 模块单元测试
- 前端 utils/composables/stores 单元测试,37 文件 502 项测试
- JaCoCo 覆盖率门禁从 30% 调整至 55%,21 模块全部通过

W6-W7: 集成与契约测试(阶段 3)
- Repository 集成测试:会员/团课/签到/支付关键表,Testcontainers 100% 通过
- Handler 集成测试:WebTestClient 覆盖正向/异常/权限路径
- 网关集成测试:JWT/RBAC/签名/限流/重试
- Flyway 迁移测试:验证迁移脚本可重复执行
- OpenAPI 契约测试:覆盖 ≥80% P0 接口,202 项契约测试 0 失败
- 跨模块契约测试:会员-支付-团课数据一致性

W8-W9: E2E 与用户旅程测试(阶段 4)
- 管理员 Web 核心流程 E2E:用户/角色/菜单/字典/配置
- 小程序会员端核心页面 E2E:购卡/预约/签到
- 5 条 P0 用户旅程全链路自动化,60 条 journey 测试 0 失败

W10: 变异测试与质量门禁(阶段 5)
- 后端 PIT 配置:pitest-maven 1.19.1 + JUnit 5,覆盖率阈值 55%/变异阈值 45%
- P0 模块基线:manage-sys 48%,gym-member 30%,gym-payment 36%
- 前端 StrykerJS 配置:utils/stores 变异测试,dateFormat.ts 70.83%
- Jenkins 质量门禁:JaCoCo/PIT/E2E 统一检查,不达标阻断构建

W11: 持续运行与改进(阶段 6)
- 测试指标收集脚本 scripts/collect-test-metrics.py + HTML 看板生成器
- Flaky Test 治理 SOP:检测→隔离→根因分析→修复→验证闭环
- 测试资产定期评审流程:月度/季度/事件驱动三级机制
- 快速参考指南 docs/testing/quick-reference.md
- 累计 10 份测试文档,7 个里程碑全部达成
This commit was merged in pull request #54.
This commit is contained in:
2026-08-02 08:28:37 +08:00
parent dc68581c5e
commit 015cb0dc78
119 changed files with 21873 additions and 553 deletions
+14 -6
View File
@@ -1,16 +1,24 @@
import { test as setup } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
/**
* 管理员认证 setup。
*
* 将认证状态写入独立的 storageState 文件,避免与其他角色或项目共享同一文件
* 导致的并发/覆盖问题。未来如需会员/教练角色,可新增 auth-member.setup.ts、
* auth-coach.setup.ts,并在 playwright.config.ts 中配置对应 project 的
* storageState。
*/
const adminAuthFile = 'playwright/.auth/admin.json';
setup('authenticate', async ({ page }) => {
setup('authenticate as admin', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('networkidle');
await page.locator('input[placeholder*="用户名"]').fill('admin');
await page.locator('input[placeholder*="密码"]').fill('Test@123');
await page.locator('button:has-text("登录")').click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
await page.context().storageState({ path: authFile });
await page.context().storageState({ path: adminAuthFile });
});
+96
View File
@@ -0,0 +1,96 @@
import { test as dataTest, TestData } from './fixtures/test-data';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';
import { MemberManagementPage } from './pages/MemberManagementPage';
import { GroupCourseManagementPage } from './pages/GroupCourseManagementPage';
import { CoachManagementPage } from './pages/CoachManagementPage';
import { StatisticsDashboardPage } from './pages/StatisticsDashboardPage';
import { UserManagementPage } from './pages/UserManagementPage';
import { RoleManagementPage } from './pages/RoleManagementPage';
import { MenuManagementPage } from './pages/MenuManagementPage';
import { DictionaryManagementPage } from './pages/DictionaryManagementPage';
import { SystemConfigPage } from './pages/SystemConfigPage';
import { FileManagementPage } from './pages/FileManagementPage';
import { NotificationPage } from './pages/NotificationPage';
import { OperationLogPage } from './pages/OperationLogPage';
import { LoginLogPage } from './pages/LoginLogPage';
import { ExceptionLogPage } from './pages/ExceptionLogPage';
/**
* 统一 Playwright fixtures。
*
* <p>在 {@link TestData} 的基础上注入所有 Page Object,使新 E2E 用例不再直接写选择器。
* 测试文件统一通过 {@code import { test, expect } from '../fixtures'} 引入。</p>
*/
type PageFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
memberManagementPage: MemberManagementPage;
groupCourseManagementPage: GroupCourseManagementPage;
coachManagementPage: CoachManagementPage;
statisticsDashboardPage: StatisticsDashboardPage;
userManagementPage: UserManagementPage;
roleManagementPage: RoleManagementPage;
menuManagementPage: MenuManagementPage;
dictionaryManagementPage: DictionaryManagementPage;
systemConfigPage: SystemConfigPage;
fileManagementPage: FileManagementPage;
notificationPage: NotificationPage;
operationLogPage: OperationLogPage;
loginLogPage: LoginLogPage;
exceptionLogPage: ExceptionLogPage;
};
export const test = dataTest.extend<PageFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
memberManagementPage: async ({ page }, use) => {
await use(new MemberManagementPage(page));
},
groupCourseManagementPage: async ({ page }, use) => {
await use(new GroupCourseManagementPage(page));
},
coachManagementPage: async ({ page }, use) => {
await use(new CoachManagementPage(page));
},
statisticsDashboardPage: async ({ page }, use) => {
await use(new StatisticsDashboardPage(page));
},
userManagementPage: async ({ page }, use) => {
await use(new UserManagementPage(page));
},
roleManagementPage: async ({ page }, use) => {
await use(new RoleManagementPage(page));
},
menuManagementPage: async ({ page }, use) => {
await use(new MenuManagementPage(page));
},
dictionaryManagementPage: async ({ page }, use) => {
await use(new DictionaryManagementPage(page));
},
systemConfigPage: async ({ page }, use) => {
await use(new SystemConfigPage(page));
},
fileManagementPage: async ({ page }, use) => {
await use(new FileManagementPage(page));
},
notificationPage: async ({ page }, use) => {
await use(new NotificationPage(page));
},
operationLogPage: async ({ page }, use) => {
await use(new OperationLogPage(page));
},
loginLogPage: async ({ page }, use) => {
await use(new LoginLogPage(page));
},
exceptionLogPage: async ({ page }, use) => {
await use(new ExceptionLogPage(page));
},
});
export { expect } from '@playwright/test';
export type { TestUser, TestRole, TestMenu } from './fixtures/test-data';
+10 -10
View File
@@ -103,7 +103,7 @@ async function globalSetup(config: FullConfig) {
if (backendAlreadyRunning) {
console.log('✅ 后端服务已在运行,跳过启动');
} else {
const backendDir = path.resolve(__dirname, '../../novalon-manage-api/manage-app');
const backendDir = path.resolve(__dirname, '../../gym-manage-api/manage-app');
const jarFile = path.join(backendDir, 'target/manage-app-1.0.0.jar');
let backendCommand: string;
@@ -116,7 +116,7 @@ async function globalSetup(config: FullConfig) {
backendArgs = [
'-jar',
jarFile,
'--spring.profiles.active=test',
'--spring.profiles.active=e2e',
'-Xms256m',
'-Xmx512m'
];
@@ -124,7 +124,7 @@ async function globalSetup(config: FullConfig) {
console.log('📦 使用Maven启动后端服务...');
console.log(' 提示: 运行 "mvn clean package -DskipTests" 构建JAR文件以获得更快的启动速度');
backendCommand = 'mvn';
backendArgs = ['spring-boot:run', '-Dspring-boot.run.profiles=test'];
backendArgs = ['spring-boot:run', '-Dspring-boot.run.profiles=e2e'];
}
console.log(` 目录: ${backendDir}`);
@@ -133,9 +133,9 @@ async function globalSetup(config: FullConfig) {
backendProcess = spawn(backendCommand, backendArgs, {
cwd: backendDir,
stdio: 'pipe',
shell: true,
shell: false,
detached: false,
env: { ...process.env, SPRING_PROFILES_ACTIVE: 'test' }
env: process.env
});
if (backendProcess.stdout) {
@@ -174,7 +174,7 @@ async function globalSetup(config: FullConfig) {
if (gatewayAlreadyRunning) {
console.log('✅ 网关服务已在运行,跳过启动');
} else {
const gatewayDir = path.resolve(__dirname, '../../novalon-manage-api/manage-gateway');
const gatewayDir = path.resolve(__dirname, '../../gym-manage-api/manage-gateway');
const gatewayJarFile = path.join(gatewayDir, 'target/manage-gateway-1.0.0.jar');
let gatewayCommand: string;
@@ -204,9 +204,9 @@ async function globalSetup(config: FullConfig) {
gatewayProcess = spawn(gatewayCommand, gatewayArgs, {
cwd: gatewayDir,
stdio: 'pipe',
shell: true,
shell: false,
detached: false,
env: { ...process.env, SPRING_PROFILES_ACTIVE: 'dev' }
env: process.env
});
if (gatewayProcess.stdout) {
@@ -299,7 +299,7 @@ async function verifyAllServices(): Promise<void> {
}
async function waitForBackendReady(): Promise<void> {
const maxRetries = 90;
const maxRetries = 180;
const retryInterval = 1000;
for (let i = 0; i < maxRetries; i++) {
@@ -347,7 +347,7 @@ async function waitForBackendReady(): Promise<void> {
}
async function waitForGatewayReady(): Promise<void> {
const maxRetries = 90;
const maxRetries = 180;
const retryInterval = 1000;
for (let i = 0; i < maxRetries; i++) {
+45 -1
View File
@@ -1,3 +1,47 @@
import { globalTeardown } from './global-setup';
import { Client } from 'pg';
import { globalTeardown as stopServices } from './global-setup';
/**
* E2E 全局 teardown。
*
* 1. 停止 global-setup 启动的后端/网关服务;
* 2. 连接到测试 Postgres,删除并重建 `e2e` schema,确保每次 E2E 运行都在
* 独立、干净的数据集上执行,避免测试数据相互污染。
*
* 依赖 docker-compose.test.yml 中配置的 postgres 服务,以及
* application-e2e.yml 中指定的 `e2e` schema。
*/
async function globalTeardown() {
console.log('🧹 开始全局 E2E 测试环境清理...');
// 1. 停止由 global-setup 启动的进程
await stopServices();
// 2. 清理独立的 e2e schema
const client = new Client({
host: process.env.TEST_DB_HOST || 'localhost',
port: Number(process.env.TEST_DB_PORT || 5433),
database: process.env.TEST_DB_NAME || 'manage_system',
user: process.env.TEST_DB_USER || 'postgres',
password: process.env.TEST_DB_PASSWORD || '123456',
});
try {
await client.connect();
console.log('🗑️ 清理 e2e schema...');
await client.query('DROP SCHEMA IF EXISTS e2e CASCADE');
await client.query('CREATE SCHEMA e2e');
console.log('✅ e2e schema 已重置');
} catch (error) {
console.warn('⚠️ 重置 e2e schema 失败:', error instanceof Error ? error.message : error);
console.warn(' 下次 E2E 启动时 Flyway 会尝试重新初始化,请留意迁移状态。');
} finally {
await client.end().catch(() => {
// 忽略关闭连接时的错误
});
}
console.log('✅ 全局 E2E 测试环境清理完成');
}
export default globalTeardown;
@@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test';
test.describe('管理员完整工作流', () => {
test.use({ storageState: 'playwright/.auth/user.json' });
test.use({ storageState: 'playwright/.auth/admin.json' });
test.describe.configure({ mode: 'serial' });
const timestamp = Date.now();
@@ -1,49 +1,42 @@
import { test, expect } from '@playwright/test';
import { MemberManagementPage } from '../pages/MemberManagementPage';
import { test, expect } from '../fixtures';
test.describe.serial('会员管理完整CRUD旅程', () => {
let memberPage: MemberManagementPage;
const timestamp = Date.now();
const searchKeyword = `会员_${timestamp}`;
test.beforeEach(async ({ page }) => {
memberPage = new MemberManagementPage(page);
});
test('会员列表显示 + 搜索', async ({ page }) => {
test('会员列表显示 + 搜索', async ({ memberManagementPage }) => {
await test.step('导航到会员管理页面', async () => {
await memberPage.goto();
await memberManagementPage.goto();
});
await test.step('验证表格显示', async () => {
await expect(memberPage.table).toBeVisible({ timeout: 10000 });
await expect(memberManagementPage.table).toBeVisible({ timeout: 10000 });
});
await test.step('验证数据加载', async () => {
const rowCount = await memberPage.getTableRowCount();
const rowCount = await memberManagementPage.getTableRowCount();
console.log(`会员列表包含 ${rowCount} 条记录`);
expect(rowCount).toBeGreaterThanOrEqual(0);
});
await test.step('搜索会员', async () => {
await memberPage.search('测试');
await memberManagementPage.search('测试');
console.log('会员搜索完成');
});
});
test('编辑会员信息', async ({ page }) => {
test('编辑会员信息', async ({ memberManagementPage }) => {
await test.step('导航到会员管理页面', async () => {
await memberPage.goto();
await memberManagementPage.goto();
});
await test.step('等待数据加载', async () => {
await expect(memberPage.table).toBeVisible({ timeout: 10000 });
await expect(memberManagementPage.table).toBeVisible({ timeout: 10000 });
});
await test.step('点击第一个会员的编辑按钮', async () => {
const rows = await memberPage.getTableRowCount();
const rows = await memberManagementPage.getTableRowCount();
if (rows > 0) {
await memberPage.clickEditOnFirstRow();
await memberManagementPage.clickEditOnFirstRow();
console.log('编辑弹窗已打开');
} else {
console.log('当前没有会员记录,跳过编辑测试');
@@ -53,32 +46,32 @@ test.describe.serial('会员管理完整CRUD旅程', () => {
await test.step('修改会员信息', async () => {
const newNickname = `编辑昵称_${timestamp}`;
await memberPage.fillEditForm(newNickname, '女');
await memberManagementPage.fillEditForm(newNickname, '女');
console.log(`昵称已修改为: ${newNickname}`);
});
await test.step('提交表单', async () => {
await memberPage.submitForm();
await memberManagementPage.submitForm();
});
await test.step('验证更新成功', async () => {
await memberPage.waitForDialogClose();
await memberManagementPage.waitForDialogClose();
console.log('会员信息更新完成');
});
});
test('搜索验证编辑后的会员', async ({ page }) => {
test('搜索验证编辑后的会员', async ({ memberManagementPage }) => {
await test.step('导航到会员管理页面', async () => {
await memberPage.goto();
await memberManagementPage.goto();
});
await test.step('按昵称搜索', async () => {
await memberPage.search(`编辑昵称_${timestamp}`);
await memberManagementPage.search(`编辑昵称_${timestamp}`);
console.log('搜索编辑后的会员');
});
await test.step('验证搜索结果显示', async () => {
const rowCount = await memberPage.getTableRowCount();
const rowCount = await memberManagementPage.getTableRowCount();
console.log(`搜索结果显示 ${rowCount} 条记录`);
expect(rowCount).toBeGreaterThanOrEqual(0);
});