☰
JUnit 5 实战:如何系统测试 Jackson 与 Fastjson 的 JSON 解析
2026/10/20 8:53:23 网站建设 项目流程

1. 从一个被忽视的测试盲区说起

很多做Java后端开发的朋友,写业务代码时对JSON解析这件事往往不太上心。ObjectMapper、JSON.parseObject、Gson.fromJson,随手一调,能跑通就行。可一旦项目进入联调阶段或者上线后出了数据格式问题,回头再看单元测试,才发现自己压根没为JSON解析写过几个像样的测试用例。我见过不少项目,Service层的测试覆盖率能到百分之七八十,但涉及JSON序列化与反序列化的部分几乎是空白,这其实是一个非常危险的信号。

JSON解析和JUnit单元测试这两个话题放在一起,核心要解决的问题就是:如何用JUnit把JSON解析逻辑的边界情况、异常场景、类型转换陷阱全部覆盖住,让数据格式问题在编码阶段就暴露出来,而不是等到联调或者线上才炸。这篇文章适合有一定Java基础、正在写业务代码但单元测试写得比较粗糙的开发者,也适合想系统梳理JSON测试思路的测试工程师。我会从实际项目中最容易踩坑的几个场景出发,把JSON解析的测试策略、JUnit的实战写法、参数化测试的运用、断言库的选择、以及那些文档里不会写的经验教训,全部摊开来讲。

先明确一个前提:本文讨论的JSON解析,指的是Java对象与JSON字符串之间的双向转换,包括序列化(对象转JSON)和反序列化(JSON转对象)。涉及的库主要是Jackson和Fastjson这两种最常见的,JUnit以JUnit 5为主。不同库的API有差异,但测试思路是相通的。

2. 为什么JSON解析的单元测试总是被跳过

2.1 一个真实的翻车场景

之前参与过一个订单系统的开发,里面有个字段叫amount,类型是BigDecimal。开发阶段大家用的测试数据都是整数,比如100、200,序列化和反序列化都没问题。结果上线后,前端传过来一个99.99,后端反序列化时因为某个配置没开,精度直接丢了,变成了99。这个问题在单元测试里完全测不出来,因为没人写过带小数的测试用例。

这个案例说明一个道理:JSON解析的问题往往不是"能不能解析",而是"解析得对不对"。而"对不对"这件事,恰恰需要单元测试来保证。可现实中,JSON解析的测试为什么总是被跳过?我总结了几个原因。

第一个原因是心理上的轻视。很多开发者觉得JSON解析是框架做的事,Jackson、Fastjson这些库已经足够成熟,不会出问题。但实际上,出问题的从来不是库本身,而是你的配置、你的对象定义、你的字段类型。库是没问题的,有问题的是使用方式。

第二个原因是写起来麻烦。要测JSON解析,你得构造对象、构造JSON字符串、调用方法、断言结果,一套下来代码量不小。而且很多场景下,你测的其实是"框架的行为"而不是"自己的逻辑",感觉投入产出比不高。

第三个原因是不知道该测什么。JSON解析的边界情况很多:null值、空字符串、嵌套对象、数组、日期格式、枚举、泛型、循环引用……如果没有系统性的思路,很容易漏测。

2.2 JSON解析测试的核心价值

把话说透:JSON解析的单元测试,本质上测的不是"解析功能",而是你的数据契约。你的Java对象定义了什么样的字段、什么类型、什么约束,JSON解析就是这份契约的执行者。测试就是验证这份契约在各种输入下是否被正确执行。

具体来说,JSON解析的单元测试能帮你发现以下几类问题:

  • 字段类型不匹配:比如前端传字符串,你定义的是Integer,反序列化直接报错或者静默失败。
  • 精度丢失:BigDecimal、Long大数、浮点数在序列化和反序列化过程中的精度问题。
  • 日期格式混乱:不同系统对日期的序列化格式不一致,导致解析失败或时间偏移。
  • null与空值的处理差异:null、空字符串、字段缺失,这三种情况在JSON里是不同的,但很多开发者混为一谈。
  • 枚举映射错误:枚举的序列化方式(按name还是按value)如果不统一,反序列化时直接抛异常。
  • 泛型擦除导致的类型丢失:List<T>这种泛型在反序列化时如果不传TypeReference,拿到的可能是LinkedHashMap而不是你的目标类型。

