Old-School Backend / 2026 ・ No.14

古法后端入门

第14讲 测试 ── 让机器替我们记住改好的地方

发布

终端画面。bash mvnt.sh test 的结果。DbRulesTest 4 件 12.21 s、BudgetRaceTest 2 件、FileTypeTest 9 件、ReceiptRulesTest 6 件、AccessRulesTest 21 件、ExpensesApplicationTests 1 件、StrictExpenseRequestTest 14 件、ActionTest 42 件、ClaimRaceTest 1 件、V10ControllerTest 7 件。合计 Tests run: 107, Failures: 0, Errors: 0,BUILD SUCCESS,Total time 21.321 s
运行全部测试。10 个类共 107 件,约 21 秒。最花时间的是一开始启动数据库(笔者于 2026 年 10 月 31 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)

这个连载用了 13 讲,把申请应用修改了几十处。每次确认用的,都是笔者手动发送的请求。如果下个月有人(或者笔者自己)不小心改回去了,我们能发现吗?
这一讲从运行一开始就有的测试开始。写不启动 Spring 的测试、只有窗口的测试、使用真正 PostgreSQL 的测试、同时按下的测试,最后故意把 10 处修正改回原样,看测试能否发现。还会用到覆盖率,以及一个叫 PIT、用来检验测试本身的工具。

一

测试只有 1 件

新井测试一开始就有啊。mvn test 一直是绿的。

古井你看过里面写了什么吗?

全 15 讲路线图(当前:第 14 讲)

1 全景图 2 Java 准备 3 裸写 HTTP 4 裸写 JDBC 5 Spring Boot 6 输入检查 7 异常与回复 8 数据库访问 9 事务 10 状态流转 11 认证与授权 12 文件 13 日志与监控 14 测试 15 运行起来 当前位置 基础 徒手 入口 数据 业务与防护 运维
# start.spring.io 模板里一开始就有的测试(全部)
@SpringBootTest
class ExpensesApplicationTests {
    @Test
    void contextLoads() {
    }
}
# ① 在连不到练习用数据库的地方 mvn test
  Tests run: 1, Failures: 0, Errors: 1   (5.502 s)
  Caused by: java.net.UnknownHostException: db.internal
# ② 在能连到练习用数据库的地方
  Tests run: 1, Failures: 0, Errors: 0   (7.805 s)   BUILD SUCCESS
# ③ 把第 10、11 讲的 3 处修正改回原样后再来一次
  Tests run: 1, Failures: 0, Errors: 0   (5.187 s)   BUILD SUCCESS
  jar 也生成了expenses-0.0.1-SNAPSHOT.jar  34,810,862 B

第 1 讲用 start.spring.io 生成的模板里,只有 1 件测试。内容是空的,只确认“应用能否启动”。而且启动需要练习用数据库,所以在连不到数据库的地方会失败(①)。

在 ③ 中,把第 10 讲的“用 where 守住状态”、第 11 讲的“只能看可以看的申请”“不批准自己的申请”这 3 处改回了修正前的写法。这是一个能看到别人申请、能自己批准的应用。测试照样是绿的,jar 也生成了。“什么都没确认”也会是绿色。这一讲就从这里开始。

  • 不用 Spring ── 状态迁移表 30 种、文件开头、申请的标注(实测 0.5 秒)
  • 只有窗口 ── 负责人用替身,只看返回的形式(实测)
  • 真正的数据库 ── 用 Docker 启动 PostgreSQL 16。并发、权限、文件(实测)
  • 对答案 ── 把 10 处修正改回去、覆盖率、PIT(实测)

工具是 Spring Boot 模板自带的 JUnit Jupiter 6.0.3、AssertJ、Mockito,再加上 Spring Security 的测试组件和 Testcontainers 2.0.5。测试名用的是日语(JUnit 会把日语方法名原样显示在结果里),下文会在括号里附上中文意思。

二

最小的测试

先写不启动 Spring 也不启动数据库的测试。把第 10 讲的状态迁移表(6 个状态 × 5 个操作)原样抄下来。用 @ParameterizedTest 和 @CsvSource,表的 1 行就是 1 件测试。

