فهرست منبع

test(装配): 首条上下文加载测试 - 装配缺陷不再等到启动那一刻才炸(台账 §八 R-5)

全仓 39 个测试文件、六种容器入口零命中:每个新增 @Service/@RestController/@Mapper
的装配(缺 bean / bean 二义 / mapper 不在 @MapperScan 范围)只在真正启动那一刻
第一次被检查,而测试套件保持全绿。本用例补上这张网,且不 mock 掉容器。

★ 裁定的判据自相矛盾:写的是「跑得通就留;跑不通(要真连 DB / Redis)就降级或关闭」,
把两支写成了互斥。实测两支同时成立 —— 它跑得通,也确实要真连 DB。只能实测后按意图落点。

实测(三个探针,各跑一次真容器,探针测完即删):
- 库指向死端口 ⇒ 红,根因是 dataSource bean 自己建不起来(本仓是 Druid 不是 Hikari,
  starter 建 bean 时就去取连接,不是懒到首次使用的语义)⇒ 全文测试必须挂着库隧道
- Redis 指向死端口 ⇒ 绿 ⇒ Redis 不是装配必要条件
- 不掐调度 ⇒ 上下文一 refresh 就立刻跑一轮(两个 @EnableScheduling + 7 个 @Scheduled
  方法全都没设 initialDelay),实测在 scheduling-1 线程上真打上游支付网关:
  /v3/queryorder 与 /v3/qrclose 各一次(200),并真写 Redis

两处按意图落地的偏离(都不是照字面做):
1. 不降级为 @ContextConfiguration 切片 —— 切片要手写一份 bean 清单,而清单的遗漏
   正是 R-5 要抓的缺陷类,让守卫依赖一份会过期的清单等于把它要防的东西请回来。
2. 在测试上下文里换掉 TaskScheduler(NeverRunsSchedules:只登记不执行)。这不属于
   裁定禁止的「mock 掉容器」:容器照常 refresh,每个业务 bean(含两个调度类)照常
   创建/注入/参与装配检查,被替掉的只是基础设施里的调度器。理由:测试套件不该在
   每次 mvn test 时朝支付网关发请求;而「需要真连库」那一半代价,裁定自己在括号里
   已经预料并接受了。

三条正对照(全是正向断言是有意的:「没发生」是自证的):
- 容器在用的 TaskScheduler 就是替换后的那个类 —— 挡「bean 没被采纳、真调度器照跑」
- @Scheduled 仍被发现(登记数 > 0)—— 挡「调度整个失效」,那时不发请求是因为压根
  没调度,绿灯会变成一句空话
- 调度类仍照常装配 —— 挡「为了不发请求,干脆把调度 bean 摘出容器」

读数(两向都测):
- 全量 418 绿 / 0 失败(改前 416,差 = 本类 2 条);scheduling- 线程 0 次、上游调用 0 次
- 变异 M-1(只摘 PaymentApplication 的 @EnableScheduling)⇒ 绿,但这是变异体不成立:
  全仓有两个 @EnableScheduling(PaymentApplication + core/scheduler/ScheduledTasks),
  摘一个调度仍活着
- 变异 M-1b(两个都摘)⇒ 1 红,正是「@Scheduled 仍被发现」那条
- 变异 M-3(摘掉 LsyOrderService 的 @Service)⇒ 2 errors,UnsatisfiedDependencyException
  (leshouyingNotifyController)⇒ 这张网确实能抓装配缺陷

已知代价(登记,不修):从今以后 mvn test 需要库隧道在;隧道断则整轮红,且红在
「上下文加载」而不是某个用例上。判读看异常类型:UnsatisfiedDependencyException /
NoSuchBeanDefinitionException = 装配缺陷;连接被拒 = 环境。
alphaH 3 هفته پیش
والد
کامیت
2693ab6f1a

+ 55 - 0
docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md

@@ -867,6 +867,61 @@ MyBatis-Plus 默认 NOT_NULL 策略会把 null 字段从 UPDATE 列清单里剔
 (`@ContextConfiguration(classes = …)`),或明确记录「因需要外部依赖而不可行」并关闭本条。
 **不得为了让它绿而给测试加 mock 掉容器** —— 那就把它要防的东西防掉了。
 