这些问题,每一个都足以让线上功能挂掉,而每一个都可以通过单元测试提前发现。

2.3 测试策略的取舍

当然,我不是说要为每一个JSON解析都写测试。那样的话测试代码会比业务代码还多。我的经验是,按照风险等级来决定测试的粒度:

风险等级场景测试策略
高涉及金额、ID、时间等关键字段必须写完整测试,覆盖边界值
中普通业务对象的序列化写基本测试,验证字段映射正确
低纯内部使用的DTO可以不写,或者只写一个冒烟测试

这个表格的意思是,你要把测试精力花在刀刃上。金额字段精度丢了是事故,内部DTO字段名写错了顶多是调试时多花五分钟。分清楚轻重,测试才可持续。

3. Jackson与Fastjson在测试中的行为差异

3.1 两个库的默认行为对比

写JSON测试之前,你得先搞清楚你用的库在默认情况下是什么行为。Jackson和Fastjson虽然都能做JSON解析,但默认行为差异不小,这些差异直接影响你测试用例的写法。

先看Jackson。Jackson的ObjectMapper默认情况下,遇到未知字段(JSON里有但Java对象里没有的字段)会直接抛UnrecognizedPropertyException。这个行为其实挺严格的,好处是能帮你发现数据契约不一致的问题,坏处是前端多传一个字段你的接口就挂了。所以实际项目中,通常要配置FAIL_ON_UNKNOWN_PROPERTIES为false。

Fastjson的默认行为就宽松得多,未知字段直接忽略,不会报错。这在联调阶段比较友好,但也容易掩盖问题。

再看null的处理。Jackson默认会把null字段也序列化出来,也就是{"name":null}这种形式。Fastjson默认会忽略null字段,输出{}。这个差异在测试时特别容易踩坑,因为你的断言如果写死了JSON字符串,换个库就挂了。

日期格式的差异更大。Jackson默认把Date序列化成时间戳(毫秒数),Fastjson默认序列化成"yyyy-MM-dd HH:mm:ss"格式的字符串。如果你在测试里断言日期字段,不显式配置格式,两个库的结果完全不一样。

3.2 测试用例的编写策略

基于上面的差异,我建议在写JSON测试时遵循几个原则。

第一,显式配置,不依赖默认值。不管是Jackson还是Fastjson,在测试类里都显式配置好ObjectMapper或JSON的行为,包括未知字段处理、null处理、日期格式。这样测试结果才是可预期的,也不会因为库版本升级导致测试莫名其妙挂掉。

第二,断言用对象比较,不用字符串比较。很多人写JSON测试喜欢断言序列化后的字符串,比如assertEquals("{\"name\":\"张三\"}", json)。这种写法极其脆弱,字段顺序变了、空格多了、null处理变了,测试就挂了。正确的做法是反序列化回对象,然后比较对象。或者用JSONAssert这样的库做结构化比较。

第三,测试双向转换。序列化和反序列化要成对测试。只测序列化不测反序列化,等于只测了一半。理想的情况是:对象 -> JSON -> 对象,然后断言两个对象相等。这就是所谓的"往返测试"(round-trip test)。

3.3 一个对比示例

下面这段代码展示了Jackson和Fastjson在同一个场景下的不同表现,以及测试时应该怎么处理:

// Jackson配置 ObjectMapper mapper = new ObjectMapper(); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); // Fastjson配置 JSON.DEFAULT_GENERATE_FEATURE |= SerializerFeature.WriteMapNullValue.getMask(); // 或者用@JSONField注解在字段级别控制

注意Jackson的JavaTimeModule,这是处理Java 8时间类型(LocalDate、LocalDateTime)必须注册的模块。如果不注册,序列化LocalDateTime直接抛InvalidDefinitionException。这个坑我踩过不止一次,尤其是在升级Jackson版本之后,之前能跑的测试突然就挂了。

Fastjson这边,null值的处理是通过SerializerFeature控制的,而且可以在字段级别用@JSONField(serialzeFeatures = ...)覆盖全局配置。这种灵活性是好事,但也意味着测试时要考虑配置的优先级。

4. 用JUnit 5构建JSON解析的测试矩阵

4.1 基础测试骨架的搭建

JUnit 5相比JUnit 4,在参数化测试、嵌套测试、动态测试方面强了不少,特别适合用来构建JSON解析的测试矩阵。先搭一个基础的测试骨架。