/** 把第 10 讲的状态迁移表,6 个状态 × 5 个操作 = 30 种,原样抄下来 */
class ActionTest {
    @ParameterizedTest(name = "{0} で {1} → {2}")
    @CsvSource({
        // 当前状态,  操作,     去向(- 表示不行)
        "DRAFT,     SUBMIT,  SUBMITTED", "DRAFT,     APPROVE, -", "DRAFT,     RETURN, -", …
        "SUBMITTED, SUBMIT,  -", "SUBMITTED, APPROVE, APPROVED", "SUBMITTED, RETURN, RETURNED", …
        …(6 行 × 5 列 = 30 通り)
    })
    void 状態遷移表のとおり(ClaimStatus from, Action action, String to) {
        if (to.equals("-")) {
            assertThat(action.allowedFrom(from)).as("%s 应该不能 %s", from, action).isFalse();
        } else {
            assertThat(action.allowedFrom(from)).as("%s 应该可以 %s", from, action).isTrue();
            assertThat(action.to()).isEqualTo(ClaimStatus.valueOf(to));
        }
    }
}

用同样的方法,还写了第 12 讲的文件开头字节(7 种)和第 6 讲的严格标注(13 种)。

# 不启动 Spring 的 3 个测试(状态迁移表・文件开头字节・申请的标注)
$ bash mvnt.sh test -Dtest='ActionTest,FileTypeTest,StrictExpenseRequestTest'
  Tests run: 42   0.198 s   ActionTest
  Tests run:  7   0.355 s   FileTypeTest
  Tests run: 13   0.933 s   StrictExpenseRequestTest
  Tests run: 62, Failures: 0, Errors: 0   BUILD SUCCESS
# 第 1 次失败了 1 件。错的不是应用,而是笔者的想当然
  [10 桁]
  expected: "amount"
   but was: "amount,reasonGivenForLargeAmount"

62 件约 1.5 秒。第 1 次失败了 1 件,但错的不是应用,而是笔者。本以为“10 位数的金额”只会被 @Digits 拒绝,结果 10 亿日元也触发了“超过 5 万日元需要理由”。写测试,也是在检验自己对规格的记忆。改成写了理由的申请后就通过了。

POINT

用表定下的规格,就按表的样子写成测试。1 行 1 件,失败时“是哪一行”会直接出现在名字里。

三

只启动窗口

接下来是窗口(Controller)。@WebMvcTest 只启动窗口、把异常变成返回的负责人(第 7 讲的 ApiErrors)等 Web 周边。负责人(Service)用 @MockitoBean 换成替身,让它照剧本回答“这份申请是 submitted”。也读入了第 11 讲门卫的设置,走和生产环境一样的门。

/** 只启动第 10 讲的窗口。负责人(Service)是按剧本回答的替身 */
@WebMvcTest(V10Controller.class)
@Import({ApiErrors.class, SecurityConfig.class})
class V10ControllerTest {
    @Autowired MockMvcTester mvc;
    @MockitoBean ClaimFlowService flow;
    @MockitoBean NaiveFlowService naive;

    @Test
    void できない操作は409で今の状態とボタンを返す() {
        given(flow.act(eq("EXP-2026-9001"), eq(Action.PAY), any()))
                .willThrow(new IllegalTransitionException("EXP-2026-9001", ClaimStatus.SUBMITTED, Action.PAY));
        MvcTestResult r = mvc.post().uri("/api/v10/claims/EXP-2026-9001/pay").header("X-User", "keiri").exchange();
        assertThat(r).hasStatus(409);
        assertThat(r).bodyJson().extractingPath("$.status").isEqualTo("submitted");
        assertThat(r).bodyJson().extractingPath("$.actions").asArray().containsExactly("approve", "return", "reject");
    }

