在 Jackson / Spring Boot 中测试自定义 Json Deserializer

Posted

技术标签:

【中文标题】在 Jackson / Spring Boot 中测试自定义 Json Deserializer【英文标题】:Testing custom JsonDeserializer in Jackson / SpringBoot 【发布时间】:2020-05-20 12:02:56 【问题描述】:

我正在尝试将单元测试写入自定义反序列化程序,该反序列化程序使用带有@Autowired 参数的构造函数和我的用@JsonDeserialize 标记的实体进行实例化。它在我的集成测试中运行良好,其中 MockMvc 带来了 spring 服务器端。

但是,在调用 objectMapper.readValue(...) 的测试中,使用没有参数的默认构造函数的反序列化器的新实例被实例化。虽然

@Bean
public MyDeserializer deserializer(ExternalObject externalObject) 

实例化解串器的有线版本,真正的调用仍然传递给空的构造函数并且上下文没有被填充。

我尝试手动实例化反序列化器实例并将其注册到 ObjectMapper 中,但它仅在我从实体类中删除 @JsonDeserialize 时才有效(即使我在 @Configuration 类中执行相同操作,它也会破坏我的集成测试。) - 看起来与此相关:https://github.com/FasterXML/jackson-databind/issues/1300

我仍然可以直接调用 deserializer.deserialize(...) 来测试反序列化器的行为,但是这种方法在不是反序列化器单元测试的测试中对我不起作用...

UPD:下面的工作代码

import com.fasterxml.jackson.annotation.JacksonInject;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.cfg.HandlerInstantiator;
import com.github.tomakehurst.wiremock.common.Json;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.web.context.support.SpringBeanAutowiringSupport;

import java.io.IOException;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;

@JsonTest
@RunWith(SpringRunner.class)
public class JacksonInjectExample 
    private static final String JSON = "\"field1\":\"value1\", \"field2\":123";

    public static class ExternalObject 
        @Override
        public String toString() 
            return "MyExternalObject";
        
    

    @JsonDeserialize(using = MyDeserializer.class)
    public static class MyEntity 
        public String field1;
        public String field2;
        public String name;

        public MyEntity(ExternalObject eo) 
            name = eo.toString();
        

        @Override
        public String toString() 
            return name;
        
    

    @Component
    public static class MyDeserializer extends JsonDeserializer<MyEntity> 

        @Autowired
        private ExternalObject external;

        public MyDeserializer() 
            SpringBeanAutowiringSupport.processInjectionBasedOnCurrentContext(this);
        

        public MyDeserializer(@JacksonInject final ExternalObject external) 
            this.external = external;
        

        @Override
        public MyEntity deserialize(JsonParser p, DeserializationContext ctxt) throws IOException,
            JsonProcessingException 
            return new MyEntity(external);
        
    

    @Configuration
    public static class TestConfiguration 
        @Bean
        public ExternalObject externalObject() 
            return new ExternalObject();
        

        @Bean
        public MyDeserializer deserializer(ExternalObject externalObject) 
            return new MyDeserializer(externalObject);
        
    

    @Test
    public void main() throws IOException 
        HandlerInstantiator hi = mock(HandlerInstantiator.class);
        MyDeserializer deserializer = new MyDeserializer();
        deserializer.external = new ExternalObject();
        doReturn(deserializer).when(hi).deserializerInstance(any(), any(), eq(MyDeserializer.class));
        final ObjectMapper mapper = Json.getObjectMapper();
        mapper.setHandlerInstantiator(hi);

        final MyEntity entity = mapper.readValue(JSON, MyEntity.class);
        Assert.assertEquals("MyExternalObject", entity.name);
    

【问题讨论】:

【参考方案1】:

单元测试不应依赖或调用其他主要类或框架。如果还存在涵盖应用程序功能的集成或验收测试以及您描述的特定依赖项集,则尤其如此。所以最好编写单元测试,以便它有一个单一的类作为它的主题,即直接调用 deserializer.deserialize(...)。

在这种情况下,单元测试应包括使用模拟或存根 ExternalObject 实例化 MyDeserializer,然后测试其 deserialize() 方法是否针对 JsonParser 和 DeserializationContext 参数的不同状态正确返回 MyEntity。 Mockito 非常适合设置模拟依赖项!

通过在单元测试中使用 ObjectMapper,Jackson 框架中的大量代码也在每次运行中被调用 - 所以测试不是验证 MyDeserializer 的合约,它是验证 MyDeserializer 组合的行为和杰克逊的一个特定版本。如果测试失败,将无法立即清楚所涉及的所有组件中的哪一个有故障。而且由于同时设置两个框架的环境更加困难,随着时间的推移,测试将变得脆弱,并且由于测试类中的设置问题而更频繁地失败。

