Статьи

Java Mapper и тестирование моделей с использованием eXpectamundo

Как давний разработчик приложений Java, работающий в различных корпоративных средах, я должен выполнить одно из общих действий — написать сопоставления для преобразования одного объекта модели Java в другой. Независимо от технологии или библиотеки, которую я использую для написания картографа, возникает один и тот же вопрос. Каков наилучший способ для юнит-тестирования?

Я прошел через различные подходы, все с различными плюсами и минусами, связанными с количеством времени, которое требуется, чтобы написать то, что по сути является довольно простым тестом. Тенденция (я не хочу признавать) состоит в том, чтобы экономить на тестировании всех полей и сосредоточиться на том, что я считаю ключевыми, чтобы сконцентрироваться, смею сказать, на более интересных областях кодовой базы. Как известно любому программисту, это путь к ошибкам, и время, потраченное на написание теста, многократно окупается при уменьшенной отладке.

Введите eXpectamundo

eXpectamundo — это библиотека Java с открытым исходным кодом, размещенная на github, которая использует новый подход к тестированию объектов модели. Это позволяет разработчику Java написать прототип объекта, который был настроен с ожиданиями. Этот прототип может быть использован для проверки фактического результата в модульном тесте. Фрагмент ниже иллюстрирует настройку прототипа. 

    ...
    User expected = prototype(User.class);
    expect(expected.getCreateTs()).isWithin(1, TimeUnit.SECONDS, Moments.today());
    expect(expected.getFirstName()).isEqualTo("John");
    expect(expected.getUserId()).isNull();
    expect(expected.getDateOfBirth()).isComparableTo(AUG(9, 1975));
    expectThat(actual).matches(expected);
    ..

Для полного примера давайте возьмем простой объект передачи данных (DTO), который передает определение нового пользователя из пользовательского интерфейса.

package org.exparity.expectamundo.sample.mapper;

import java.util.Date;

public class UserDTO {

  private String username, firstName, surname;
  private Date dateOfBirth;

  public UserDTO(String username, String firstName, String surname,
    Date dateOfBirth) {
    this.username = username;
    this.firstName = firstName;
    this.surname = surname;
    this.dateOfBirth = dateOfBirth;
  }

  public String getUsername() {
    return username;
  }

  public String getFirstName() {
    return firstName;
  }

  public String getSurname() {
    return surname;
  }

  public Date getDateOfBirth() {
    return dateOfBirth;
  }
}

Этот DTO должен быть сопоставлен с моделью объекта User, которая затем может быть обработана, сохранена и т. Д. Сервисным уровнем. Пользовательский объект домена определяется следующим образом:

package org.exparity.expectamundo.sample.mapper;

import java.util.Date;

public class User {

  private Integer userId;
  private Date createTs = new Date();
  private String username, firstName, surname;
  private Date dateOfBirth;

  public User(String username, String firstName, String surname,
    final Date dateOfBirth) {
    this.username = username;
    this.firstName = firstName;
    this.surname = surname;
    this.dateOfBirth = dateOfBirth;
  }

  public Integer getUserId() {
    return userId;
  }

  public Date getCreateTs() {
    return createTs;
  }

  public String getUsername() {
    return username;
  }

  public String getFirstName() {
    return firstName;
  }

  public String getSurname() {
    return surname;
  }

  public Date getDateOfBirth() {
    return dateOfBirth;
  }
}

Код для mapper прост, поэтому мы будем использовать простой слой отображения с ручным кодированием, однако я внес в маппер ошибку, которую мы обнаружим позже в нашем модульном тесте.

package org.exparity.expectamundo.sample.mapper;

public class UserDTOToUserMapper {
  public User map(final UserDTO userDTO) {
    return new User(userDTO.getUsername(), userDTO.getSurname(),
      userDTO.getFirstName(),
      userDTO.getDateOfBirth());
  }
}

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

package org.exparity.expectamundo.sample.mapper;

import java.util.concurrent.TimeUnit;
import org.junit.Test;
import static org.exparity.dates.en.FluentDate.AUG;
import static org.exparity.expectamundo.Expectamundo.*;
import static org.exparity.hamcrest.date.Moments.now;

public class UserDTOToUserMapperTest {

  @Test
  public void canMapUserDTOToUser() {

    UserDTO dto = new UserDTO("JohnSmith", "John", "Smith", AUG(9, 1975));
    User actual = new UserDTOToUserMapper().map(dto);

    User expected = prototype(User.class);
    expect(expected.getCreateTs()).isWithin(1, TimeUnit.SECONDS, now());
    expect(expected.getFirstName()).isEqualTo("John");
    expect(expected.getSurname()).isEqualTo("Smith");
    expect(expected.getUsername()).isEqualTo("JohnSmith");
    expect(expected.getUserId()).isNull();
    expect(expected.getDateOfBirth()).isSameDay(AUG(9, 1975));
    expectThat(actual).matches(expected);
  }
}