    @Test
    void 思わぬ失敗は500で問い合わせ番号は頼みごとの番号() {  // 第 13 讲
        given(flow.find("EXP-2026-9001")).willThrow(new IllegalStateException("DB が止まった"));
        MvcTestResult r = mvc.get().uri("/api/v10/claims/EXP-2026-9001").exchange();
        assertThat(r).hasStatus(500);
        String reqId = r.getResponse().getHeader("X-Request-Id");
        assertThat(r).bodyJson().extractingPath("$.errorId").isEqualTo("ERR-" + reqId);
        assertThat(r).bodyText().doesNotContain("DB が止まった");
    }
}
# 只有窗口的测试(7 件),运行 3 次
  ✓ 承認できたら200と次のボタン(批准成功 → 200 和下一步按钮)
  ✓ できない操作は409で今の状態とボタンを返す(不能做的操作 → 409,返回当前状态和按钮)
  ✓ 知らない操作名は400(不认识的操作名 → 400)
  ✓ ない申請は404(不存在的申请 → 404)
  ✓ ないURLは500ではなく404(不存在的 URL → 404 而不是 500)
  ✓ 中身の変更で金額がマイナスなら400と項目(修改内容时金额为负 → 400 和项目)
  ✓ 思わぬ失敗は500で問い合わせ番号は頼みごとの番号(意外失败 → 500,咨询编号 = 请求编号)
  Spring 启动0.82〜0.85 s7 件合计1.22〜1.34 s

请求不是真正的 HTTP,而是由 MockMvc 直接交给 Spring 内部。MockMvcTester 能用 AssertJ 的写法检查这个返回。第 13 讲的“不存在的 URL 是 404”“500 的咨询编号就是请求编号”也在这里确认了。因为不启动数据库,Spring 启动约 0.8 秒。

不过负责人是替身,所以SQL 对不对完全不知道。那是下一章的事。

四

用真正的数据库

想写用数据库的测试时,第一个绊脚石是“测试用的数据库怎么建”。数据库的结构,在源码里没有汇总在任何地方。

# 应用源码里的数据库结构(src/main/resources/schema.sql)
  1 张表(expense)和 1 个序号。还是第 1 讲的样子
# 练习用数据库里实际有的
  7 张表app_user  budget  expense  expense_history  expense_line  receipt  receipt_blob
  陆续追加的 SQLsetup4.sql  setup9.sql  setup10.sql  setup11.sql  setup12.sql  (手动用 psql 执行)
# 为测试取出现在的结构
$ pg_dump -s -O -x expenses > dump.sql            # 315 行
  → 去掉只用于练习的表(clock、batch_test)和面向 psql 的行
  src/test/resources/schema-test.sql              # 125 行,26 条语句

第 4 讲以后的表,都是手动执行每一讲的 SQL 加上的。应用源码里只有第 1 讲的那 1 张表。在新电脑上运行,第 4 讲之后的窗口会因为缺表缺列而无法工作。暂且用 pg_dump -s(只要结构)从练习用数据库里取出来,作为测试用的 DDL(本来应该用 Flyway 之类的工具,把变更按顺序留在源码里)。

数据库用 Testcontainers 在 Docker 里启动一个 PostgreSQL 16。加上 @ServiceConnection,Spring Boot 会自动接收连接地址。

/** 用 Docker 启动一个测试用的 PostgreSQL 16。连接地址由 Spring 自动接收 */
@TestConfiguration(proxyBeanMethods = false)
public class TestDb {
    @Bean
    @ServiceConnection
    PostgreSQLContainer postgres() {
        return new PostgreSQLContainer("postgres:16");
    }
}

/** 完整启动应用、连接真正 PostgreSQL 的测试标记 */
@SpringBootTest
@AutoConfigureMockMvc
@Import({TestDb.class, Claims.class})
@ActiveProfiles("test")
public @interface DbTest {
}

# 只在测试时追加的设置(数据库连接地址由 Testcontainers 填入)
spring.sql.init.schema-locations=classpath:schema-test.sql
# 数据库规则的测试(4 件),运行 3 次
  ✓ 金額0円はDBが断る(金额 0 日元 → 数据库拒绝)
  ✓ 打ち間違いの状態はDBが断る(状态拼错 → 数据库拒绝)
  ✓ 一円未満はDBが黙って丸める(不足 1 日元 → 数据库默默四舍五入)
  ✓ 同じ日同じ品目同じ金額は1件だけ(同日同品目同金额只能 1 件)
# 所用时间(Docker 镜像已拉取)
  清理器(ryuk)启动0.64〜0.80 s
  PostgreSQL 16 启动1.67〜1.81 s
  整个应用启动(含上面)9.3〜10.1 s
  这 4 件合计(含上面)11.5〜12.2 s

PostgreSQL 启动不到 2 秒,但算上整个应用启动约 10 秒,是最重的测试。即便如此,数据库的 CHECK(第 6、10 讲)、以及把不足 1 日元的部分默默四舍五入(第 6 讲),只有用真正的数据库才能确认。

