Введение


Одной из основных задач каждого веб-сервиса является возврат модели на клиентскую сторону, и в этом случае Spring Boot предоставляет удобный уровень абстракции, позволяя разработчику оставаться на уровне работы с моделями, а сам процесс сериализации модели оставлять вне исходного кода программы. Но как быть, если сама сериализация становится частью бизнес-логики приложения и, следовательно, требует покрытия тестовыми сценариями?

В данной статье будет рассмотрен один из сценариев, когда нам может понадобиться учесть особенности бизнес-логики приложения при сериализации (сценарий округления денежных сумм), на примере которого мы столкнёмся с механизмом сериализации в Spring Boot, а также описан возможный способ тестирования.

Постановка задачи


Пусть наш веб-сервис отвечает за предоставление информации о расходах клиента, и нам требуется предоставлять данные с конфигурируемой точностью. Логичным решением будет вынести все преобразования модели на перефирию сервиса, при этом сохранив наглядность применения логики округления.

Рассмотрим возможное решение


Представим контроллер нашего приложения, который будет возвращать требуемую модель.

@RestController
public class AccountController {

    // Поскольку нас не интересует сам процесс получения модели,
    // оставим его за кадром некоторого внутреннего сервиса.
    @Autowired
    private AccountService accountService;

    @RequestMapping(value = "/account/{clientId}", method = RequestMethod.GET, produces = "application/json")
    public Account getAccount(@PathVariable long clientId) throws Exception {
        Account result = accountService.getAccount(clientId);
        // Как видим, результат работы контролера -
        // это всё ещё модель нашего сервиса, а не итоговый json,
        // который получит клиентское приложение.
        return result; 
    }
}

Теперь посмотрим на нашу модель.

public class Account {

    private Long clientId;
    
    // По умолчанию в Spring Boot сериализацией занимается FasterXML/jackson,
    // в котором предусмотрено API для кастомизации, им и воспользуемся.
    // Укажем, что сериализацией расходов мы хотим управлять
    // посредством некоторого нашего сервиса MoneySerializer
    @JsonSerialize(using = MoneySerializer.class)
    private BigDecimal value;
    
    // Пропустим описание набора getter'ов и setter'ов для полей модели
}

Возможно, вам уже приходилось иметь дело с другими аннотациями для кастомизации. Особенность данной аннотации — возможность определить свой сервис, ответственный за сериализацию аннотированного поля модели.

Прежде чем посмотреть, что из себя представляет сериализатор, усложним задачу: пусть параметры округления будут конфигурируемы через некоторый внутренний сервис, абстрагирующий любое проявление динамического разрешения параметров.

Это усложнение наш ключевой момент, который мы хотим рассмотреть. Как мы могли видеть по реализации модели, API нашего фреймворка принимает в аргументе класс сериализации, а значит жизненый цикл сериализатора переходит под контроль фреймворка сериализатора. Отсюда возникает вопрос, что делать в том случае, если мы хотим внедрить зависимость из контекста нашего приложения в сериализатор? Для этого рассмотрим реализацию приведённого выше сериализатора.

// Обратите внимание на данную аннотацию,
// поскольку Jackson интегрирован с Spring,
// API позволяет проинформировать фреймворк
// для возможности использования Spring DI
@JsonComponent
public class MoneySerializer extends JsonSerializer<BigDecimal> {

    // Данный сервис, абстрагирующий параметры округления,
    // является частью контекста Spring Boot являясь одним из Bean'ов.
    private RoundingHolder roundingHolder;

    @Autowired
    public MoneySerializer(RoundingHolder roundingHolder) {
        this.roundingHolder = roundingHolder;
    }

    // Сам процесс сериализации не будет в новинку,
    // всё, что нам надо, это в переопределённом методе сериализации -
    // реализовать итоговое представление интересующего нас объекта.
    @Override
    public void serialize(BigDecimal value, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
        jsonGenerator.writeNumber(value.setScale(roundingHolder.getPrecision(), roundingHolder.getRoundingMode()));
    }
}

Наш сервис готов, но, как ответственные разработчики, мы хотим убедиться, что собранная нами кухня работает.

Перейдём к тестированию


Давайте посмотрим, что предлагает нам API фреймворка для тестирования.