Jackson 框架负责使用 @JacksonInject 编写 ObjectMapper.readValue 和构造函数的单元测试。对于“不是反序列化器单元测试的其他单元测试” - 最好为该测试模拟/存根 MyDeserializer (或其他依赖项)。这样,其他类的逻辑与 MyDeserializer 中的逻辑隔离 - 并且可以验证其他类的合同,而无需通过被测单元之外的代码行为来限定。

【讨论】:

感谢您的意见。我知道从 ObjectMapper 调用它使其不是完全单元测试的方法,并且对于测试反序列化器本身就足够了,但是,不幸的是,我正在测试某些服务的其他测试与反序列化器的结果紧密耦合并模拟它会到某个扩展意味着在测试中重写其完整的逻辑。我知道这不是一个好的设计,但它现在不在我的手中,为了开始重构,我需要以现有的方式支持现有的测试。【参考方案2】:

我不知道如何设置这个,特别是使用 Jackson 注入,但你可以使用 spring Json 测试来测试它。我认为这种方法更接近真实场景,也更简单。 Spring 将仅加载与序列化/反序列化 bean 相关的内容,因此您必须仅提供自定义 bean 或模拟来代替它们。

@JsonTest
public class JacksonInjectExample 

  private static final String JSON = "\"field1\":\"value1\", \"field2\":123";

  @Autowired
  private JacksonTester<MyEntity> jacksonTester;

  @Configuration
  public static class TestConfiguration 
      @Bean
      public ExternalObject externalObject() 
          return new ExternalObject();
      
  

  @Test
  public void test() throws IOException 
      MyEntity result = jacksonTester.parseObject(JSON);
      assertThat(result.getName()).isEqualTo("MyExternalObject");
  

如果您想使用模拟,请使用以下 sn-p:

  @MockBean
  private ExternalObject externalObject;

  @Test
  public void test() throws IOException 
      when(externalObject.toString()).thenReturn("Any string");
      MyEntity result = jacksonTester.parseObject(JSON);
      assertThat(result.getName()).isEqualTo("Any string");
  

【讨论】:

谢谢您,您的代码有效,但是对我来说,通过 mapper.readValue(JSON, MyEntity.class) 的评估来进行解析对我来说很重要,因为我有测试可以在后台进行。 .【参考方案3】:

非常有趣的问题,它让我想知道自动装配到杰克逊反序列化器中实际上是如何在 Spring 应用程序中工作的。使用的杰克逊设施似乎是HandlerInstantiator interface,也就是configured by spring to the SpringHandlerInstantiator implementation,它只是在应用程序上下文中查找类。

所以理论上你可以在单元测试中使用你自己的(模拟的)HandlerInstantiator 设置一个ObjectMapper,从deserializerInstance() 返回一个准备好的实例。其他方法返回null好像没问题,或者类参数不匹配时,会导致jackson自己创建实例。

但是,我认为这不是对反序列化逻辑进行单元测试的好方法,因为ObjectMapper 设置必然不同于实际应用程序执行期间使用的设置。使用JsonTest annotation as suggested in Anton's answer 会是一种更好的方法,因为您将获得与运行时相同的 json 配置。

【讨论】:

我提前给了你赏金 :) 不幸的是,它不起作用。我已经使用您的解决方案使用代码更新了问题。出于某种原因,ObjectMapper 仍然尝试在没有 args 构造函数的情况下实例化 MyDeserializer 的实例,并且由于没有连接 ExternalObject 的上下文,我收到警告和后续的 NPE。 我看了看,可能只是小错误,试试eq(MyDeserializer.class) 而不是eq(MyEntity.class)。 Jackson 已经知道它必须从注解中寻找MyDeserializer 的实例,HandlerInstantiator 只是提供了一个对应的实例,它不负责为给定的实体类找到正确的反序列化器。 谢谢,它现在可以在这个简化的示例中使用!现在我必须弄清楚为什么这在我的实际测试中不起作用......可能是因为 CollectionType 反序列化器首先被实例化,它绕过模拟实例化器在其中创建 MyDeserializer 实例...... 想通了,它是 ObjectMapper 的另一个实例。此方法完美运行,无需使用反射 api 和其他丑陋的 hack。谢谢!

以上是关于在 Jackson / Spring Boot 中测试自定义 Json Deserializer的主要内容,如果未能解决你的问题,请参考以下文章

Jackson 在我的 Spring Boot 应用程序中忽略了 spring.jackson.properties

spring-boot 使用啥版本的 Jackson?

在 Spring Boot 中使用 Jackson 反序列化 Date 对象

在 Jackson / Spring Boot 中测试自定义 Json Deserializer

Spring boot + Jackson - 始终将日期转换为 UTC

Spring Boot的Jackson JSON映射器[关闭]