作为测试用数据库,在 Java 里运行的 H2 也很常用。把同样的 DDL 交给它试试。

# ① 把同样的 DDL(schema-test.sql 的 26 条)交给 H2 2.4(PostgreSQL 模式)
  通过 8未通过 18
  CREATE TABLE public.expense (… CHECK (status = ANY ((ARRAY['draft', …])::text[])) …)
    Syntax error in SQL statement "… [*]])))"
  → expense 表建不起来,之后的索引、外键也接连失败
# ② 把表手动改写成 H2 能接受的形式后,试过的语句两边答案相同
                                      H2 2.4.240          PostgreSQL 16.15
  amount = 1280.125                   1280.13             1280.13
  status = 'aproved'                  CHECK 拒绝CHECK 拒绝
  amount = 1280.124                   1280.12             1280.12
  where item = '交通費 '0                   0
  select 7 / 2                        3                   3

PostgreSQL 的 DDL,在 H2 中 26 条有 18 条没通过。把表改写成 H2 能接受的形式后,这里试过的语句答案相同。但那意味着要手动再维护一份和生产不同的数据库结构。这一讲决定使用和生产相同的 PostgreSQL 16。

五

同时按下的测试

第 9、10 讲的修正,都是关于“同时来了怎么办”。按顺序一件件发送的测试,即使去掉 where 也不会失败(第 8 章会实际确认)。于是做了一个工具:准备 n 个线程,等所有人到齐后听信号一起开始。

/** 让 n 件工作听到信号后同时开始,返回成功的件数 */
public static int run(int n, Job job) throws Exception {
    ExecutorService pool = Executors.newFixedThreadPool(n);
    CountDownLatch go = new CountDownLatch(1);
    List<Future<Boolean>> results = new ArrayList<>();
    for (int i = 0; i < n; i++) {
        int k = i;
        results.add(pool.submit(() -> {
            go.await();  // 等所有人到齐
            try {
                job.run(k);
                return true;
            } catch (RuntimeException e) {
                return false;  // 相当于 409 的失败
            }
        }));
    }
    go.countDown();  // 预备,开始
    …
}

/** 第 10 讲:同时按批准和退回,也只有 1 件通过 */
@Test
void 承認50件と差戻し50件を同時に押しても通るのは1件() throws Exception {
    String id = claims.create("新井", "submitted");
    int ok = Race.run(100, i -> flow.act(id, i % 2 == 0 ? Action.APPROVE : Action.RETURN, "furui"));
    assertThat(ok).isEqualTo(1);
    assertThat(claims.historyRows(id)).isEqualTo(1);
}

这个测试能在多大程度上发现坏版本?以不用 where 把关的第 10 讲朴素版为对象,跑了很多轮来统计。

# 对不用 where 把关的朴素版(第 10 讲)同时发批准和退回。通过 2 件以上就算“发现了”
  同时件数有信号无信号用 where 把关的版本
  2             145 / 150         134 / 150         0 / 60
  10            144 / 150         136 / 150         0 / 60
  100           150 / 150         105 / 150         0 / 60
  (3 次 × 50 轮。把关版是 3 次 × 20 轮。数字是“通过 2 件以上”的轮数)

发现坏版本的比例(实测)

发现坏版本的比例(实测,有无信号各 150 轮) 0% 50% 100% 97% 89% 0% 同时 2 件 96% 91% 0% 同时 10 件 100% 70% 0% 同时 100 件 有信号(到齐再开始) 无信号(建好就开始) 用 where 把关的版本 并发测试即使代码坏了也可能通过。用信号让大家同时开始,件数多一些 把关版在 3 次 × 20 轮中每轮都只通过 1 件(2 件以上为 0 轮)

用信号让大家到齐,同时 100 件时 150 轮全部发现。没有信号、建好就跑的话,100 件也有 3 成漏掉。同时 2 件时,即使有信号,150 轮里也漏了 5 轮。并发测试即使代码坏了也可能通过。不要因为一次绿就放心,要让大家到齐,件数多一些。用 where 把关的版本,每一轮都只通过 1 件。

六

谁可以做什么