// Поскольку мы хотим протестировать механизм, работа которого
// строится на контексте Spring, то нам требуется запустить и сам контекст.
// Что же касается аннотации JsonTest, то она отвечает за наполнение
// запускаемого нами контекста только тем окружением,
// которое требуется для JSON-сериализации.
@JsonTest
@ContextConfiguration(classes = {AccountSerializationTest.Config.class})
@RunWith(SpringRunner.class)
public class AccountSerializationTest {

    // Важно убедиться, что в тестовом сценарии используется
    // тот же объект класса ObjectMapper, что и в приложении.
    // Для этого воспользуемся инъекцией маппера из контекста.
    // При использовании кастомизированнго маппера,
    // его следует добавить в тестовый контекст.
    @Autowired
    private ObjectMapper objectMapper;

    @Test
    public void testAccountMoneyRounding() throws Exception {
        Account account = new Account();
        account.setClientId(1L);
        account.setValue(BigDecimal.valueOf(1.123456789));

        String expectedResult = "{\"clientId\":1,\"value\":\1.123\}";

        // Теперь, когда у нас в руках необходимый маппер модели в JSON,
        // убедимся в корректном исполнении бизнес-сценария.
        assertEquals(expectedResult, objectMapper.writeValueAsString(account));
    }

    // Поскольку класс MoneySerializer передаётся в API фреймворка 
    // через аннотацию в модели, то жизненным циклом объекта этого класса
    // уже будет заниматься Jackson. Но наш сервис, отвечающий за округление,
    // порождается контекстом Spring и, следовательно, необходимо
    // добавить его в изолированный тестовый контекст.
    @TestConfiguration
    public static class Config {
        @Bean
        public static RoundingHolder roundingHolder() {
            RoundingHolder roundingHolder = Mockito.mock(RoundingHolder.class);
            // позаботимся о том, чтобы сервис обладал логикой для проведения тестового сценария
            Mockito.when(roundingHolder.getMathContext()).thenReturn(new MathContext(3, RoundingMode.HALF_EVEN));
            return roundingHolder;
        }
    }
}

Остановимся на этом моменте поподробнее. Для сериализации и десериализации моделей в Jackson используется класс ObjectMapper. Это именно тот объект контекста, который отвечает за преобразование моделей, следовательно, чтобы убедиться в том, как модель будет представлена, надо проверить как её обработает ObjectMapper из контекста.

При желании создать свой кастомный ObjectMapper, вы можете встретить следующий типичный пример: ObjectMapper mapper = new ObjectMapper. Однако, посмотрим на то, как Spring Boot создаёт экземпляр этого класса по умолчанию. Для этого обратимся к исходному коду автоконфигурации JacksonAutoConfiguration, отвечающей за создание объекта:

@Bean
public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
    return builder.createXmlMapper(false).build();
}

И если пойти ещё дальше и заглянуть в build(), то обнаружим, что для работы сериализации, к которой мы могли привыкнуть при работе с дефолтным маппером (как например инъекция сервисов в кастомный сериализатор) мало просто создать Bean маппера, следует обратиться к предоставляемому билдеру. Кстати, в самой документации к Spring Boot это указано явно.

Отступлением хотелось бы добавить отсылку к JacksonTester. Как к представителю оболочки для BDD тестирования сериализации в контексте Mockito.

Подведём итоги


  • Spring Boot предоставляет возможность кастомизировать сериализацию модели посредством аннотации JsonSerializer
  • Для тестирования сериализации используйте маппер в той же конфигурации, что и в приложении
  • При переопределении бина из автоконфигурации Spring Boot, обратите внимание на то, как этот бин создаёт сам Spring Boot, чтобы не упустить возможностей, которыми обладал дефолтный бин
  • Для задания ограниченного контекста, необходимого для тестирования сериализации, можно воспользоваться аннотацией JsonTest

Заключение


Спасибо за внимание! Данный пример будет актуален как для текущей версии Spring Boot 2.1.x, так и для более ранних версий вплоть до 1.4.x. Также техника подойдёт для ситуаций с десериализацией модели. Смотрите под капот ваших основных фреймворков для лучшего понимания механизма работы приложения и ответственно подходите к тестированию.

Комментарии (0)