1. 为什么“JSON解析”和“JUnit单元测试”总被放在一起练
但凡写过几行Java的人,迟早都会撞上这两个东西:一个是JSON解析,一个是JUnit单元测试。很多教程把它们拆成两个独立章节讲,结果新手学完还是不知道这俩到底怎么配合。其实在实际项目里,这两个技能几乎是绑在一起的——你写了一个解析JSON的方法,怎么证明它真的能正确解析?靠手动打印?靠肉眼比对?那迟早要出事。真正靠谱的做法,是给它配上一组JUnit测试用例,让机器帮你验证每一次改动。
我见过太多项目,JSON解析逻辑写得花里胡哨,嵌套对象、泛型集合、日期格式全都塞在一个方法里,但一个测试都没有。等到线上返回的JSON字段类型变了、某个字段突然为null、或者多了一层嵌套,整个解析直接抛异常,排查半天才发现是某个getString用错了地方。这种问题如果提前写了单元测试,本地跑一遍就能拦住。
所以这篇内容的核心,就是围绕“JSON解析 + JUnit单元测试”这个组合,把从环境搭建、解析方案选型、测试用例设计,到边界场景覆盖、常见坑排查的完整链路讲清楚。适合刚接触Java后端、Android、或者任何需要处理接口数据的开发者,也适合已经会写但测试写得稀烂、想系统补课的人。读完你至少能做到:拿到一段JSON,知道用哪种方式解析最稳;写完解析代码,知道怎么用JUnit把它测明白。
2. JSON解析方案怎么选:先搞清楚你要解决什么问题
2.1 三种主流解析方式的本质区别
Java里解析JSON,绕来绕去基本就三条路:原生手写解析、通用库解析、对象映射(反序列化)。很多人一上来就问“用哪个库好”,其实这个问题问错了,应该先问“我的JSON结构稳定吗、我要取多少字段”。
原生手写解析,指的是用字符串切割、正则、或者JSONObject这类基础API一层层取。它的优点是依赖少、可控性强,缺点是代码量大、嵌套一深就难维护。通用库解析,典型代表是各种JSON库提供的JSONObject、JSONArray树形模型,你可以像操作Map一样按key取值。对象映射则是把JSON直接转成Java对象,比如把{"name":"张三","age":18}直接变成一个User对象。
我个人的判断标准很简单:如果这个JSON结构固定、字段明确、会被反复使用,那就用对象映射;如果只是临时取一两个字段、结构还可能变,那就用树形模型。手写解析基本只在极端受限环境(比如不能引入任何第三方库)才考虑,日常项目不推荐。
2.2 选型时最容易忽略的三个维度
第一个维度是空值和缺失字段的处理。不同库对null、字段不存在、空字符串的默认行为不一样。有的库遇到字段缺失直接给null,有的会抛异常,有的会给你默认值。这个差异在写测试用例时特别关键,因为你的断言必须和库的实际行为对齐。
第二个维度是类型转换的宽容度。比如JSON里写的是"18"(字符串),但你的Java字段是int,有的库会自动转,有的直接报错。再比如时间字段,JSON里是"2024-01-01",你的字段是Date,不同库对日期格式的默认支持差别很大。
第三个维度是性能与内存。树形模型会把整个JSON加载到内存里构建对象树,大JSON会吃内存;流式解析(类似SAX思想)则是边读边处理,内存占用小但代码复杂。日常接口数据量不大时感知不明显,但如果你要处理几十MB的JSON文件,选型就必须考虑这一点。
2.3 一个真实的选型翻车案例
之前有个朋友做数据同步,接口返回的JSON里有个字段有时是数组、有时是单个对象。他一开始用对象映射,定义了一个List<Item>字段,结果遇到单个对象时直接解析失败。后来改成树形模型,先判断这个节点是数组还是对象,再分别处理,问题才解决。
这个案例说明:对象映射适合结构稳定的场景,结构不稳定时树形模型更灵活。而无论选哪种,你都需要JUnit测试来覆盖“数组情况”和“单对象情况”两种输入,否则这个坑迟早会在线上爆出来。
3. JUnit单元测试的正确打开方式
3.1 测试方法的基本结构:AAA模式
写JUnit测试,最忌讳的就是一个测试方法里塞一堆逻辑,最后自己都看不懂在测什么。我推荐始终遵循AAA模式:Arrange(准备)、Act(执行)、Assert(断言)。
Arrange阶段准备输入数据,比如构造一段JSON字符串;Act阶段调用你要测试的解析方法;Assert阶段验证结果是否符合预期。这三个阶段最好用空行隔开,让人一眼就能看出测试的意图。
举个简单例子,假设你有个方法parseUserName(String json),测试应该长这样:
@Test public void should_return_name_when_json_contains_name_field() { // Arrange String json = "{\"name\":\"张三\"}"; // Act String name = parser.parseUserName(json); // Assert assertEquals("张三", name); }方法名用should_xxx_when_xxx的格式,读起来就像一句话,测试失败时看名字就知道哪里出了问题。
3.2 断言不是越多越好,而是要精准
新手常犯的错是一个测试方法里写十几个assertEquals,结果第一个失败后面的都不执行,你根本不知道是全错还是只错了一个。正确的做法是一个测试方法只验证一个行为。如果你要验证解析出来的对象有五个字段,那就写五个测试方法,或者至少用assertAll把多个断言包起来,让它们全部执行完再报告结果。
另外,断言要具体。不要只断言“结果不为null”,而要断言“结果等于期望的具体值”。assertNotNull只能证明方法没返回空,证明不了它返回的是对的东西。
3.3 测试数据的管理:别把JSON写死在方法里
如果每个测试方法里都写一大段JSON字符串,代码会又臭又长,改一个字段要改十几个地方。我的做法是把测试用的JSON抽成常量或者放在@BeforeEach里初始化,甚至单独放一个测试资源文件。这样测试方法本身只关注“我要验证什么”,而不是“JSON长什么样”。
对于特别复杂的JSON,可以用文本块(Java 15+的"""语法)来写,可读性比转义字符串好太多。如果项目还在用老版本Java,那就老老实实用StringBuilder拼接,或者从resources目录读取。
4. 从零搭建一个可复现的JSON解析测试工程
4.1 环境与依赖准备
假设你用的是Maven项目,核心依赖就两块:JSON库和JUnit。JSON库我以最常见的Jackson为例(其他库思路完全一样),JUnit用5.x版本。pom.xml里加这几项:
<dependencies> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.10.0</version> <scope>test</scope> </dependency> </dependencies>这里有个细节:JUnit的scope要设成test,因为它只在测试阶段用,打包上线时不应该带进去。Jackson的版本尽量用较新的稳定版,老版本对某些边界情况(比如超大数字、特殊字符)处理有差异。
4.2 定义被测对象和解析方法
先定义一个简单的数据类,字段覆盖常见类型:字符串、整数、布尔、列表、嵌套对象。
public class Order { private String orderId; private int amount; private boolean paid; private List<String> items; private User buyer; // getter/setter 省略 } public class User { private String name; private Integer age; // getter/setter 省略 }解析方法就用Jackson的ObjectMapper:
public class OrderParser { private final ObjectMapper mapper = new ObjectMapper(); public Order parse(String json) throws Exception { return mapper.readValue(json, Order.class); } }注意readValue会抛异常,测试方法要么声明throws Exception,要么用assertThrows捕获。我倾向于在正常路径的测试里直接throws Exception,在异常路径的测试里用assertThrows。
4.3 编写第一组测试用例
先测最正常的场景:所有字段都有值。
@Test public void should_parse_all_fields_when_json_is_complete() throws Exception { String json = "{\"orderId\":\"A001\",\"amount\":100,\"paid\":true," + "\"items\":[\"book\",\"pen\"]," + "\"buyer\":{\"name\":\"张三\",\"age\":25}}"; Order order = parser.parse(json); assertEquals("A001", order.getOrderId()); assertEquals(100, order.getAmount()); assertTrue(order.isPaid()); assertEquals(2, order.getItems().size()); assertEquals("张三", order.getBuyer().getName()); }跑一遍,如果绿了,说明基本解析链路通了。但这才刚开始,真正有价值的是后面的边界测试。
4.4 参数计算与字段映射的验证思路
有人会问,测试里怎么验证“字段映射对不对”?比如JSON里的order_id要映射到Java的orderId。这时候要么在字段上加@JsonProperty("order_id")注解,要么配置全局的命名策略。测试时就要专门写一个用例,输入带下划线的JSON,断言解析出来的orderId有值。
再比如数字精度问题:JSON里写100.00,Java字段是double,解析出来是100.0还是100.00?这种细节必须用测试固定下来,否则不同库、不同版本行为可能不一样。我的习惯是,凡是涉及金额、比例这类敏感数字,测试里一定用BigDecimal并断言compareTo而不是equals,避免精度尾差导致误判。
5. 边界场景与异常路径的测试设计
5.1 空值、缺失字段、空字符串的区分
这是JSON解析测试里最容易漏的一块。三种情况必须分开测:
- 字段完全不存在:
{"amount":100} - 字段存在但值为null:
{"orderId":null,"amount":100} - 字段存在但值为空字符串:
{"orderId":"","amount":100}
对于String类型,前两种通常都得到null,第三种得到空字符串。对于int这种基本类型,如果字段缺失或为null,Jackson默认会给0,但如果你用的是包装类Integer,就会得到null。这个差异必须在测试里明确,否则业务代码里一个int字段被当成“有值”处理,就会出bug。
@Test public void should_return_zero_when_amount_field_missing() throws Exception { String json = "{\"orderId\":\"A001\"}"; Order order = parser.parse(json); assertEquals(0, order.getAmount()); }5.2 类型不匹配时的行为验证
JSON里是字符串"100",Java字段是int,Jackson默认会尝试转换,成功则得到100。但如果JSON里是"abc",就会抛InvalidFormatException。这类测试要用assertThrows:
@Test public void should_throw_when_amount_is_not_a_number() { String json = "{\"amount\":\"abc\"}"; assertThrows(Exception.class, () -> parser.parse(json)); }注意这里断言的是Exception.class,实际项目中最好断言具体的异常类型,比如InvalidFormatException,这样测试更精确,也能防止“抛了别的异常但测试还是绿的”这种假阳性。
5.3 嵌套对象和数组的边界
嵌套对象为null、数组为空、数组里元素为null,这三种情况都要覆盖。比如items字段是空数组[],解析出来应该是size为0的List而不是null;如果items字段缺失,取决于你的配置,可能是null也可能是空List。这些行为一旦确定,就用测试锁死,后续任何人改动代码导致行为变化,测试都会报警。
5.4 超长JSON和特殊字符
JSON里包含换行符、引号、反斜杠、中文、emoji时,解析是否正常?这些字符在字符串里需要转义,手写测试数据时很容易写错。我的建议是测试数据里专门放一段包含\n、\"、\\的字符串,验证解析后内容一致。至于超长JSON,可以写个循环生成几千个元素的数组,验证解析不超时、不栈溢出——这类测试不用每次都跑,可以标记为@Tag("slow")按需执行。
6. 常见问题与排查技巧实录
6.1 测试跑不过但代码看起来没问题
这种情况十有八九是测试数据和实际JSON格式有细微差异。比如JSON里多了一个逗号、少了一个引号、或者用了中文引号。排查方法很简单:把测试里的JSON字符串打印出来,复制到在线的JSON校验工具里过一遍,格式错误立刻现形。
另一个常见原因是依赖版本冲突。比如项目里同时引入了两个不同版本的JSON库,运行时加载的是A版本,但你按B版本的文档写测试,行为自然对不上。用mvn dependency:tree看一下依赖树,排除掉多余版本。
6.2 断言失败但期望值和实际值“看起来一样”
这通常是类型不一致导致的。比如期望值是Integer的100,实际值是Long的100,assertEquals就会失败。或者期望值是字符串"100",实际值是数字100。解决办法是打印两边的getClass(),确认类型后再调整断言。
还有一种情况是浮点数精度。assertEquals(0.1+0.2, 0.3)永远失败,因为浮点运算有误差。涉及小数时用assertEquals(expected, actual, delta)带误差范围,或者干脆用BigDecimal。
6.3 测试之间互相影响
JUnit默认每个测试方法都会新建一个测试类实例,所以实例变量不会互相干扰。但如果你用了static变量、或者共享了外部资源(比如同一个文件、同一个数据库连接),就可能出现“单独跑绿、一起跑红”的情况。我的原则是:测试方法必须独立,不依赖执行顺序,不共享可变状态。需要共享的初始化逻辑放在@BeforeEach里,保证每个测试都从干净状态开始。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 解析抛异常,提示字段无法识别 | JSON里有Java类没有的字段 | 配置忽略未知字段,或补全字段 |
| 中文字段解析成乱码 | 编码不一致 | 确认JSON字符串和读取时都用UTF-8 |
| 日期字段解析失败 | 格式不匹配 | 指定日期格式或使用支持多格式的配置 |
| 测试单独跑通过,一起跑失败 | 共享了可变状态 | 检查static变量和外部资源 |
| 断言失败但值看起来相同 | 类型或精度不一致 | 打印getClass(),用带delta的断言 |
| 大JSON解析内存溢出 | 用了树形模型全量加载 | 改用流式解析或分片处理 |
6.5 几个我踩过的坑
第一个坑:用assertEquals比较两个对象。如果对象没有重写equals方法,比较的是引用地址,永远不相等。要么重写equals,要么逐字段断言。
第二个坑:测试里捕获了异常却没断言。比如try { parser.parse(json); } catch(Exception e) {},这样即使抛异常测试也是绿的,完全失去了测试的意义。正确做法是用assertThrows或者让异常抛出。
第三个坑:过度依赖@Ignore。测试跑不过就加个@Ignore跳过,时间一长整个测试套件形同虚设。跑不过的测试要么修好,要么删掉,不要留着当摆设。
7. 让测试真正产生价值的几个进阶习惯
7.1 测试命名要能当文档看
好的测试方法名本身就是一份需求文档。should_return_empty_list_when_items_field_is_missing比testItems2强一百倍。团队新人看一遍测试方法名,就知道这个解析器在各种情况下应该表现成什么样。
7.2 用参数化测试覆盖多组输入
JUnit 5的@ParameterizedTest特别适合JSON解析这种“同一逻辑、多组输入”的场景。比如测不同格式的日期字符串,可以这样写:
@ParameterizedTest @ValueSource(strings = {"2024-01-01", "2024/01/01", "20240101"}) public void should_parse_various_date_formats(String dateStr) { // 构造JSON并断言解析结果 }一组输入一行代码,比复制粘贴十个测试方法清爽得多。
7.3 把测试当成回归防护网
每次修bug,先写一个能复现bug的测试,看着它失败,再改代码让它通过。这样这个bug以后永远不会再出现。这个习惯坚持半年,你会发现线上事故明显变少,因为大部分低级错误在本地就被测试拦住了。
7.4 覆盖率不是目标,但可以参考
不用追求100%覆盖率,但核心解析逻辑、所有分支(if/else、try/catch)最好都有测试覆盖。IDEA自带的覆盖率工具跑一下,看看哪些分支是红的,针对性补测试。一般来说,解析器的正常路径、空值路径、异常路径这三块覆盖到,基本就稳了。
8. 关于JSON解析与单元测试配合的个人体会
我刚开始写代码那会儿,也觉得写测试是浪费时间,有那功夫不如多写两个功能。直到有一次改了一个解析方法,本地手动测了三条数据都正常,上线后遇到一个字段为空的case直接崩了,回滚加排查折腾了大半天。从那以后我就养成了习惯:凡是涉及数据解析的方法,先写测试再写实现。
JSON解析和JUnit测试这两个东西,单独看都不难,难的是把它们组合起来形成一套可靠的验证机制。解析方案选型决定了你的代码结构,测试用例设计决定了你的代码质量。两者配合好了,接口数据怎么变你都不慌,因为测试会第一时间告诉你哪里出了问题。
最后分享一个小技巧:如果你的项目里JSON结构经常变,可以维护一个test/resources目录,把各种真实场景的JSON样本存成文件,测试时直接读取。这样产品经理或后端同事给你一个新样本,你往目录里一丢,加个测试方法就能验证,效率比手写字符串高得多。