Old-School Backend / 2026 ・ No.14
古法后端入门
第14讲 测试 ── 让机器替我们记住改好的地方
发布
这个连载用了 13 讲,把申请应用修改了几十处。每次确认用的,都是笔者手动发送的请求。如果下个月有人(或者笔者自己)不小心改回去了,我们能发现吗?
这一讲从运行一开始就有的测试开始。写不启动 Spring 的测试、只有窗口的测试、使用真正 PostgreSQL 的测试、同时按下的测试,最后故意把 10 处修正改回原样,看测试能否发现。还会用到覆盖率,以及一个叫 PIT、用来检验测试本身的工具。
测试只有 1 件
新井测试一开始就有啊。mvn test 一直是绿的。
古井你看过里面写了什么吗?
全 15 讲路线图(当前:第 14 讲)
# 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 万日元需要理由”。写测试,也是在检验自己对规格的记忆。改成写了理由的申请后就通过了。
用表定下的规格,就按表的样子写成测试。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 件以上”的轮数)
发现坏版本的比例(实测)
用信号让大家到齐,同时 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 件(改回去、再恢复,都由脚本完成)。
| 修正之处 | 改回的写法 | 失败的测试(件数)与失败时的信息 |
|---|---|---|
| 第 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。防线有两道时,去掉其中一道,测试也可能通过。需要分别确认每道防线的行。
写好测试后,故意弄坏一次,看它失败。不会失败的测试,什么也没守住。
覆盖率与检验测试的工具
测试覆盖到哪里,可以用 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 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
存活下来的改动(实测)
在不用 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 种类型
不用 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 件确认它的测试,然后把修正改回去,看它失败。这就是这一讲的全部。
结 — 一页总结与下一讲
- 模板的测试只看能否启动。改回 3 处修正也是绿的。
- 用表定下的规格按表写成测试。不用 Spring 的话 65 件 0.5 秒。
- 只有窗口的测试确认返回的形式,真正数据库的测试确认 SQL、CHECK、四舍五入。数据库结构要放进源码。
- 并发测试用信号让大家到齐,件数多一些。即便如此也可能漏掉。
- 时钟要固定,残留物在开始前删除。
- 测试要故意弄坏、看它失败。10 处都被发现。覆盖率是地图,PIT 能找出边界的漏洞。
新井这样下个月谁来改都放心了。
古井在这些测试能运行的地方是这样。下一讲是最后一讲。把这个申请应用放到自己电脑之外运行。Docker、nginx、能扛住多少人,还有虚拟线程。
参考
- Spring Boot — Testing Spring Boot Applications
- Spring Boot — Testcontainers
- Spring Framework — MockMvc AssertJ Integration(MockMvcTester)
- Spring Security — Testing with CSRF Protection
- JUnit User Guide — Parameterized Classes and Tests
- Testcontainers for Java — Postgres Module
- PIT — Maven Quick Start
- JaCoCo — Java Code Coverage Library
记录是笔者于 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