第 11 讲的“3 个人 × 5 个操作”表,也原样写成了测试。登录用 Spring Security 测试组件的 user(…)“当作已经以此人登录”,写入用的暗号用 csrf() 加上。门卫(SecurityConfig)、负责人、数据库都是真的。

/** 第 11 讲:谁可以对哪份申请做什么 */
@ParameterizedTest(name = "{0} が {1} → {2}")
@CsvSource({
    "arai,  submit, 200", "arai,  approve, 403", "arai,  return, 403", "arai,  reject, 403", "arai,  pay, 403",
    "furui, submit, 404", "furui, approve, 200", "furui, return, 200", "furui, reject, 200", "furui, pay, 403",
    "keiri, submit, 404", "keiri, approve, 403", "keiri, return, 403", "keiri, reject, 403", "keiri, pay, 200",
})
void 役割ごとにできる操作(String who, String action, int expected) {
    String id = claims.create("新井", READY.get(action));  // 先建一份处在可执行该操作状态的新井的申请,再按
    assertThat(mvc.post().uri("/api/v11/claims/{id}/{a}", id, action).with(user(USERS.get(who))).with(csrf()))
            .hasStatus(expected);
}

@Test
void 他人の申請は存在しないのと同じ404() {  // IDOR
    String furuis = claims.create("古井", "submitted");
    assertThat(mvc.get().uri("/api/v11/claims/{id}", furuis).with(user(USERS.get("arai")))).hasStatus(404);
}
# 第 11 讲门卫和负责人的测试(21 件)
  ✓ 役割ごとにできる操作(各角色能做的操作)× 15
  ✓ 他人の申請は存在しないのと同じ404(别人的申请 = 不存在 → 404)
  ✓ 自分の申請は承認できない(不能批准自己的申请)
  ✓ ヘッダーで名乗っても無視される(用请求头冒名也被忽略)
  ✓ ログインしていなければ401(未登录 → 401)
  ✓ 合言葉がなければ403(没有暗号(CSRF)→ 403)
  ✓ パスワードちがいといない利用者は同じ401(密码错误与不存在的用户同为 401)
  21 件合计0.66〜0.75 s(应用和数据库已在前面的测试中启动)

应用和数据库已在前面的测试中启动,所以 21 件不到 1 秒。因为 Spring 的测试会复用相同设置的启动。测试类有 10 个,但 Spring 只启动了 2 次(带数据库的,和只有窗口的)。

密码的测试只放了 1 个 {noop}(表示“原样字符串”的标记)的用户。不用真正的 bcrypt 散列(如第 11 讲所测,1 次约 80 ms)。

七

时钟与残留物

第 6 讲的“今天”实验,只能在东京 0〜9 点做。因为在 UTC 的 JVM 里,东京的今天还是“未来”。测试里,我们把时钟固定住。Hibernate Validator 有替换标注所用时钟的接口(ClockProvider)。

static Validator validator(Clock clock) {
    return Validation.byProvider(HibernateValidator.class).configure()
            .clockProvider(() -> clock).buildValidatorFactory().getValidator();
}

// 第 6 讲的“今天”:东京的 10/31 00:30,在 UTC 还是 10/30 15:30
static final Instant TOKYO_0030 = Instant.parse("2026-10-30T15:30:00Z");

@Test
void 東京の今日の申請は東京の時計なら通る() {
    Validator v = validator(Clock.fixed(TOKYO_0030, ZoneId.of("Asia/Tokyo")));
    assertThat(broken(v, new StrictExpenseRequest(LocalDate.of(2026, 10, 31), "交通費", BigDecimal.ONE, null))).isEmpty();
}

@Test
void UTCの時計だと東京の今日が未来になる() {
    Validator v = validator(Clock.fixed(TOKYO_0030, ZoneId.of("UTC")));
    assertThat(broken(v, new StrictExpenseRequest(LocalDate.of(2026, 10, 31), "交通費", BigDecimal.ONE, null))).isEqualTo("date");
}

把时钟固定在东京 10/31 00:30,同一份申请,用东京的时钟能通过,用 UTC 的时钟则以“未来的日期”被拒绝。这 2 件无论白天黑夜、在哪个国家运行,结果都一样。受当前时刻左右的测试,总有一天会失败。

还有一种东西会留在测试之外:第 12 讲的文件。