+#### ✅ 已实施(2026-09-22)—— 判据自相矛盾,按意图落地;两处偏离见下
+
+**交付物**:`src/test/java/com/payment/platform/PaymentApplicationContextTest.java`,2 个用例。
+全量回归 **418 绿 / 0 失败**(改前 416)。
+
+**★ 裁定的判据本身自相矛盾**(同 §八 R-2 那种「两半各自都对、合起来是错的」):
+上面写的是「**跑得通就留;跑不通(要真连 DB / Redis)就降级或关闭**」,把
+「跑得通」与「要真连 DB/Redis」写成了互斥的两支。实测**两支同时成立** ——
+它跑得通(BUILD SUCCESS),也**确实**要真连 DB。所以按字面无法判定,只能实测后按意图落点。
+
+**实测读数(三个探针,各跑一次真容器;探针文件测完即删)**
+
+| 探针 | 结果 | 根因 / 说明 |
+|---|---|---|
+| 库指向死端口(`spring.datasource.druid.url` → `127.0.0.1:59999`) | **红** | `dataSource` **bean 自己**建不起来:`org.postgresql…PSQLException: Connection refused`。本仓是 **Druid** 不是 Hikari,starter 在建 bean 时就去取连接(不是 Hikari 那种懒到首次使用的语义)⇒ **全文测试必须挂着库隧道** |
+| Redis 指向死端口(`spring.data.redis.port` → `6399`) | **绿** | Redis 不是装配的必要条件 |
+| 不掐调度(原样 `@SpringBootTest`) | 绿,但有对外副作用 | `@EnableScheduling` 在 `PaymentApplication` 上;全仓 **7 个类、7 个 `@Scheduled` 方法,0 个设 `initialDelay`** ⇒ 默认 0 ⇒ **上下文一 refresh 就立刻跑一轮**。实测运行期间在 `scheduling-1` 线程上**真打上游支付网关**:`/v3/queryorder`、`/v3/qrclose` 各一次(200),并真写 Redis |
+
+**两处按意图落地的偏离**(都不能照字面做;按 `constraint-docs-drift-anchor-by-name` 第 6 条,
+意图无歧义 ⇒ 落地 + 登记 + 报告,不构成决策点)
+
+1. **保留全文 `@SpringBootTest`,不降级为切片。** 裁定给的另一条出路是
+   `@ContextConfiguration(classes = …)` 切片,但它要求**手写一份 bean 清单** ——
+   而**清单的遗漏正是 R-5 要抓的那一类缺陷**(缺 bean / mapper 不在扫描范围)。
+   让守卫依赖一份会过期的清单,等于把它要防的东西请回来。
+2. **在测试上下文里换掉 `TaskScheduler`(`NeverRunsSchedules`:只登记不执行)。**
+   这一条**不属于**裁定禁止的「mock 掉容器」:容器照常 refresh,
+   **每一个业务 bean(含两个调度类)照常创建、照常注入、照常参与装配检查**,
+   被替掉的只是基础设施里的**调度器**。理由:**测试套件不该在每次 `mvn test` 时朝支付网关发请求** ——
+   而「需要真连库」那一半代价,裁定自己在括号里已经预料并接受了。
+
+**三条正对照**(全是正向断言是有意的:「没发生」是自证的,只有把「该在的还在」钉住才不是空话)
+
+| 断言 | 挡的空转方式 |
+|---|---|
+| 容器在用的 `TaskScheduler` 就是替换后的那个类 | bean 没被采纳、真调度器照跑(那时本用例会**向支付网关发请求却仍然绿**) |
+| `@Scheduled` 仍被发现(登记数 > 0) | 调度整个失效(`@EnableScheduling` 被摘)—— 那时「不发外部请求」是**因为压根没调度**,绿灯成空话 |
+| 调度类 `LsyReconcileScheduler` 仍照常装配 | 为了不发请求干脆把调度 bean 摘出容器,R-5 的装配网会漏掉它们 |
+
+**两向变异读数**
+
+| 变异 | 预期 | 实测 |
+|---|---|---|
+| M-1 只摘 `PaymentApplication` 的 `@EnableScheduling` | 应红 | **绿 —— 变异体不成立**:全仓有**两个** `@EnableScheduling`(`PaymentApplication` + `core/scheduler/ScheduledTasks`),摘一个调度还活着,② 绿得**正确** |
+| M-1b 两个 `@EnableScheduling` 都摘 | 应红 | **1 红**,正是「`@Scheduled` 仍被发现」那条 |
+| M-3 摘掉 `LsyOrderService` 的 `@Service`(制造装配缺陷) | 应红 | **2 errors**,`UnsatisfiedDependencyException`(`leshouyingNotifyController`)⇒ **R-5 的网确实能抓装配缺陷**,不再等到启动那一刻 |
+| 无害方向 | 应仍绿 | 三个被变异文件按 md5 还原到变异前字节;最终全量 **418 绿** |
+
+**★ M-1 那次「绿」差点被读成「正对照是摆设」** —— 实际上是变异体没奏效。
+「变异后仍绿」有两种含义(断言空洞 / 变异无效),**必须查清是哪一种再下结论**。
+
+**已知代价(登记,不修)**:从今以后 `mvn test` **需要库隧道在**;隧道断则**整轮红**,
+且红在「上下文加载」而不是某个用例上 —— 判读时看异常类型
+(`UnsatisfiedDependencyException` / `NoSuchBeanDefinitionException` = 装配缺陷;连接被拒 = 环境)。
+
 ### R-6 §一「必须问上游的 5 个问题」 → **整理为对外清单;其中一问的问法已作废**
 
 **结论**:

