完成自动化测试套件实施(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:
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user