Тест показывает, как можно выполнить простые тесты на равенство, а также представил некоторые из специализированных тестов, которые можно выполнить, например, тестирование на нулевое значение или тестирование границ временной метки create и выполнение проверки сравнения для свойства dateOfBirth. Запуск модульного теста сообщает о сбое в устройстве отображения, когда свойства имени и фамилии были перенесены средством отображения.

java.lang.AssertionError: 
Expected a User containing properties :
  getCreateTs() is expected within 1 seconds of Sun Jan 18 13:00:33 GMT 2015
  getFirstName() is equal to John
  getSurname() is equal to Smith
  getUsername() is equal to JohnSmith
  getUserId() is null
  getDateOfBirth() is comparable to Sat Aug 09 00:00:00 BST 1975
But actual is a User containing properties :
  getFirstName() is Smith
  getSurname() is John

Простое исправление в маппере решает проблему:

package org.exparity.expectamundo.sample.mapper;

public class UserDTOToUserMapper {
  public User map(final UserDTO userDTO) {
    return new User(userDTO.getUsername(),userDTO.getFirstName(),
      userDTO.getSurname(),
      userDTO.getDateOfBirth());
  }
}

Но я могу сделать это с помощью Hamcrest!

Подголовник, эквивалентный этому тесту, будет следовать одному из двух шаблонов; пользовательская реализация org.hamcrest.Matcher для сопоставления пользовательских объектов или набор встроенных утверждений согласно следующему примеру:

package org.exparity.expectamundo.sample.mapper;

import java.util.concurrent.TimeUnit;
import org.junit.Test;
import static org.exparity.dates.en.FluentDate.AUG;
import static org.exparity.hamcrest.date.DateMatchers.within;
import static org.exparity.hamcrest.date.Moments.now;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;

public class UserDTOToUserMapperHamcrestTest {

  @Test
  public void canMapUserDTOToUser() {
    UserDTO dto = new UserDTO("JohnSmith", "John", "Smith", AUG(9, 1975));
    User actual = new UserDTOToUserMapper().map(dto);
    assertThat(actual.getCreateTs(), within(1, TimeUnit.SECONDS, now()));
    assertThat(actual.getFirstName(), equalTo("John"));
    assertThat(actual.getSurname(), equalTo("Smith"));
    assertThat(actual.getUsername(), equalTo("JohnSmith"));
    assertThat(actual.getUserId(), nullValue());
    assertThat(actual.getDateOfBirth(), comparesEqualTo(AUG(9, 1975)));
  }
}

В этом примере единственная разница, которую eXpectamundo предлагает по сравнению с hamcrest, заключается в другом способе сообщения о несоответствиях. eXpectamundo сообщит обо всех различиях между ожидаемым и фактическим, в то время как тест на хемкресте не пройдёт по первому различию. Улучшение, но на самом деле не причина для рассмотрения альтернатив. Подход, предлагаемый eXpectomundo, начинает дифференцироваться при тестировании более сложных коллекций объектов и графиков.

Тестирование коллекций с помощью eXpectamundo

Если мы продвинем наш код вперед и создадим хранилище, которое позволит нам сохранять и извлекать пользовательские экземпляры. Для простоты я использовал базовый репозиторий с поддержкой HashMap. Код для хранилища выглядит следующим образом:

package org.exparity.expectamundo.sample.mapper;

import java.util.*;

public class UserRepository {

  private Map userMap = new HashMap<>();

  public List getAll() {
    return new ArrayList<>(userMap.values());
  }

  public void addUser(final User user) {
    this.userMap.put(user.getUsername(), user);
  }

  public User getUserByUsername(final String username) {
    return userMap.get(username);
  }
}

Затем мы пишем модульный тест для подтверждения поведения хранилища.

package org.exparity.expectamundo.sample.mapper;

import java.util.Date;
import java.util.concurrent.TimeUnit;
import org.junit.Test;
import static org.exparity.dates.en.FluentDate.AUG;
import static org.exparity.expectamundo.Expectamundo.*;

public class UserRepositoryTest {

  private static String FIRST_NAME = "John";
  private static String SURNAME = "Smith";
  private static String USERNAME = "JohnSmith";
  private static Date DATE_OF_BIRTH = AUG(9, 1975);
  private static User EXPECTED_USER;

  static {
    EXPECTED_USER = prototype(User.class);
    expect(EXPECTED_USER.getCreateTs()).isWithin(1, TimeUnit.SECONDS, new Date());
    expect(EXPECTED_USER.getFirstName()).isEqualTo(FIRST_NAME);
    expect(EXPECTED_USER.getSurname()).isEqualTo(SURNAME);
    expect(EXPECTED_USER.getUsername()).isEqualTo(USERNAME);
    expect(EXPECTED_USER.getUserId()).isNull();
    expect(EXPECTED_USER.getDateOfBirth()).isComparableTo(DATE_OF_BIRTH);
  }