# 第 12 讲的测试(6 件)
  ✓ 置くときの名前は自分で付ける(存放时的文件名由自己起)
  ✓ 中身が画像でなければ415(内容不是图片 → 415)
  ✓ 同じ領収書は2度目が409(同一张收据第 2 次 → 409)
  ✓ 他人の申請には付けられない(不能附到别人的申请上)
  ✓ 他人の領収書は返さない(不返回别人的收据)
  ✓ DBに書けなかったらファイルも残さない(写不进数据库时也不留文件)
  6 件合计0.68〜0.82 s
# 第 8 章的实验中,把修正改回原样运行(R5)之后,同一个测试连续失败了 5 次
  ReceiptRulesTest.置くときの名前は自分で付けるR6 R7 R8 R9 R10
  target/test-store/written-outside.jpg   ← 还留着
  坏掉的版本写到存放位置外面的文件还留着

在第 8 章的实验中把第 12 讲的修正改回去时,坏版本照名字把文件写到了存放位置外面。修正恢复后,那个文件还留着,接下来 5 次测试明明应用是对的却失败了。测试不能被上次运行留下的东西左右。改成开始前先删除。数据库也一样,这一讲的测试每次都新建受理编号以 TST- 开头的申请,不碰别的测试的申请。

八

把修正改回原样

新井107 件,全是绿的!

古井第 1 章那 1 件也是绿的。弄坏了再确认吧。

从第 6 讲到第 13 讲的修正里选了 10 处,逐个改回“修正前的写法”,再运行全部 107 件(改回去、再恢复,都由脚本完成)。

终端画面。python3 revert14.py R2 R3 的结果。把 R2(第 11 讲 只能看可以看的申请)改回去后,107 件中 3 件失败,都是 expected: 404 but was: 200。把 R3(不批准自己的申请)改回去后,AccessRulesTest.自分の申請は承認できない 以 expected: 403 but was: 200 失败
把第 11 讲的修正逐个改回原样,再运行全部测试。别人的申请能看到时 3 件、能自己批准时 1 件,马上失败(笔者于 2026 年 10 月 31 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)
修正之处改回的写法失败的测试(件数)与失败时的信息
第 6 讲 去掉前后空白item = item.strip() → (—)StrictExpenseRequestTest(1)
expected: "交通費" but was: " 交通費 "
第 9 讲 不够就不扣and remaining >= #{amount} → (—)BudgetRaceTest(1)
expected: 100 but was: 150
第 10 讲 用 where 守住状态where id = #{id} and status = #{from} → where id = #{id}ClaimRaceTest(1)
expected: 1 but was: 3
第 10 讲 退回后可重新提交SUBMIT(SUBMITTED, DRAFT, RETURNED) → SUBMIT(SUBMITTED, DRAFT)ActionTest(2)
expected: "submit" but was: ""
第 11 讲 只能看可以看的申请<if test="!seeAll">and applicant = #{name}</if> → (—)AccessRulesTest, ReceiptRulesTest(3)
[HTTP status code] expected: 404 but was: 200
第 11 讲 不批准自己的申请if (… APPROVE && applicant == me) throw SelfApproval… → (—)AccessRulesTest(1)
[HTTP status code] expected: 403 but was: 200
第 11 讲 只有批准人能批准.hasRole("APPROVER") → .authenticated()AccessRulesTest(5)
[HTTP status code] expected: 403 but was: 200
第 12 讲 存放时的名字自己起dir.resolve(id) → dir.resolve(file.getOriginalFilename())ReceiptRulesTest(2)
[HTTP status code] expected: 200 but was: 500
第 12 讲 看内容辨别if (type == null) throw … 415 → type = file.getContentType()ReceiptRulesTest(1)
[HTTP status code] expected: 415 but was: 200
第 13 讲 不存在的 URL 是 404@ExceptionHandler(NoResourceFoundException.class) → (—)V10ControllerTest(1)
[HTTP status code] expected: 404 but was: 500

10 处都至少有 1 件测试失败。失败时的信息像“应该是 404 却是 200”那样,直接说出了发生了什么。去掉第 10 讲的 where 时,只有并发测试发现了(做了 5 次,5 次都发现)。逐件发送的测试中,迁移表的检查先拒绝了,所以没有 where 也不会出问题。

