在 Jackson / Spring Boot 中测试自定义 Json 反序列化器

Testing custom JsonDeserializer in Jackson / SpringBoot

我正在尝试为自定义反序列化器编写单元测试,该反序列化器是使用带有@Autowired 参数的构造函数实例化的,我的实体标有@JsonDeserialize。它在我的集成测试中运行良好,其中 MockMvc 启动 spring 服务器端。

然而,对于调用 objectMapper.readValue(...) 的测试,会实例化一个使用默认构造函数且不带参数的反序列化器的新实例。尽管

@Bean
public MyDeserializer deserializer(ExternalObject externalObject) 

实例化反序列化器的有线版本,实际调用仍传递给空构造函数并且上下文未被填充。

我尝试手动实例化反序列化器实例并在 ObjectMapper 中注册它,但只有当我从我的实体 class 中删除 @JsonDeserialize 时它才有效(并且它会破坏我的集成测试,即使我在我的@Configuration class.) - 看起来与此相关:https://github.com/FasterXML/jackson-databind/issues/1300

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

更新:下面的工作代码

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);
    }
}

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

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

通过在单元测试中使用 ObjectMapper,每个 运行 中也会调用 Jackson 框架中的大量代码 - 因此测试不是在验证 MyDeserializer 的契约,而是在验证MyDeserializer 和 Jackson 特定版本的组合行为。如果测试失败,则不会立即清楚所涉及的所有组件中的哪一个有问题。而且由于一起设置两个框架的环境更加困难,随着时间的推移,测试将变得脆弱,并且由于测试中的设置问题而更容易失败 class.

Jackson 框架负责编写ObjectMapper.readValue 的单元测试和使用@JacksonInject 的构造函数。对于“不是 Deserializer 的单元测试的其他单元测试”- 最好 mock/stub 该测试的 MyDeserializer(或其他依赖项)。这样,另一个 class 的逻辑与 MyDeserializer 中的逻辑隔离开来——并且另一个 class 的合约可以在不被测试单元外部的代码行为限定的情况下进行验证。

我不知道如何使用 Jackson 注入来设置它,但是您可以使用 spring Json 测试来测试它。我认为这种方法更接近真实场景并且更简单。 Spring 将仅加载与 serialization/deserialization 个 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");
  }

如果您想使用模拟,请使用以下代码段:

  @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");
  }

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

因此理论上您可以在单元测试中使用您自己的(模拟的)HandlerInstantiator、return 从 deserializerInstance() 准备好的实例来设置 ObjectMapper。其他方法好像可以return null 或者 class 参数不匹配,这会导致jackson自己创建实例

但是,我认为这不是单元测试反序列化逻辑的好方法,因为 ObjectMapper 设置必然不同于实际应用程序执行期间使用的设置。使用 会是一个更好的方法,因为您将获得在运行时将使用的相同 json 配置。