class JsonParsingTest { private ObjectMapper mapper; @BeforeEach void setUp() { mapper = new ObjectMapper(); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); } @Test @DisplayName("普通对象序列化与反序列化往返测试") void testRoundTrip() throws Exception { User user = new User("张三", 25, new BigDecimal("99.99")); String json = mapper.writeValueAsString(user); User parsed = mapper.readValue(json, User.class); assertEquals(user.getName(), parsed.getName()); assertEquals(user.getAge(), parsed.getAge()); assertEquals(0, user.getAmount().compareTo(parsed.getAmount())); } }

这个骨架里有两个细节值得说。一是@BeforeEach里初始化ObjectMapper,保证每个测试方法用的是干净的配置,避免测试之间的相互影响。二是BigDecimal的比较用了compareTo而不是equals,因为BigDecimal的equals会比较精度(scale),new BigDecimal("99.99")和new BigDecimal("99.990")用equals是不相等的,但compareTo认为相等。这个坑在金额测试里特别常见。

4.2 参数化测试覆盖边界值

JSON解析的边界情况很多,用@ParameterizedTest可以一次性覆盖多个输入,代码简洁且覆盖全面。

@ParameterizedTest @ValueSource(strings = { "{\"name\":\"张三\",\"age\":25}", "{\"name\":\"\",\"age\":0}", "{\"name\":null,\"age\":null}", "{}" }) @DisplayName("反序列化各种边界输入") void testDeserializeEdgeCases(String json) throws Exception { User user = mapper.readValue(json, User.class); assertNotNull(user); }

这段测试覆盖了正常值、空字符串、null值、字段缺失四种情况。注意最后一种{},在FAIL_ON_UNKNOWN_PROPERTIES为false的情况下,Jackson会创建一个所有字段都是默认值的对象,不会报错。如果你的业务逻辑要求某些字段必须存在,那就要额外加校验,测试也要覆盖到。

对于数值类型的边界,可以用@CsvSource:

@ParameterizedTest @CsvSource({ "0, 0", "1, 1", "-1, -1", "2147483647, 2147483647", "-2147483648, -2147483648" }) @DisplayName("Integer边界值反序列化") void testIntegerBoundary(int input, int expected) throws Exception { String json = "{\"age\":" + input + "}"; User user = mapper.readValue(json, User.class); assertEquals(expected, user.getAge()); }

这里覆盖了Integer的最大值和最小值。如果超出这个范围,比如2147483648,Jackson会抛JsonMappingException。这个异常场景也应该有对应的测试,用assertThrows来验证。

4.3 嵌套测试组织测试逻辑

JUnit 5的@Nested注解可以把相关的测试组织在一起,让测试类结构更清晰。对于JSON解析这种场景,可以按"序列化"、"反序列化"、"异常处理"来分组。

class UserJsonTest { @Nested @DisplayName("序列化测试") class SerializationTest { @Test void testNullFieldHandling() { /* ... */ } @Test void testDateFormat() { /* ... */ } } @Nested @DisplayName("反序列化测试") class DeserializationTest { @Test void testUnknownField() { /* ... */ } @Test void testTypeMismatch() { /* ... */ } } @Nested @DisplayName("异常场景测试") class ExceptionTest { @Test void testMalformedJson() { /* ... */ } } }

这种组织方式的好处是,当测试失败时,你能从测试报告里一眼看出是哪一类场景出了问题。而且@Nested内部类可以共享外部类的@BeforeEach,配置复用很方便。

4.4 动态测试处理不确定的输入

有些场景下,测试输入不是固定的,而是根据某些条件动态生成的。比如你要测试一个JSON解析器对各种随机字符串的健壮性,可以用@TestFactory生成动态测试。