有意思的是去掉第 11 讲“只有批准人能批准”的时候。本应失败的 6 件中,新井批准的那 1 件没有失败。因为测试的申请是新井的,所以在“不批准自己的申请”那里变成了 403。防线有两道时,去掉其中一道,测试也可能通过。需要分别确认每道防线的行。

POINT

写好测试后,故意弄坏一次,看它失败。不会失败的测试,什么也没守住。

九

覆盖率与检验测试的工具

测试覆盖到哪里,可以用 JaCoCo 统计。它在测试运行期间,给经过的行和分支做标记。

# JaCoCo 0.8.14 的结果(运行 107 件测试时经过的行与分支)
  包行分支
  第 1〜8 讲(朴素版窗口多)71/172  41.3%     10/30  33.3%
  第 9 讲(预算・版本号)26/89  29.2%      4/32  12.5%
  第 10 讲(状态迁移)51/82  62.2%      5/16  31.2%
  第 11 讲(认证・授权)48/72  66.7%      16/22  72.7%
  第 12 讲(文件)59/71  83.1%      28/38  73.7%
  第 13 讲(编号)19/19  100.0%     5/8  62.5%
  合计274/505  54.3%    68/146  46.6%
# 按类(部分)
  Action                  13/13
  SecureClaimService      12/13
  ReceiptService          31/33
  FileType                4/4
  NaiveFlowService        3/15
  SeedUsers               0/17

整体 54%。看起来低,但第 1〜9 讲的朴素版(故意写坏的版本)和实验用窗口,本来就不是测试要守护的东西。想守护的类中,Action、SecureClaimService、ReceiptService 超过了 9 成。覆盖率是寻找“哪里没确认”的地图,不是分数。

不过,经过了某一行,不等于确认过。PIT 是这样一个工具:故意一点点改写代码(把 >= 改成 >,把 true 改成 false 等),每改一次就运行测试,看是否失败。就像让机器把第 8 章做几十遍。

PIT 报告画面。FileType.java 的第 9 行和第 11 行是红色。第 9 行的 9 个变异中第 7 个 changed conditional boundary 为 SURVIVED,第 11 行的 8 个中第 3 个 changed conditional boundary 为 SURVIVED,其余为 KILLED
PIT 的报告(加上边界行之前)。把 >= 改成 > 后没有任何测试失败的地方,留成了红色(笔者于 2026 年 10 月 31 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)
# PIT 1.20.4:把不启动 Spring 的测试所看的 4 个类,故意一点点改掉
  改动(变异) 42测试发现了 38 (90%)没发现 3测试没经过的地方 1
  SURVIVED  FileType.detect               line 9   h.length >= 3  →  h.length > 3
  SURVIVED  FileType.detect               line 11  h.length >= 5  →  h.length > 5
  SURVIVED  StrictExpenseRequest          line 27  amount.compareTo(50000) <= 0  →  < 0
# 加了 3 行边界
  "JPEG の印ちょうど3バイト| ffd8ff| image/jpeg"
  "%PDF- ちょうど5バイト| 255044462d  | application/pdf"
  "ちょうど 5 万円で理由なし| 出張 | 50000 | '' | ''"
  改动 42测试发现了 41 (98%)没发现 0测试没经过的地方 1

存活下来的改动(实测)

存活下来的改动(FileType 第 9 行) h.length >= 3 && FF D8 FF … h.length > 3 && FF D8 FF … 原来的代码 PIT 改过的代码 JPEG 10 字节 image/jpeg image/jpeg 相同 → 发现不了 ffd8(2 字节) null null 相同 → 发现不了 ffd8ff(3 字节) image/jpeg null 不同 → 发现 在加上 3 字节这一行之前,这个边界没有被任何测试确认过 只是经过了那一行,不等于“确认过”。在边界上各放一件 FileType 在 JaCoCo 的行覆盖是 4/4(100%)

在不用 Spring 的测试所看的 4 个类中,42 处改动里有 3 处没有任何测试发现。3 处都是边界:正好 3 字节的 JPEG、正好 5 字节的 PDF、正好 5 万日元。FileType 在 JaCoCo 里行覆盖是 100%,但边界一次也没试过。加上 3 行边界后,漏掉的变成 0(剩下的 1 处是测试没调用的小方法)。PIT 一次约 15 秒。如果连启动 Spring 的测试也包括进来,要多花好几倍,所以先用在表格和计算类上比较合适。

