Продолжаю серию публикаций про наши Java-онлайн курсы. Предыдущие посты:

Сразу предупрежу: точно так же, как в контроллерах на дженериках сами контроллеры не параметризируются, здесь мы НЕ БУДЕМ параметризировать сами классы тестов. Поэтому не спешите писать комментарии, не прочитав статьи, что это «Bad practice». По поводу усложнения кода заранее отвечу так же, как и в комментариях к статье про контроллеры — код тестов и их написание становятся проще, за счет усложнения инструментов (собственно на этом и строится разработка фреймворков и ООП). Можно считать приведенные здесь подходы слоем абстракции, праметризирующий подход популярной библиотеки AssertJ к сравнению объектов и расширяющий его на сравнение json объектов.

Тесты сервисов/репозиториев

Большинство разработчиков, кто писал тесты, видели/использовали такие привычные конструкции при тестировании сервисов/репозиториев:

@Test
void getFromService() {
    User actual = service.get(USER_ID);
    assertEquals(USER_ID, actual.getId());
    assertEquals(user.getName(), actual.getName());
    assertEquals(user.getEmail(), actual.getEmail());
    assertEquals(user.getCaloriesPerDay(), actual.getCaloriesPerDay());
    assertEquals(user.getRoles(), actual.getRoles());
}

и похожие на них для проверки возвращаемых из контроллера json объектов c помощью MockMvcResultMatchers.jsonPath (ссылка на полный код класса внизу статьи)

@Test
void getFromController() throws Exception {
    mockMvc.perform(MockMvcRequestBuilders.get(REST_URL + USER_ID)
                    .with(httpBasic(admin.getEmail(), admin.getPassword())))
            .andDo(print())
            .andExpect(status().isOk())
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.id").value(USER_ID))
            .andExpect(jsonPath("$.name").value(user.getName()))
            .andExpect(jsonPath("$.email").value(user.getEmail()))
            .andExpect(jsonPath("$.caloriesPerDay").value(user.getCaloriesPerDay()))
            .andExpect(jsonPath("$.roles", hasSize(1)))
            .andExpect(jsonPath("$.roles", contains(Role.USER.name())));
}

И это для небольшого объекта без вложений и с одним элементом в колелкции ролей! Понятно, что часто на тестирование ВСЕХ полей реальных объекта забивается.

Давайте попробуем упростить жизнь разработчику, пройдя путь, который мы используем уже почти 10 лет на нашей стажировке TopJava (Maven/ Spring/ Security/ Spring Boot/ JPA(Hibernate)/ Swagger/OpenAPI 3.0)/ Rest)

  1. Если возвращается объект DTO, то тесты можно упростить, переопределив в нем equals/hasCode (например через Lombok @EqualsAndHashCode(callSuper = true))

    @Test
    void getToFromService() {
        UserTo actualTo = service.getTo(USER_ID);
        assertEquals(userTo, actualTo);
    }
    
  2. Для контроллеров все немного сложнее: нам нужен утильный класс для преобразования Json в объект, который мы затем можем сравнить:

    public class JsonUtil {
        public static <T> T readValue(String json, Class<T> clazz) throws IOException {
            return getObjectMapper().readValue(json, clazz);
        }
        ...
    }
    @Test
    void getToFromController() throws Exception {
        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get(REST_URL_TO + USER_ID)
           .with(httpBasic(admin.getEmail(), admin.getPassword())))
           .andExpect(...)
           .andReturn();
        String json = result.getResponse().getContentAsString();
        User actual = JsonUtil.readValue(json, User.class);
        assertEquals(user, actual);
    }
    

    Или можно сократить:

    @Test
    void getToFromController() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get(REST_URL_TO + USER_ID)
           .with(httpBasic(admin.getEmail(), admin.getPassword())))
           .andExpect(...)
           .andExpect(result -> assertEquals(user,
                JsonUtil.readValue(result.getResponse().getContentAsString(), User.class)));
    }
    
  3. Если объект является Entity, и переопределить equals/hasCode по всем полям мы не можем, можно упростить код с помощью мощной библиотеки AssertJ.
    Будте внимательны: в Junit методах проверки порядок аргументов (expected, actual), а в методах AssertJ наоборот (actual,expected).

    @Test
    void getEntityFromService() {
        User actual = service.get(USER_ID);
        assertThat(actual).usingRecursiveComparison().ignoringFields("registered", "password").isEqualTo(user);
    }
    

    Чтобы не дублировать стратегию сравнения, можно вынсти ее в константу и использовать во всех тестах:

    public static BiConsumer<User, User> USER_MATCHER = 
          (a, e) -> assertThat(a).usingRecursiveComparison().ignoringFields("registered", "password").isEqualTo(e)
      
    @Test
    void getEntityFromService() {
        User actual = service.get(USER_ID);
        USER_MATCHER.accept(actual, user);
    }
  4. Применяем этот подход к контроллерам:

    @Test
    void getEntityFromController() {
        mockMvc.perform(MockMvcRequestBuilders.get(REST_URL_TO + USER_ID)
           .with(httpBasic(admin.getEmail(), admin.getPassword())))
           .andExpect(...)
           .andExpect(result -> USER_MATCHER.accept(
                    JsonUtil.readValue(result.getResponse().getContentAsString(), User.class), user));
    }
    
  5. Осталось расширить наш подход на все случаи:

В некоторых сложных случаях, когда мы используем JsonUtil для создания json тела POST запроса, а поля объекта помечены как @JsonIgnore или @JsonProperty(access = Access.WRITE_ONLY), приходится использовать хак - добавлять эти поля вручную (код JsonUtil.writeAdditionProps и, например, использование в AdminUserControllerTest.update)

Если проект большой и тестов много, мы получаем выигрыш даже не 3-X, а на порядок!
При этом уходит все дублирование и ошибок также становится на порядок меньше.

Напоследок традиционно: приятного кодирования и приглашаем на наши курсы.

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


  1. onets
    17.05.2024 13:52
    +1

    А еще можно использовать параметризированные тесты (в nunit это атрибут [TestCase]), передавать туда в том числе ожидаемые данные. Можно передать строку, в которой будет json. А внутри сравнивать актуальные и ожидаемые.

    В итоге все тесты можно будет разделить на несколько групп. Типа набор тестов, где все заканчивается хорошо. Отдельно набор тестов, где все заканчивается плохо. И тем самым еще сократить код.


    1. gkislin Автор
      17.05.2024 13:52

      Да, дейтивительно можно параметризировать параметры теста, отдельная тема.
      А вот от json строк предлагаю уйти - мой подход кажется мне гораздо удобнее и универсальнее.


  1. igorhak
    17.05.2024 13:52
    +1

    Давно уже использую этот подход, сделал ещё короче код на котлин, написав функцию-расширение, которая использует object mapper, ‘...andExpect { .. }.getResult«MyDto»(objectMapper)‘