@TestFactory @DisplayName("随机JSON字符串健壮性测试") Collection<DynamicTest> testRandomInputs() { List<String> inputs = Arrays.asList( "{\"name\":\"test\"}", "{\"name\":\"test\",\"extra\":123}", "{\"name\":123}", "not a json", "" ); return inputs.stream() .map(input -> DynamicTest.dynamicTest( "输入: " + input, () -> { assertDoesNotThrow(() -> { try { mapper.readValue(input, User.class); } catch (JsonProcessingException e) { // 预期内的异常,记录但不失败 } }); })) .collect(Collectors.toList()); }

动态测试适合这种"输入集合不确定"的场景。不过要注意,动态测试里如果断言失败,报错信息可能不如静态测试清晰,所以关键场景还是用静态测试更稳妥。

5. 那些只有踩过才知道的JSON测试陷阱

5.1 BigDecimal精度陷阱

前面提过BigDecimal的equals和compareTo的区别,这里再展开说一下。BigDecimal的equals方法比较的是"值+精度",new BigDecimal("1.0")和new BigDecimal("1.00")用equals比较返回false,但compareTo返回0。在JSON解析测试里,如果你用assertEquals比较两个BigDecimal,很容易出现"明明值一样但测试挂了"的情况。

正确的做法是统一用compareTo:

assertEquals(0, expected.compareTo(actual));

或者用AssertJ的isEqualByComparingTo:

assertThat(actual).isEqualByComparingTo(expected);

还有一个更隐蔽的坑:Jackson在反序列化BigDecimal时,如果JSON里是科学计数法(比如1E+2),解析出来的BigDecimal的scale可能是负数。这个在测试里如果不注意,断言会莫名其妙失败。

5.2 泛型擦除与TypeReference

反序列化泛型集合时,如果不传TypeReference,Jackson会返回List<LinkedHashMap>而不是List<User>。这个错误在编译期不会报,运行时才炸,而且报错信息往往很迷惑。

// 错误写法 List<User> users = mapper.readValue(json, List.class); // 实际返回List<LinkedHashMap> // 正确写法 List<User> users = mapper.readValue(json, new TypeReference<List<User>>() {});

测试的时候,除了断言集合大小和元素内容,最好再加一个类型断言:

assertTrue(users.get(0) instanceof User);

这样即使泛型信息丢了,测试也能第一时间发现。

5.3 日期格式的时区问题

日期测试是JSON解析测试里最容易出问题的部分。Date类型在序列化时,Jackson默认用UTC时区,如果你的系统时区是东八区,序列化出来的时间戳和预期可能差8小时。LocalDateTime不涉及时区,但格式如果不配置,默认是ISO-8601格式(2024-01-01T10:00:00),和很多前端期望的yyyy-MM-dd HH:mm:ss不一致。

测试日期字段时,我建议:

  • 显式配置日期格式,不依赖默认值。
  • 测试用例里用固定的时间点,不要用new Date()这种动态值。
  • 如果涉及时区,测试里显式设置时区,避免受运行环境影响。
mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); mapper.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));

5.4 循环引用导致的栈溢出

如果对象之间有循环引用(比如订单里有用户,用户里有订单列表),序列化时会无限递归,最终StackOverflowError。这个问题在测试里如果不覆盖,上线后一旦触发就是致命错误。

Jackson处理循环引用可以用@JsonIgnore、@JsonManagedReference/@JsonBackReference,或者配置SerializationFeature.FAIL_ON_SELF_REFERENCES。测试时要专门构造循环引用的场景,验证配置是否生效。

@Test @DisplayName("循环引用序列化不应导致栈溢出") void testCircularReference() { Order order = new Order(); User user = new User(); order.setUser(user); user.setOrders(Collections.singletonList(order)); assertDoesNotThrow(() -> mapper.writeValueAsString(order)); }

5.5 枚举映射的隐式陷阱

枚举的序列化默认用name(),反序列化也用name()匹配。但如果你的枚举定义和JSON里的值不一致,比如枚举是PENDING,JSON里传的是pending,反序列化直接抛异常。测试时要覆盖大小写、未知枚举值等场景。

@Test @DisplayName("未知枚举值应抛出明确异常") void testUnknownEnum() { String json = "{\"status\":\"UNKNOWN_STATUS\"}"; assertThrows(JsonMappingException.class, () -> { mapper.readValue(json, Order.class); }); }

如果业务上允许未知枚举值映射到默认值,那就要配置DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL或者自定义反序列化器,测试也要相应调整。

6. 让JSON测试真正落地的工程化实践

6.1 测试数据的集中管理

JSON测试的测试数据(JSON字符串、期望对象)如果散落在各个测试方法里,维护起来很痛苦。我的做法是建一个test-data目录,把常用的JSON样本放在文件里,测试时读取。

String json = Files.readString(Paths.get("src/test/resources/json/user-valid.json")); User user = mapper.readValue(json, User.class);

这样做的好处是,JSON样本可以被多个测试复用,修改时只改一处。而且JSON文件在IDE里有语法高亮和格式校验,比在Java字符串里写JSON舒服得多。

6.2 用JSONAssert做结构化断言

前面说过,直接断言JSON字符串很脆弱。除了反序列化回对象比较,还可以用JSONAssert库做结构化比较。

JSONAssert.assertEquals( "{\"name\":\"张三\",\"age\":25}", actualJson, false // false表示宽松模式,忽略字段顺序和额外字段 );

宽松模式下,只要期望的字段都存在且值匹配,额外的字段和字段顺序都不影响。这对于测试接口返回的JSON特别有用,因为接口返回的字段可能比你关心的多。

6.3 测试覆盖率与持续集成

JSON解析的测试写完之后,建议用JaCoCo看一下覆盖率。重点关注equals、hashCode、自定义序列化器这些容易被忽略的方法。在CI流程里,可以设置覆盖率阈值,比如JSON相关类的行覆盖率不低于80%,低于阈值就构建失败。

不过覆盖率只是参考,不要为了凑覆盖率写无意义的测试。一个测试如果只是assertNotNull,那它的价值有限。真正有价值的测试是能发现问题的测试。

6.4 一个完整的测试类示例

把上面的思路整合起来,一个完整的JSON测试类大概长这样:

@DisplayName("用户对象JSON解析测试") class UserJsonTest { private ObjectMapper mapper; @BeforeEach void setUp() { mapper = new ObjectMapper(); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); } @Nested @DisplayName("正常场景") class NormalCases { @Test @DisplayName("完整对象往返转换") void testRoundTrip() throws Exception { User user = new User("张三", 25, new BigDecimal("99.99")); String json = mapper.writeValueAsString(user); User parsed = mapper.readValue(json, User.class); assertEquals(user.getName(), parsed.getName()); assertEquals(0, user.getAmount().compareTo(parsed.getAmount())); } } @Nested @DisplayName("边界场景") class EdgeCases { @ParameterizedTest @ValueSource(strings = {"{}", "{\"name\":null}", "{\"name\":\"\"}"}) @DisplayName("空值与缺失字段") void testEmptyValues(String json) throws Exception { User user = mapper.readValue(json, User.class); assertNotNull(user); } } @Nested @DisplayName("异常场景") class ExceptionCases { @Test @DisplayName("非法JSON应抛出异常") void testMalformedJson() { assertThrows(JsonProcessingException.class, () -> { mapper.readValue("{invalid}", User.class); }); } } }

这个测试类结构清晰,覆盖了正常、边界、异常三类场景,用@Nested分组,用@ParameterizedTest覆盖多输入。实际项目中,你可以根据业务复杂度调整测试的粒度。

6.5 关于测试维护的几点心得

最后分享几个关于JSON测试维护的心得。

第一,测试要跟着契约走。当你的Java对象字段发生变化时,对应的JSON测试必须同步更新。我见过太多项目,对象字段改了,测试没改,结果测试跑的是旧逻辑,完全失去了保护作用。建议把JSON测试和对象定义放在同一个模块,改对象时IDE会提示相关测试。

第二,不要过度测试框架行为。比如"Jackson能不能序列化一个String"这种测试没有意义,框架肯定能。你要测的是"你的配置下,你的对象序列化结果是否符合预期"。测试的焦点始终是你的代码和配置,不是框架本身。

第三,异常测试要断言异常类型和关键信息。只写assertThrows(Exception.class, ...)太宽泛了,任何异常都能通过。应该断言具体的异常类型,必要时还要检查异常消息里是否包含关键字段名,这样出问题时能快速定位。

第四,定期review测试用例。业务在变,测试也要跟着变。每个迭代结束时,花十分钟看看JSON相关的测试是否还覆盖了当前的业务场景,有没有新的边界情况需要补充。这个习惯坚持下来,能避免很多线上事故。

JSON解析这件事,说简单也简单,一个注解就能搞定;说复杂也复杂,精度、时区、泛型、循环引用,每一个都能让你加班到深夜。而单元测试就是那道防线,让你在写代码的时候就发现问题,而不是在凌晨三点被报警电话叫醒。把测试写扎实,睡得才安稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询