十

速度,以及何时运行

# 107 件测试运行 3 次。按种类统计件数与时间(取自 Maven 的输出)
  种类件数时间
  不用 Spring65      0.48〜0.54 s
  只有窗口(@WebMvcTest)7       1.22〜1.34 s
  整个应用+真正的数据库35      14.0〜14.8 s(其中启动 9.3〜10.1 s)
  Maven 整体(含编译等)107     20.6〜23.6 s
  启动 Spring 的次数2   (测试类有 10 个,设置相同的会被复用)

测试的 3 种类型

测试的 3 种类型(真实启动哪些部分) 门卫・窗口 负责人 仓库管理员 PostgreSQL 不用 Spring 不启动 直接调用类 65 件 0.48〜0.54 s 照抄的表 边界・标注 只有窗口 真实 替身(剧本) 不启动 不启动 7 件 1.22〜1.34 s 启动 0.82〜0.85 s 状态码 返回的形式 整个应用+数据库 真实 真实 真实 真实 35 件 14.0〜14.8 s 启动 9.3〜10.1 s SQL・并发・权限 文件 越往右越接近真实,但也越慢。快的测试多写,慢的测试放在要害处 件数和时间是 107 件运行 3 次的实测(Docker 镜像已拉取)

不用 Spring 的 65 件合计 0.5 秒,带数据库的 35 件 14 秒(其中 10 秒是启动)。快的测试多写,慢的测试放在要害处。每次保存时运行不用 Spring 的测试,push 之前或在 CI(每次进入共享仓库时自动运行的检查)里运行全部,这样分开就不会在意等待时间。只要 CI 里也能用 Docker,同一个 mvn test 就能把真正 PostgreSQL 的测试也跑起来。

想试的事写法
运行全部测试./mvnw test
只运行 1 个类./mvnw test -Dtest=ActionTest
覆盖率(target/site/jacoco/index.html)jacoco-maven-plugin 的 prepare-agent 和 report
PIT(限定目标类和测试)./mvnw org.pitest:pitest-maven:mutationCoverage -DtargetClasses=… -DtargetTests=…
真正数据库的测试先启动 Docker(Testcontainers 会启动 PostgreSQL)

先在自己的应用里选一处最让人害怕的修正,写 1 件确认它的测试,然后把修正改回去,看它失败。这就是这一讲的全部。

结 — 一页总结与下一讲

  1. 模板的测试只看能否启动。改回 3 处修正也是绿的。
  2. 用表定下的规格按表写成测试。不用 Spring 的话 65 件 0.5 秒。
  3. 只有窗口的测试确认返回的形式,真正数据库的测试确认 SQL、CHECK、四舍五入。数据库结构要放进源码。
  4. 并发测试用信号让大家到齐,件数多一些。即便如此也可能漏掉。
  5. 时钟要固定,残留物在开始前删除。
  6. 测试要故意弄坏、看它失败。10 处都被发现。覆盖率是地图,PIT 能找出边界的漏洞。

新井这样下个月谁来改都放心了。

古井在这些测试能运行的地方是这样。下一讲是最后一讲。把这个申请应用放到自己电脑之外运行。Docker、nginx、能扛住多少人,还有虚拟线程。

参考

记录是笔者于 2026 年 10 月 31 日,在云端 Linux(Ubuntu 24.04、内核 6.18)上,使用 Spring Boot 4.1.2(Spring Framework 7.0.10、Spring Security 7.1.2、MyBatis 3.5.19)、JUnit Jupiter 6.0.3、Testcontainers 2.0.5(Docker 29.9.2、postgres:16)、JaCoCo 0.8.14、PIT 1.20.4、H2 2.4.240、OpenJDK 25.0.4、PostgreSQL 16.15 取得的。时间取自 Maven 的输出,写的是 107 件运行 3 次的范围(Docker 镜像已拉取)。并发测试的轮数也是当时的实测。把修正改回原样的实验,由脚本改写并运行后再恢复。表中的数字和输出,全部是从当时命令输出中读取的实际值。登场人物均为虚构。

古法后端入门 第14讲 — 测试 / 2026.10

← 返回文章列表