完成自动化测试套件实施(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
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-manage-api</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>manage-test-datafactory</artifactId>
<packaging>jar</packaging>
<name>Manage Test Data Factory</name>
<description>共享测试数据工厂,集中构造会员/教练/课程/订单/支付等业务的测试实体</description>
<dependencies>
<!-- 共享测试基类与 ID 生成器 -->
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-common-test</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 业务模块:会员、会员卡、会员卡记录 -->
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-member</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 业务模块:教练、违规记录 -->
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-coach</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 业务模块:团课、团课预约 -->
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-groupCourse</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 业务模块:支付订单 -->
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-payment</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 数据库实体:教练时间规则等 -->
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-db</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,78 @@
package cn.novalon.gym.manage.test.datafactory;
import cn.novalon.gym.manage.common.test.TestIds;
import cn.novalon.gym.manage.coach.entity.CoachViolationEntity;
import cn.novalon.gym.manage.db.entity.CoachTimeRuleEntity;
import java.time.LocalDateTime;
/**
* 教练相关测试数据工厂。
*
* <p>提供教练时间规则({@link CoachTimeRuleEntity})与违规记录({@link CoachViolationEntity})的默认构造。
* 由于当前系统中“教练”本身复用系统用户模型,这里以教练业务相关的核心实体为代表。</p>
*/
public final class CoachDataFactory {
private CoachDataFactory() {
// 工厂类禁止实例化
}
/**
* 构造一个默认的教练时间规则实体。
*/
public static CoachTimeRuleEntity defaultTimeRule() {
Long id = TestIds.nextId();
CoachTimeRuleEntity rule = new CoachTimeRuleEntity();
rule.setId(id);
rule.setMinDuration(30);
rule.setMaxDuration(120);
rule.setNormalWindow(10);
rule.setLateWindow(5);
rule.setEndGrace(10);
rule.setIsDefault(false);
rule.setSortOrder(0);
rule.setStatus("1");
rule.setRemark("测试教练时间规则-" + id);
rule.setCreateBy("test");
rule.setUpdateBy("test");
rule.setCreatedAt(LocalDateTime.now());
rule.setUpdatedAt(LocalDateTime.now());
return rule;
}
/**
* 构造一个匹配指定课程时长的教练时间规则实体。
*/
public static CoachTimeRuleEntity timeRuleForDuration(int minDuration, int maxDuration) {
CoachTimeRuleEntity rule = defaultTimeRule();
rule.setMinDuration(minDuration);
rule.setMaxDuration(maxDuration);
return rule;
}
/**
* 构造一条默认的教练违规记录。
*/
public static CoachViolationEntity defaultViolation() {
Long id = TestIds.nextId();
CoachViolationEntity violation = new CoachViolationEntity();
violation.setId(id);
violation.setCoachId(TestIds.nextId());
violation.setCourseId(TestIds.nextId());
violation.setViolationTime(LocalDateTime.now());
violation.setViolationReason("测试违规原因-" + id);
violation.setCreatedAt(LocalDateTime.now());
return violation;
}
/**
* 构造指定教练与课程的违规记录。
*/
public static CoachViolationEntity violationOf(Long coachId, Long courseId) {
CoachViolationEntity violation = defaultViolation();
violation.setCoachId(coachId);
violation.setCourseId(courseId);
return violation;
}
}
@@ -0,0 +1,106 @@
package cn.novalon.gym.manage.test.datafactory;
import cn.novalon.gym.manage.common.test.TestIds;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseBookingEntity;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseTypeEntity;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 课程相关测试数据工厂。
*
* <p>提供团课({@link GroupCourseEntity})、团课类型({@link GroupCourseTypeEntity}
* 与团课预约({@link GroupCourseBookingEntity})的默认构造。</p>
*/
public final class CourseDataFactory {
private CourseDataFactory() {
// 工厂类禁止实例化
}
/**
* 构造一个默认的团课类型实体。
*/
public static GroupCourseTypeEntity defaultCourseType() {
Long id = TestIds.nextId();
GroupCourseTypeEntity type = new GroupCourseTypeEntity();
type.setId(id);
type.setTypeName("瑜伽-" + id);
type.setBaseDifficulty(3);
type.setDescription("测试团课类型-" + id);
type.setCategory("有氧");
type.setCreatedAt(LocalDateTime.now());
return type;
}
/**
* 构造一个默认的团课实体。
*/
public static GroupCourseEntity defaultGroupCourse() {
Long id = TestIds.nextId();
LocalDateTime startTime = LocalDateTime.now().plusDays(1).withMinute(0).withSecond(0).withNano(0);
GroupCourseEntity course = new GroupCourseEntity();
course.setId(id);
course.setCourseName("瑜伽团课-" + id);
course.setCoachId(TestIds.nextId());
course.setCourseType(TestIds.nextId());
course.setStartTime(startTime);
course.setEndTime(startTime.plusHours(1));
course.setMaxMembers(20);
course.setCurrentMembers(0);
course.setActualStartTime(null);
course.setActualEndTime(null);
course.setStatus(0L);
course.setLocation("测试教室 A");
course.setCoverImage("https://example.com/course/" + id + ".png");
course.setDescription("测试团课描述-" + id);
course.setStoredValueAmount(BigDecimal.TEN);
course.setQrCodePath(null);
course.setCreatedAt(LocalDateTime.now());
return course;
}
/**
* 构造指定教练与类型的团课实体。
*/
public static GroupCourseEntity groupCourseOf(Long coachId, Long courseTypeId) {
GroupCourseEntity course = defaultGroupCourse();
course.setCoachId(coachId);
course.setCourseType(courseTypeId);
return course;
}
/**
* 构造一个默认的团课预约实体。
*/
public static GroupCourseBookingEntity defaultBooking() {
Long id = TestIds.nextId();
LocalDateTime startTime = LocalDateTime.now().plusDays(1);
GroupCourseBookingEntity booking = new GroupCourseBookingEntity();
booking.setId(id);
booking.setCourseId(TestIds.nextId());
booking.setMemberId(TestIds.nextId());
booking.setMemberCardRecordId(TestIds.nextId());
booking.setBookingTime(LocalDateTime.now());
booking.setStatus("0");
booking.setCancelTime(null);
booking.setCourseName("瑜伽团课-" + id);
booking.setCourseStartTime(startTime);
booking.setCourseEndTime(startTime.plusHours(1));
booking.setLocation("测试教室 A");
booking.setCreatedAt(LocalDateTime.now());
return booking;
}
/**
* 构造指定会员与课程的预约实体。
*/
public static GroupCourseBookingEntity bookingOf(Long memberId, Long courseId) {
GroupCourseBookingEntity booking = defaultBooking();
booking.setMemberId(memberId);
booking.setCourseId(courseId);
return booking;
}
}
@@ -0,0 +1,118 @@
package cn.novalon.gym.manage.test.datafactory;
import cn.novalon.gym.manage.common.test.TestIds;
import cn.novalon.gym.manage.member.entity.Member;
import cn.novalon.gym.manage.member.entity.MemberCard;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 会员相关测试数据工厂。
*
* <p>提供会员({@link Member})与会员卡({@link MemberCard})的默认构造与常见变体。
* 所有标识类字段均使用 {@link TestIds} 生成,避免唯一约束冲突。</p>
*/
public final class MemberDataFactory {
private MemberDataFactory() {
// 工厂类禁止实例化
}
/**
* 构造一个可正常使用的会员实体。
*/
public static Member defaultMember() {
Long id = TestIds.nextId();
Member member = Member.builder()
.memberNo(TestIds.nextMemberNo())
.nickname("会员-" + id)
.phone(TestIds.nextPhone())
.gender(1)
.birthday(LocalDate.of(1990, 1, 1))
.address("测试地址-" + id)
.subscribed(false)
.lastLoginAt(LocalDateTime.now())
.avatar("https://example.com/avatar/" + id + ".png")
.unionId(TestIds.nextCode("UNION"))
.miniappOpenId(TestIds.nextCode("MINI"))
.officialOpenId(TestIds.nextCode("OFFICIAL"))
.isDeleted(false)
.build();
member.setId(id);
member.setCreatedAt(LocalDateTime.now());
return member;
}
/**
* 构造指定手机号的会员实体。
*/
public static Member memberWithPhone(String phone) {
Member member = defaultMember();
member.setPhone(phone);
return member;
}
/**
* 构造一个默认上架的时长卡会员卡的实体。
*/
public static MemberCard defaultTimeCard() {
Long id = TestIds.nextId();
MemberCard card = MemberCard.builder()
.memberCardId(id)
.memberCardName("月卡-" + id)
.memberCardType("TIME_CARD")
.memberCardPrice(299.0)
.memberCardValidityDays(30)
.memberCardTotalTimes(null)
.memberCardAmount(null)
.memberCardStatus(1)
.extraConfig(null)
.build();
card.setId(id);
card.setCreatedAt(LocalDateTime.now());
return card;
}
/**
* 构造一个默认上架的次卡会员卡实体。
*/
public static MemberCard defaultCountCard() {
Long id = TestIds.nextId();
MemberCard card = MemberCard.builder()
.memberCardId(id)
.memberCardName("10 次卡-" + id)
.memberCardType("COUNT_CARD")
.memberCardPrice(499.0)
.memberCardValidityDays(null)
.memberCardTotalTimes(10)
.memberCardAmount(null)
.memberCardStatus(1)
.extraConfig(null)
.build();
card.setId(id);
card.setCreatedAt(LocalDateTime.now());
return card;
}
/**
* 构造一个默认上架的储值卡会员卡实体。
*/
public static MemberCard defaultStoredValueCard() {
Long id = TestIds.nextId();
MemberCard card = MemberCard.builder()
.memberCardId(id)
.memberCardName("储值卡-" + id)
.memberCardType("STORED_VALUE_CARD")
.memberCardPrice(null)
.memberCardValidityDays(null)
.memberCardTotalTimes(null)
.memberCardAmount(1000.0)
.memberCardStatus(1)
.extraConfig(null)
.build();
card.setId(id);
card.setCreatedAt(LocalDateTime.now());
return card;
}
}
@@ -0,0 +1,88 @@
package cn.novalon.gym.manage.test.datafactory;
import cn.novalon.gym.manage.common.test.TestIds;
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.entity.MemberCardTransaction;
import cn.novalon.gym.manage.member.enums.MemberCardRecordStatus;
import java.time.LocalDateTime;
/**
* 订单相关测试数据工厂。
*
* <p>当前系统中会员卡购买记录({@link MemberCardRecord})与会员卡交易流水
* {@link MemberCardTransaction})承担“订单”语义,本工厂据此提供默认构造。</p>
*/
public final class OrderDataFactory {
private OrderDataFactory() {
// 工厂类禁止实例化
}
/**
* 构造一个默认的会员卡购买订单(会员卡记录)。
*/
public static MemberCardRecord defaultMemberCardRecord() {
Long id = TestIds.nextId();
MemberCardRecord record = MemberCardRecord.builder()
.id(id)
.memberCardRecordId(id)
.memberId(TestIds.nextId())
.memberCardId(TestIds.nextId())
.status(MemberCardRecordStatus.ACTIVE)
.remainingTimes(10)
.remainingAmount(0.0)
.expireTime(LocalDateTime.now().plusDays(30))
.sourceOrderId(TestIds.nextId())
.purchaseTime(LocalDateTime.now())
.version(0)
.cardComposition(null)
.build();
record.setCreatedAt(LocalDateTime.now());
return record;
}
/**
* 构造指定会员与会员卡的购买订单。
*/
public static MemberCardRecord memberCardRecordOf(Long memberId, Long memberCardId) {
MemberCardRecord record = defaultMemberCardRecord();
record.setMemberId(memberId);
record.setMemberCardId(memberCardId);
return record;
}
/**
* 构造一个默认的会员卡交易流水。
*/
public static MemberCardTransaction defaultTransaction() {
Long id = TestIds.nextId();
return MemberCardTransaction.builder()
.id(id)
.createdAt(LocalDateTime.now())
.memberCardRecordId(TestIds.nextId())
.memberId(TestIds.nextId())
.memberCardId(TestIds.nextId())
.operationType("PURCHASE")
.changeAmount(10)
.changeBalance(0.0)
.afterRemainingCount(10)
.afterRemainingBalance(0.0)
.relatedBizType("MEMBER_CARD")
.sourceOrderId(TestIds.nextId())
.remark("测试交易-" + id)
.isArchived(false)
.archivedAt(null)
.build();
}
/**
* 构造指定会员卡记录与会员的交易流水。
*/
public static MemberCardTransaction transactionOf(Long memberId, Long memberCardRecordId) {
MemberCardTransaction transaction = defaultTransaction();
transaction.setMemberId(memberId);
transaction.setMemberCardRecordId(memberCardRecordId);
return transaction;
}
}
@@ -0,0 +1,93 @@
package cn.novalon.gym.manage.test.datafactory;
import cn.novalon.gym.manage.common.test.TestIds;
import cn.novalon.gym.manage.payment.entity.PaymentOrder;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 支付相关测试数据工厂。
*
* <p>提供支付订单({@link PaymentOrder})的默认构造与常见状态变体。
* 注意:{@link PaymentOrder#isNew()} 以 {@code createdAt} 是否为空判断,
* 因此默认构造不设置创建时间,便于在 Repository 测试中模拟新实体。</p>
*/
public final class PaymentDataFactory {
private static final DateTimeFormatter REQ_DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
private PaymentDataFactory() {
// 工厂类禁止实例化
}
/**
* 构造一个默认的待支付订单。
*/
public static PaymentOrder defaultUnpaidOrder() {
Long id = TestIds.nextId();
LocalDateTime now = LocalDateTime.now();
return PaymentOrder.builder()
.id(id)
.orderNo(TestIds.nextOrderNo())
.memberId(TestIds.nextId())
.orderType("MEMBER_CARD")
.goodsDesc("会员卡购买")
.transAmt(BigDecimal.valueOf(299))
.tradeType("JSAPI")
.payStatus("UNPAID")
.huifuId("6666000207573586")
.reqSeqId(TestIds.nextCode("REQ"))
.reqDate(now.format(REQ_DATE_FMT))
.hfSeqId(null)
.partyOrderId(null)
.payInfo(null)
.qrCode(null)
.payUrl(null)
.remark("测试支付订单-" + id)
.notifyUrl("http://localhost:8084/api/payment/notify")
.payTime(null)
.expireTime(now.plusMinutes(30))
.errorCode(null)
.errorMsg(null)
.createdAt(null)
.updatedAt(null)
.deletedAt(null)
.build();
}
/**
* 构造一个指定会员的待支付订单。
*/
public static PaymentOrder unpaidOrderOf(Long memberId) {
PaymentOrder order = defaultUnpaidOrder();
order.setMemberId(memberId);
return order;
}
/**
* 构造一个已支付订单。
*/
public static PaymentOrder defaultPaidOrder() {
PaymentOrder order = defaultUnpaidOrder();
LocalDateTime now = LocalDateTime.now();
order.setPayStatus("PAID");
order.setHfSeqId(TestIds.nextCode("HF"));
order.setPayTime(now);
order.setCreatedAt(now);
order.setUpdatedAt(now);
return order;
}
/**
* 构造一个已关闭订单。
*/
public static PaymentOrder defaultClosedOrder() {
PaymentOrder order = defaultUnpaidOrder();
order.setPayStatus("CLOSED");
order.setErrorCode("CLOSED");
order.setErrorMsg("订单已关闭");
return order;
}
}
@@ -0,0 +1,90 @@
package cn.novalon.gym.manage.test.datafactory;
import cn.novalon.gym.manage.common.test.UnitTestBase;
import cn.novalon.gym.manage.coach.entity.CoachViolationEntity;
import cn.novalon.gym.manage.db.entity.CoachTimeRuleEntity;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseBookingEntity;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseTypeEntity;
import cn.novalon.gym.manage.member.entity.Member;
import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.entity.MemberCardTransaction;
import cn.novalon.gym.manage.payment.entity.PaymentOrder;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* TestDataFactory 冒烟测试。
*
* <p>验证所有工厂类可正确构造非空实体,且唯一标识不重复。</p>
*/
class TestDataFactorySmokeTest extends UnitTestBase {
@Test
void shouldCreateUniqueMembers() {
Member m1 = MemberDataFactory.defaultMember();
Member m2 = MemberDataFactory.defaultMember();
assertThat(m1).isNotNull();
assertThat(m1.getId()).isNotNull();
assertThat(m1.getMemberNo()).isNotBlank();
assertThat(m1.getId()).isNotEqualTo(m2.getId());
assertThat(m1.getMemberNo()).isNotEqualTo(m2.getMemberNo());
}
@Test
void shouldCreateMemberCards() {
MemberCard timeCard = MemberDataFactory.defaultTimeCard();
MemberCard countCard = MemberDataFactory.defaultCountCard();
MemberCard storedCard = MemberDataFactory.defaultStoredValueCard();
assertThat(timeCard.getMemberCardType()).isEqualTo("TIME_CARD");
assertThat(countCard.getMemberCardType()).isEqualTo("COUNT_CARD");
assertThat(storedCard.getMemberCardType()).isEqualTo("STORED_VALUE_CARD");
}
@Test
void shouldCreateCoachEntities() {
CoachTimeRuleEntity rule = CoachDataFactory.defaultTimeRule();
CoachViolationEntity violation = CoachDataFactory.defaultViolation();
assertThat(rule.getId()).isNotNull();
assertThat(violation.getId()).isNotNull();
assertThat(rule.getMinDuration()).isLessThanOrEqualTo(rule.getMaxDuration());
}
@Test
void shouldCreateCourseEntities() {
GroupCourseTypeEntity type = CourseDataFactory.defaultCourseType();
GroupCourseEntity course = CourseDataFactory.defaultGroupCourse();
GroupCourseBookingEntity booking = CourseDataFactory.defaultBooking();
assertThat(type.getId()).isNotNull();
assertThat(course.getId()).isNotNull();
assertThat(booking.getId()).isNotNull();
assertThat(course.getStartTime()).isBefore(course.getEndTime());
}
@Test
void shouldCreateOrderEntities() {
MemberCardRecord record = OrderDataFactory.defaultMemberCardRecord();
MemberCardTransaction transaction = OrderDataFactory.defaultTransaction();
assertThat(record.getId()).isNotNull();
assertThat(record.getStatus()).isNotNull();
assertThat(transaction.getId()).isNotNull();
}
@Test
void shouldCreatePaymentOrders() {
PaymentOrder unpaid = PaymentDataFactory.defaultUnpaidOrder();
PaymentOrder paid = PaymentDataFactory.defaultPaidOrder();
assertThat(unpaid.getId()).isNotNull();
assertThat(unpaid.getOrderNo()).isNotBlank();
assertThat(unpaid.isNew()).isTrue();
assertThat(paid.isNew()).isFalse();
}
}