Статьи

Шаблоны для лучшего модульного тестирования с JPA

За прошедшие годы я столкнулся со многими проектами, которые, как правило, попадают в одну из двух категорий: проекты, обладающие большой уверенностью, энергией и импульсом, выполняющие сотни (или тысячи) модульных тестов десятки раз в день; и те проекты, где усилия по написанию и запуску модульных тестов достаточно высоки, и тесты не поддерживаются, и в результате никто в команде не знает, работает ли их программное обеспечение. Итак, как нам получить наш проект в первую категорию? Ниже я выделю несколько шаблонов и практик, которые значительно снижают планку к созданию надежного набора тестов, позволяя вашей команде освоить практику и культуру полного охвата тестированием.

Настройка и скорость

Конфигурация, настройка и обслуживание сред создают значительный барьер для хорошо отлаженного и поддерживаемого набора тестов. В идеале мы хотим включить запуск тестов с нулевой настройкой. Для приложений, интенсивно работающих с данными, требуется база данных, которая, как известно, сложна в настройке и настройке. Мы будем использовать базу данных для наших тестов, но вместо подключения к внешней базе данных будет запускаться и запускаться как часть модульного теста. Это дает нам следующее:

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

Моей первой реакцией, когда я увидел этот подход в использовании, было «как тесты могут быть значимыми, если они не запускаются на том же программном обеспечении базы данных, которое используется в производстве»? Ответ двусторонний. Мы должны запустить эти тесты на одном и том же программном обеспечении базы данных. На самом деле, я призываю вас сделать это — на вашем сервере непрерывной интеграции. Тесты имеют смысл в том смысле, что они могут делать утверждения и использовать ваш код. Проблемы, связанные с платформой базы данных, могут быть устранены на сервере сборки, который может выполнять те же тесты с использованием другой конфигурации.

Для настройки я рекомендую использовать Apache Derby или Hypersonic . Вот как это делается с Hypersonic и Eclipselink в качестве нашего поставщика JPA:

  1. добавьте hsqldb.jar в путь к классам тестового проекта (это для Hypersonic)
  2. настройте соединение JDBC для использования URL-адреса базы данных в памяти следующим образом: jdbc: hsqldb: mem: tests
  3. настройте ваш файл persistence.xml с правильным диалектом SQL. Для Eclipselink это делается путем установки eclipselink.target-database в HSQL следующим образом:
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<persistence xmlns="http://java.sun.com/xml/ns/persistence" 
 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.0" 
 xsi:schemaLocation="http://java.sun.com/xml/ns/persistence
  http://java.sun.com/xml/ns/persistence/persistence_1_0.xsd">
    <persistence-unit name="blogDomain" transaction-type="RESOURCE_LOCAL">
        <provider>org.eclipse.persistence.jpa.PersistenceProvider</provider>
        ... snip ...
        <properties>
            <property name="eclipselink.target-database" value="HSQL"/>
            <property name="eclipselink.ddl-generation" value="create-tables"/>
            <property name="eclipselink.ddl-generation.output-mode" 
                             value="database"/>
            <property name="eclipselink.weaving" value="false"/>
            <property name="eclipselink.logging.level" value="INFO"/>
        </properties>
    </persistence-unit>
</persistence>

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

Тестовые данные

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

 @Test
 public void testUpdateBlog() {
  Blog blog = MockFactory.on(Blog.class).create(entityManager);
  entityManager.flush();
  entityManager.clear();
  
  blog.setName(blog.getName()+"2");
  
  Blog updatedBlog = service.updateBlog(blog);
  assertNotNull(updatedBlog);
  assertNotSame(updatedBlog,blog);
  assertEquals(blog.getName(),updatedBlog.getName());
  assertEquals(blog.getId(),updatedBlog.getId());
 }
 

В этом примере тест проверяет, что сущность Blog правильно обновлена ​​службой. Обратите внимание, что тест не должен был заполнять объект блога, прежде чем сохранить его: все необходимые значения были заполнены MockFactory.

Чтобы это работало, нам понадобится реализация MockFactory. Вот как выглядит наш:

/**
 * A factory for domain objects that mocks their data.
 * Example usage:
 * <pre><code>
 * Blog blog = MockFactory.on(Blog.class).create(entityManager);
 * </code></pre>
 * @author David Green
 */
public abstract class MockFactory<T> {

 private static Map<Class<?>,MockFactory<?>> factories = 
      new HashMap<Class<?>, MockFactory<?>>();
 static {
  register(new MockBlogFactory());
  register(new MockArticleFactory());
 }
 private static void register(MockFactory<?> mockFactory) {
  factories.put(mockFactory.domainClass,mockFactory);
 }
 @SuppressWarnings("unchecked")
 public static <T> MockFactory<T> on(Class<T> domainClass) {
  MockFactory<?> factory = factories.get(domainClass);
  if (factory == null) {
   throw new IllegalStateException(
    "Did you forget to register a mock factory for "+
      domainClass.getClass().getName()+"?");
  }
  return (MockFactory<T>) factory;
 }
 