  @Test
  public void canGetAll() {
    User user = new User(USERNAME, FIRST_NAME, SURNAME, DATE_OF_BIRTH);
    UserRepository repos = new UserRepository();
    repos.addUser(user);
    expectThat(repos.getAll()).contains(EXPECTED_USER);
  }

  @Test
  public void canGetByUsername() {
    User user = new User(USERNAME, FIRST_NAME, SURNAME, DATE_OF_BIRTH);
    UserRepository repos = new UserRepository();
    repos.addUser(user);
    expectThat(repos.getUserByUsername(USERNAME)).matches(EXPECTED_USER);
  }
}

Тест показывает, как созданный прототип можно использовать для глубокой проверки объекта и, при желании, можно повторно использовать в нескольких тестах. Эквивалентное совпадение в Hamcrest заключается в написании настраиваемого сопоставления для объекта User или, как показано ниже, для плоских объектов с использованием множественного сопоставления. (Обратите внимание, что существует несколько способов написания соответствия, один из приведенных ниже был наиболее кратким примером).

package org.exparity.expectamundo.sample.mapper;

import java.util.Date;
import java.util.concurrent.TimeUnit;
import org.hamcrest.*;
import org.junit.Test;
import static org.exparity.dates.en.FluentDate.AUG;
import static org.exparity.hamcrest.BeanMatchers.hasProperty;
import static org.exparity.hamcrest.date.DateMatchers.*;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;

public class UserRepositoryHamcrestTest {

  private static String FIRST_NAME = "John";
  private static String SURNAME = "Smith";
  private static String USERNAME = "JohnSmith";
  private static Date DATE_OF_BIRTH = AUG(9, 1975);

  private static final Matcher<user> EXPECTED_USER = Matchers.allOf(
      hasProperty("CreateTs", within(1, TimeUnit.SECONDS, new Date())),
      hasProperty("FirstName", equalTo(FIRST_NAME)),
      hasProperty("Surname", equalTo(SURNAME)),
      hasProperty("Username", equalTo(USERNAME)),
      hasProperty("UserId", nullValue()),
      hasProperty("DateOfBirth", sameDay(DATE_OF_BIRTH)));

  @Test
  public void canGetAll() {
    User user = new User(USERNAME, FIRST_NAME, SURNAME, DATE_OF_BIRTH);
    UserRepository repos = new UserRepository();
    repos.addUser(user);
    assertThat(repos.getAll(), hasItem(EXPECTED_USER));
  }

  @Test
  public void canGetByUsername() {
    User user = new User(USERNAME, FIRST_NAME, SURNAME, DATE_OF_BIRTH);
    UserRepository repos = new UserRepository();
    repos.addUser(user);
    assertThat(repos.getUserByUsername(USERNAME), is(EXPECTED_USER));
  }
}

Для сравнения, этот тест на хемкресте соответствует тесту eXpectamundo по компактности, но не по типу безопасности. Может быть создан типобезопасный сопоставитель, который проверяет каждое свойство индивидуально, что создает значительно больше кода без какой-либо выгоды по сравнению с эквивалентом eXpectamundo. Отчеты об ошибках во время сбоев также понятны и интуитивно понятны для теста eXpectamundo, в меньшей степени — для эквивалента Hamcrest. (Опять же, эквивалентный описательный тест может быть написан с использованием Hamcrest, но потребует гораздо больше кода). Ниже приведен пример сообщения об ошибке, где вместо имени возвращается фамилия:

java.lang.AssertionError: 
Expected a list containing a User with properties:
  getCreateTs() is a expected within 1 seconds of Fri Mar 06 17:29:52 GMT 2015
  getFirstName() is equal to John
  getSurname() is equal to Smith
  getUsername() is equal to JohnSmith
  getUserId() is is null
  getDateOfBirth() is is comparable to Sat Aug 09 00:00:00 BST 1975
but actual list contains:
  User containing properties
    getFirstName() is Smith

Резюме

Таким образом, eXpectamundo предлагает новый подход для проверки моделей во время тестирования. Он предоставляет интерфейс с безопасным типом для установки ожиданий, что делает создание глубоких тестов моделей, особенно в среде IDE с автозаполнением, особенно простым. О сбоях также сообщается с понятной трассировкой ошибок. Полная информация о eXpectamundo и других ожиданиях и функциях, которые он поддерживает, доступна на странице eXpectamundo на github . Пример кода также доступен на github .

Попробуйте это

Чтобы попробовать eXpectamundo для себя, включите зависимость в свой maven pom или другой менеджер зависимостей

    <dependency>
      <groupId>org.exparity</groupId>
      <artifactId>expectamundo</artifactId>
      <version>0.9.15</version>
      <scope>test</scope>
    </dependency>