+ 172 - 0
java/src/test/java/com/payment/platform/PaymentApplicationContextTest.java

@@ -0,0 +1,172 @@
+package com.payment.platform;
+
+import com.payment.platform.module.payment.leshouying.scheduler.LsyReconcileScheduler;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.scheduling.TaskScheduler;
+import org.springframework.scheduling.Trigger;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 上下文加载测试 —— 全仓**第一条**碰 Spring 容器的测试(裁定 R-5)。
+ *
+ * 【为什么它存在】台账 §3.3 实测:`src/test` 下 39 个测试文件里,
+ * `@SpringBootTest` / `@WebMvcTest` / `@DataJpaTest` / `@MybatisPlusTest` /
+ * `@ContextConfiguration` / `@ExtendWith(SpringExtension.class)` **六种容器入口全部零命中** ——
+ * 全部单测走 Mockito,**不碰容器**。于是每个新增的 `@Service` / `@RestController` / `@Mapper`
+ * 的装配(缺 bean / bean 二义 / mapper 不在 `@MapperScan` 范围)**只在真正启动那一刻**
+ * 第一次被检查,而测试套件保持全绿。
+ *
+ * 【裁定 R-5 的约束,写在最前面】**不得为了让它绿而 mock 掉容器** ——
+ * 那就把它要防的东西防掉了。
+ *
+ * 【实测(2026-09-22)它需要什么】真连 PostgreSQL。本仓 `src/test/resources/` **不存在**,
+ * 测试直接吃 `src/main/resources/application.yml`(线上那份),其中 Druid starter 在
+ * 建 `dataSource` bean 时就会去拿连接(不是 Hikari 那种懒到首次使用的语义),
+ * 所以**跑本用例必须挂着库隧道**。这不是推测,是探针量出来的:
+ *   ① 把 `spring.datasource.druid.url` 指向死端口 ⇒ 上下文加载**失败**,
+ *      根因是 `dataSource` bean 本身(`...DruidDataSourceAutoConfigure`)抛
+ *      `PSQLException: Connection refused`;
+ *   ② 把 `spring.data.redis.port` 指向死端口 ⇒ 上下文**照常加载成功**(Redis 非装配必需)。
+ *
+ * 【为什么要换掉 `TaskScheduler`】全仓有**两个** `@EnableScheduling`
+ * (`PaymentApplication` 与 `core/scheduler/ScheduledTasks` —— 只摘掉一个调度仍然活着,
+ * 别拿它当「关掉调度」的开关),而 7 个类、7 个 `@Scheduled` 方法
+ * **没有一个设 `initialDelay`** ⇒ 默认 0 ⇒
+ * **上下文一 refresh 就立刻跑一轮**。实测:不换的话,本用例运行期间会真的
+ * 向**上游支付网关**发 HTTP(`/v3/queryorder`、`/v3/qrclose` 各一次,`scheduling-1` 线程上),
+ * 并真的写 Redis。测试套件不该在每次 `mvn test` 时朝支付网关发请求 —— 这是本测试里
+ * 唯一一处「不照裁定字面、按裁定意图落地」的地方,理由与读数见台账 §八 R-5。
+ *
+ * ⚠ 换 `TaskScheduler` **不是**上面禁的「mock 掉容器」:容器照常 refresh,
+ * 每一个业务 bean(含下面注入的那个调度类)都照常创建、照常注入、照常参与装配检查,
+ * 被替掉的只是基础设施里的**调度器**;换句话说,本用例仍然会因装配缺陷而红,
+ * 只是不会因为「跑了一轮定时任务」而向外发请求。
+ */
+@SpringBootTest
+class PaymentApplicationContextTest {
+
+    @Autowired
+    private ApplicationContext context;
+
+    @Autowired
+    private TaskScheduler scheduler;
+
+    @Autowired
+    private LsyReconcileScheduler reconcileScheduler;
+
+    /** 只登记、不执行。用来在测试上下文里掐掉全部定时任务。 */
+    static class NeverRunsSchedules implements TaskScheduler {
+
+        /** 被登记过的任务数 —— 供「调度确实还在被登记」这条正对照使用。 */
+        private final AtomicInteger registered = new AtomicInteger();
+
+        int registered() {
+            return registered.get();
+        }
+
+        @Override
+        public Clock getClock() {
+            return Clock.systemDefaultZone();
+        }
+
+        @Override
+        public ScheduledFuture<?> schedule(Runnable task, Trigger trigger) {
+            registered.incrementAndGet();
+            return null;
+        }
+
+        @Override
+        public ScheduledFuture<?> schedule(Runnable task, Instant startTime) {
+            registered.incrementAndGet();
+            return null;
+        }
+
+        @Override
+        public ScheduledFuture<?> scheduleAtFixedRate(Runnable task, Duration period) {
+            registered.incrementAndGet();
+            return null;
+        }
+
+        @Override
+        public ScheduledFuture<?> scheduleAtFixedRate(Runnable task, Instant startTime, Duration period) {
+            registered.incrementAndGet();
+            return null;
+        }
+
+        @Override
+        public ScheduledFuture<?> scheduleWithFixedDelay(Runnable task, Duration delay) {
+            registered.incrementAndGet();
+            return null;
+        }
+
+        @Override
+        public ScheduledFuture<?> scheduleWithFixedDelay(Runnable task, Instant startTime, Duration delay) {
+            registered.incrementAndGet();
+            return null;
+        }
+    }
+
+    @TestConfiguration
+    static class MuteScheduling {
+
+        /** 名字必须是 `taskScheduler` —— `ScheduledAnnotationBeanPostProcessor` 优先按这个名字找。 */
+        @Bean
+        TaskScheduler taskScheduler() {
+            return new NeverRunsSchedules();
+        }
+    }
+
+    @Test
+    void contextLoads() {
+        // 本用例的断言就是「上下文装配成功」本身:`@SpringBootTest` 在 JUnit 的
+        // `@BeforeEach` 之前就把容器 refresh 完,装配失败会**在进入本方法之前**抛异常。
+        // 所以这里不写断言不是遗漏 —— 写一条假的 assertTrue(true) 反而是噪音。
+        //
+        // ⚠ 但这也意味着:**它的红灯长得和「测试写错了」一样**,都发生在进方法之前。
+        //   判读时看异常类型:`UnsatisfiedDependencyException` / `NoSuchBeanDefinitionException`
+        //   / `BeanDefinitionOverrideException` 是装配缺陷(本用例要抓的),
+        //   连不上库是环境问题(本用例的已知依赖,见类注释)。
+    }
+
+    /**
+     * 三条正对照,各挡一种「本用例悄悄变成空转」的方式。
+     *
+     * 全是正对照、没有一条负断言是有意的:本类要防的事情(「不装配」/「偷偷跑定时任务」)
+     * 都是**不发生**的事件,而「不发生」是自证的 —— 只有把「该在的还在」钉住,
+     * 「没发生」才不是空话。
+     */
+    @Test
+    void schedulingIsStillWiredButNothingRuns() {
+        // ① 我那个调度器**就是**容器在用的那个 ⇒ 没有任何别的调度器能把任务真的跑起来。
+        //    挡的是「bean 没被采纳、真调度器照跑」——那种情况下本类会向支付网关发请求却仍然绿。
+        assertEquals(NeverRunsSchedules.class, scheduler.getClass(),
+                "容器里的 TaskScheduler 不是本测试替换的那个,定时任务可能真的跑起来了");
+
+        // ② `@Scheduled` 注解**仍然在被发现**(登记动作确实发生过)。
+        //    挡的是「调度整个失效了」——那时本类没向外交发请求是**因为压根没调度**,
+        //    而不是因为被掐住了,绿灯会变成一句空话(例如 `@EnableScheduling` 被摘掉)。
+        assertTrue(((NeverRunsSchedules) scheduler).registered() > 0,
+                "一个定时任务都没被登记 —— 调度很可能已经失效,本用例的『不发外部请求』是空转");
+
+        // ③ 调度类本身**照常装配**(R-5 要抓的那类缺陷仍在本用例的覆盖范围内)。
+        //    挡的是「为了不发请求,干脆把调度 bean 从容器里摘掉」这种走样 ——
+        //    那样一来 R-5 的装配网就漏掉了它们。
+        assertNotNull(reconcileScheduler,
+                "调度类没有被装配 —— 装配检查漏了它们");
+        assertNotNull(context.getBean(LsyReconcileScheduler.class));
+    }
+}