 private final Class<T> domainClass;

 private int seed;
 
 protected MockFactory(Class<T> domainClass) {
  if (domainClass.getAnnotation(Entity.class) == null) {
   throw new IllegalArgumentException();
  }
  this.domainClass = domainClass;
 }

 /**
  * Create several objects
  * @param entityManager the entity manager, or null if the mocked objects
  *            should not be persisted
  * @param count the number of objects to create
  * @return the created objects
  */
 public List<T> create(EntityManager entityManager,int count) {
  List<T> mocks = new ArrayList<T>(count);
  for (int x = 0;x<count;++x) {
   T t = create(entityManager);
   mocks.add(t);
  }
  return mocks;
 }

 /**
  * Create a single object
  * @param entityManager the entity manager, or null if the mocked object
  *        should not be persisted
  * @return the mocked object
  */
 public T create(EntityManager entityManager) {
  T mock;
  try {
   mock = domainClass.newInstance();
  } catch (Exception e) {
   // must have a default constructor
   throw new IllegalStateException();
  }
  populate(++seed,mock);
  if (entityManager != null) {
   entityManager.persist(mock);
  }
  return mock;
 }

 /**
  * Populate the given domain object with data
  * @param seed a seed that may be used to create data
  * @param mock the domain object to populate
  */
 protected abstract void populate(int seed, T mock);
 

 private static class MockBlogFactory extends MockFactory<Blog> {
  public MockBlogFactory() {
   super(Blog.class);
  }
  
  @Override
  protected void populate(int seed, Blog mock) {
   mock.setName("Blog "+seed);
  }
 }

 private static class MockArticleFactory extends MockFactory<Article> {
  
  public MockArticleFactory() {
   super(Article.class);
  }
  
  @Override
  protected void populate(int seed, Article mock) {
   mock.setAuthor("First Last");
   mock.setTitle("Article "+seed);
   mock.setContent("article "+seed+" content");
  }
 }
}

Эта реализация использует статические методы и статический инициализатор. Это не очень похоже на весну. Если вы предпочитаете, вы можете избавиться от всей статики и вместо этого сделать Spring впрыскивать (autowire) ваши фиктивные фабрики в ваши тестовые классы.

В нашей реализации мы используем целочисленное начальное число, чтобы помочь нам получить значения, которые варьируются. Эта реализация довольно тривиальна, однако при необходимости мы можем применить более продвинутые методы, такие как использование словарей данных для исходных данных. Такие фиктивные фабрики должны заполнять достаточно значений, чтобы можно было сохранить объект фиктивного домена: все ненулевые свойства должны иметь значения. Чтобы убедиться, что это верно в течение срока службы нашего проекта, важно пройти тест:

/**
 * test that {@link MockFactory mock factories} are working as expected
 * 
 * @author David Green
 */
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration( { "/applicationContext-test.xml" })
@Transactional
public class MockFactoryTest {

 @PersistenceContext
 private EntityManager entityManager;

 @Test
 public void testCreateBlog() {
  Blog blog = MockFactory.on(Blog.class).create(entityManager);
  assertNotNull(blog);
  assertNotNull(blog.getName());
  entityAssertions(blog);
  entityManager.flush();
 }
 @Test
 public void testCreateArticle() {
  Blog blog = MockFactory.on(Blog.class).create(entityManager);
  Article article = MockFactory.on(Article.class).create(null);
  article.setBlog(blog);
  entityManager.persist(article);
  assertNotNull(article);
  assertNotNull(article.getTitle());
  assertNotNull(article.getContent());
  entityAssertions(article);
  entityManager.flush();
 }
 private void entityAssertions(AbstractEntity entity) {
  assertNotNull(entity.getId());
  assertNotNull(entity.getCreated());
  assertNotNull(entity.getModified());
 }
}

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

Теперь, когда у нас есть простой способ получения данных в наших тестах, как мы можем устранить потенциальные побочные эффекты между тестами? Эта часть проста: мы обеспечиваем откат транзакций после каждого теста. В нашем примере мы используем Spring для запуска наших тестов через SpringJUnit4ClassRunner. Его поведение по умолчанию — откат после каждого запуска теста, однако, если вы не используете Spring, у вас должно быть что-то вроде этого в абстрактном классе тестового примера:

 /**
  * Overriding methods should call super.
  */ 
 @After
 public void after() {
  if (entityManager.getTransaction().isActive()) {
   entityManager.getTransaction().rollback();
  }
 }

Резюме

Мы увидели, как можно упростить модульное тестирование с помощью нескольких простых шаблонов:

  • база данных в памяти
  • поддельные данные
  • откат транзакций после каждого теста

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

В моей следующей статье я опубликую полный рабочий исходный код и покажу, как эти методы могут стать шагом вперед в тестировании веб-сервисов Spring REST.

От http://greensopinion.blogspot.com/2010/07/patterns-for-better-unit-testing-with.html