1.1 把正确性变成快速反馈
软件构造的第一项专业能力,是让错误尽早、稳定且低成本地暴露。编译器、静态分析、测试、代码审查和生产观测构成不同时间尺度的反馈环;前一层越可靠,后一层越少替它收拾低级错误。
从一个含糊函数开始
“计算两个日期相差几天”看似简单,规格难点是:
- 输入是日期还是带时区的瞬间?
- 允许结束日期早于开始日期吗?
- 返回有符号天数还是绝对值?
- 对
null怎么处理?
本节使用 Java 21 语言级别。先把下面的完整类保存为 src/main/java/StayLength.java;JUnit 练习环境在后面的测试段给出,其余短片段会标明依赖上下文。
若业务谈的是日历日期,Java 应优先使用 LocalDate,不要把 Date 的毫秒差除以 86,400,000。跨夏令时的“一天”未必等于固定 24 小时。
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
public final class StayLength {
private StayLength() {}
/** 返回 [arrival, departure) 覆盖的自然日数量。 */
public static long days(LocalDate arrival, LocalDate departure) {
Objects.requireNonNull(arrival, "arrival");
Objects.requireNonNull(departure, "departure");
if (departure.isBefore(arrival)) {
throw new IllegalArgumentException("departure must not precede arrival");
}
return ChronoUnit.DAYS.between(arrival, departure);
}
}类型选择已经消掉了时区与毫秒换算问题;方法名、区间约定和异常又把剩余语义固定下来。
反馈阶梯
| 层次 | 擅长发现 | 发现时间 | 局限 |
|---|---|---|---|
| 编译器 | 类型、语法、可达性、部分穷尽性 | 编辑/编译 | 不理解多数业务规则 |
| 静态分析 | 已知 bug pattern、资源泄露、错误 API 用法 | 本地/CI | 有误报,依赖规则覆盖 |
| 单元与性质测试 | 可执行行为、边界与代数性质 | 秒到分钟 | 只能覆盖生成/编写的输入 |
| 集成测试 | 数据库、序列化、进程边界 | 分钟 | 环境和诊断成本更高 |
| Review | 可读性、设计、风险和遗漏 | 合并前 | 人会疲劳,不能稳定模拟执行 |
| 生产观测 | 真实分布、容量和未知故障 | 发布后 | 代价最高,不能替代前置质量 |
这些层互相补充,让每类错误尽量在成本最低的阶段被捕获。
用类型排除非法状态
下面是依赖尚未定义的 transfer 方法的调用示意;三个字符串很难暴露参数角色:
transfer("account-1", "account-2", "100.00");值对象让编译器区分标识符与金额。把以下两个 public record 分别保存为 AccountId.java、Money.java,与前面的类放在同一源码目录,BigDecimal 的 import 属于 Money 文件:
import java.math.BigDecimal;
public record AccountId(String value) {
public AccountId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("account id is blank");
}
}
}
public record Money(BigDecimal amount, String currency) {
public Money {
if (amount == null || currency == null) {
throw new IllegalArgumentException("amount and currency are required");
}
if (amount.signum() < 0) {
throw new IllegalArgumentException("amount must be non-negative");
}
}
}这仍不能阻止两个 AccountId 参数互换:来源和目标若都是同一类型,编译器看不出业务方向。可以使用带角色名称的命令对象,或在确有风险时区分来源/目标类型,并用测试固定转账方向。
Money 仍不是完整货币库:还需定义小数位、舍入和币种合法性。但它至少阻止“任意字符串金额”进入核心接口。
Optional 适合作为“可能没有返回值”的返回类型,不应无差别用于实体字段和所有参数。对集合返回空集合通常比返回 null 更容易组合。
静态分析检查的是模式,不是证明
Java 中 == 对引用比较对象身份,.equals() 表达值等价;枚举与显式 sentinel 等场景又确实需要身份比较。Error Prone 的 ReferenceEquality 等规则会在它能识别的易错场景报警,不代表任何 == 都是错。
if (Objects.equals(expectedName, actualName)) {
// null-safe value equality
}枚举 switch 也要区分 statement 与 expression。以下方法放在业务类中,并假设同一作用域定义 enum Status { PENDING, RUNNING, DONE }。Java 21 的 switch expression 要求穷尽:
static int priority(Status status) {
return switch (status) {
case PENDING -> 10;
case RUNNING -> 20;
case DONE -> 30;
};
}增加枚举值并重新编译这段代码后,编译器会迫使这里作出决定。穷尽性也不代表可接受 null;本例传入 null 会抛 NullPointerException。随手写 default 虽能编译,却可能遮住未来新增分支;只有确有合理兜底语义时才使用。
静态分析规则应:
- 固定在构建配置中,开发机与 CI 一致;
- 新增规则先评估现存告警与误报;
- suppression 写在最小范围并说明理由;
- 把实际 bug pattern 与纯格式规则分开治理;
- 版本升级后阅读规则变化,不复制过时插件号。
测试先固定行为边界
在独立练习目录创建下面的 pom.xml,使用 JDK 21+、Maven 3.9+。版本用于固定本次练习,并非声称它们是最新版本。Maven 首次运行需能取得这些依赖。
<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>example</groupId><artifactId>contracts-lab</artifactId><version>1.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId>
<version>5.12.2</version><scope>test</scope>
</dependency>
</dependencies>
<build><plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId>
<version>3.5.3</version>
</plugin>
</plugins></build>
</project>把测试保存为 src/test/java/StayLengthTest.java;这里沿用默认包以减少练习配置,实际项目应使用命名包。
import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
class StayLengthTest {
@Test
void sameDayHasZeroNights() {
var day = LocalDate.of(2026, 8, 3);
assertEquals(0, StayLength.days(day, day));
}
@Test
void countsCalendarDaysAcrossMonthBoundary() {
assertEquals(2, StayLength.days(
LocalDate.of(2026, 7, 31),
LocalDate.of(2026, 8, 2)));
}
@Test
void countsLeapDay() {
assertEquals(2, StayLength.days(
LocalDate.of(2024, 2, 28), LocalDate.of(2024, 3, 1)));
}
@Test
void rejectsMissingDates() {
var day = LocalDate.of(2026, 8, 3);
assertThrows(NullPointerException.class, () -> StayLength.days(null, day));
assertThrows(NullPointerException.class, () -> StayLength.days(day, null));
}
@Test
void rejectsReverseRange() {
assertThrows(IllegalArgumentException.class, () ->
StayLength.days(
LocalDate.of(2026, 8, 4),
LocalDate.of(2026, 8, 3)));
}
}在含 pom.xml 的目录执行 mvn test,应发现 5 个测试,失败、错误和跳过数均为 0。这里只验证日历日期数量;如果需求改成“已持续满多少小时”,应重新定义瞬间与时区语义。JUnit 5.12.2
测试优先的价值是迫使接口在实现前接受例子检验,不是要求每一行代码都机械遵循同一仪式。修复缺陷时先写能复现缺陷的测试;探索未知 API 时可以先做 spike,再丢弃试验并从稳定契约重写。
小批量构造让反馈可解释
一次变更同时改接口、重写模块、升级依赖和格式化全仓时,即使测试失败也难以定位。可审查的批次通常满足:
one behavior change
+ its tests
+ necessary refactor
+ explicit migration/compatibility note每批都应能编译、运行相关测试,并说明失败模式。小步提交让因果关系更清楚,回退也更安全。
本节检查点
- 能用领域类型和穷尽分支消除一类非法状态。
- 能说明编译器、静态分析、测试和 review 各自的证据边界。
- 能把含糊需求变成可执行例子和明确失败语义。
参考
下一节把这些反馈落到对象内部:前置条件、后置条件和表示不变式怎样在失败中仍保持成立。