Статьи

Spring Pitfalls: транзакционные тесты считаются вредными

Одной из особенностей Spring killer является тестирование интеграции в контейнере. В то время как EJB испытывал недостаток в этой функциональности в течение многих лет (Java EE 6, наконец, решает эту проблему, однако я не проверял ее, ekhem), Spring с самого начала позволял вам тестировать полный стек, начиная с веб-уровня, с помощью сервисов всех вплоть до базы данных.

База данных является проблемной частью. Сначала вам нужно использовать автономную базу данных в памяти, такую ​​как H2, чтобы отделить ваши тесты от внешней базы данных. Spring очень помогает в этом, особенно сейчас, с профилями и поддержкой встроенных баз данных . Вторая проблема более тонкая. В то время как типичное приложение Spring практически полностью не сохраняет состояния (в лучшую или худшую сторону), база данных по своей сути является состоящей из состояний. Это усложняет интеграционное тестирование, поскольку самый первый принцип написания тестов состоит в том, что они должны быть независимыми друг от друга и повторяемыми. Если один тест записывает что-то в базу данных, другой тест может не сработать; также тот же тест может не пройти при последующем вызове из-за изменений в базе данных.

Очевидно, что Spring также решает эту проблему очень аккуратно: перед началом каждого теста Spring запускает новую транзакцию. Весь тест (включая его настройку и демонтаж) выполняется в рамках одной транзакции, которая … откатывается в конце. Это означает, что все изменения, сделанные во время теста, видны в базе данных, как если бы они были сохранены. Однако откат после каждого теста стирает все изменения, и следующий тест работает на чистой и свежей базе данных. Brilliant!

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

01
02
03
04
05
06
07
08
09
10
11
12
13
@Test
public void shouldThrowLazyInitializationExceptionWhenFetchingLazyOneToManyRelationship() throws Exception {
  //given
  final Book someBook = findAnyExistingBook();
  
  //when
  try {
    someBook.reviews().size();
    fail();
  } catch(LazyInitializationException e) {
    //then
  }
}

Это известная проблема с Hibernate и весенним интеграционным тестированием. Книга — это объект базы данных с ленивым по умолчанию отношением к отзывам. findAnyExistingBook () просто читает тестовую книгу из транзакционного сервиса. Теперь немного теории: пока объект связан с сеансом (EntityManager, если используется JPA), он может лениво и прозрачно загружать отношения. В нашем случае это означает: пока это находится в рамках транзакции. В тот момент, когда объект покидает транзакцию, он становится отделенным. На этом этапе жизненного цикла сущность больше не присоединяется к session / EntityManager (который уже был зафиксирован и уже закрыт), и любой подход к извлечению отложенных свойств вызывает ужасное исключение LazyInitializationException. Это поведение на самом деле стандартизировано в JPA (за исключением самого класса исключений, который зависит от поставщика).

В нашем случае мы вызываем .reviews () («getter» в стиле Scala, мы скоро также переведем наш тестовый сценарий в ScalaTest) и ожидаем увидеть исключение Hibernate. Однако исключение не выдается, и приложение продолжает работать. Это потому, что весь тест выполняется внутри транзакции, а сущность Book никогда не выходит из области транзакции. Ленивая загрузка всегда работает в интеграционных тестах Spring.

Честно говоря, мы никогда не увидим подобные тесты в реальной жизни (если только вы не тестируете, чтобы убедиться, что данная коллекция ленивая — вряд ли). В реальной жизни мы тестируем бизнес-логику, которая просто работает в тестах. Однако после развертывания мы начинаем испытывать LazyInitializationException. Но мы проверили это! Не только поддержка тестирования интеграции Spring скрыла проблему , но и побуждает разработчика добавить OpenSessionInViewFilter или OpenEntityManagerInViewFilter . Другими словами: наш тест не только не обнаружил ошибку в нашем коде, но также значительно ухудшил нашу общую архитектуру и производительность. Не то, что я ожидал.

Мой типичный рабочий процесс в настоящее время при реализации какой-либо сквозной функции — это написание внутренних тестов, реализация бэкэнда, включая REST API, и когда все идет гладко, разверните его и переходите к графическому интерфейсу. Последний полностью написан с использованием AJAX / JavaScript, поэтому мне нужно только развернуть его и часто заменять дешевые клиентские файлы. На этом этапе я не хочу возвращаться на сервер, чтобы исправить обнаруженные ошибки.

Подавление исключения LazyInitializationException относится к числу наиболее известных проблем при тестировании интеграции Spring. Но это только верхушка айсберга. Вот немного более сложный пример (он снова использует JPA, но эта проблема проявляется и в простом JDBC, и в любой другой технологии персистентности):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
@Test
public void externalThreadShouldSeeChangesMadeInMainThread() throws Exception {
  //given
  final Book someBook = findAnyExistingBook();
  someBook.setAuthor("Bruce Wayne");
  bookService.save(someBook);
  
  //when
  final Future<Book> future = executorService.submit(new Callable<Book>() {
    @Override
    public Book call() throws Exception {
      return bookService.findBy(someBook.id()).get();
    }
  });
  
  //then
  assertThat(future.get().getAuthor()).isEqualTo("Bruce Wayne");
}

На первом этапе мы загружаем некоторую книгу из базы данных и модифицируем автора, а затем сохраняем сущность. Затем мы загружаем ту же сущность по id в другой поток. Сущность уже сохранена, поэтому гарантируется, что поток увидит изменения. Однако это не тот случай, что подтверждается неверным утверждением на последнем шаге. Что произошло?

Мы только что наблюдали «Я» в свойствах транзакции ACID . Изменения, сделанные тестовым потоком, не видны другим потокам / соединениям, пока транзакция не будет зафиксирована. Но мы знаем, что тестовая транзакция фиксируется! Эта небольшая демонстрация демонстрирует, как трудно писать многопоточные интеграционные тесты с поддержкой транзакций. Несколько недель назад я усвоил трудный путь, когда хотел провести интеграционное тестирование планировщика Quartz с включенным JDBCJobStore . Как бы я ни старался, работы никогда не увольняли. Оказалось, что я планировал задание в тесте под управлением Spring в рамках транзакции Spring. Поскольку транзакция не была зафиксирована, внешний планировщик и рабочие потоки не смогли увидеть новую запись задания в базе данных. И сколько часов вы потратили на устранение таких проблем?

Говоря об отладке, та же проблема возникает при устранении неполадок, связанных с ошибками в тесте базы данных. Я могу добавить этот простой компонент веб-консоли H2 (перейдите к localhost: 8082) в свою тестовую конфигурацию:

1
2
@Bean(initMethod = "start", destroyMethod = "stop")
def h2WebServer() = Server.createWebServer("-webDaemon", "-webAllowOthers")

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

Пожалуйста, внимательно прочитайте следующий тест, он не длинный:

01
02
03
04
05
06
07
08
09
10
11
12
@Test
public void shouldNotSaveAndLoadChangesMadeToNotManagedEntity() throws Exception {
  //given
  final Book unManagedBook = findAnyExistingBook();
  unManagedBook.setAuthor("Clark Kent");
  
  //when
  final Book loadedBook = bookService.findBy(unManagedBook.id()).get();
  
  //then
  assertThat(loadedBook.getAuthor()).isNotEqualTo("Clark Kent");
}

Мы загружаем книгу и изменяем автора, не сохраняя его явно. Затем мы снова загружаем его из базы данных и проверяем, чтобы изменения не были сохранены. Угадайте что, как-то мы обновили объект!

Если вы опытный пользователь JPA / Hibernate, вы точно знаете, как это могло произойти. Помните, когда я описывал прикрепленные / отсоединенные объекты выше? Когда объект все еще присоединен к базовому EntityManager / сеансу, он также имеет другие полномочия. Поставщик JPA обязан отслеживать изменения, внесенные в такие объекты, и автоматически распространять их в базу данных, когда объект становится отсоединенным (так называемая грязная проверка).

Это означает, что идиоматический способ работы с модификациями сущностей JPA состоит в том, чтобы загрузить объект из базы данных, выполнить необходимые изменения с помощью установщиков и… вот и все. Когда объект становится отделенным, JPA обнаружит, что он был изменен, и выдаст ОБНОВЛЕНИЕ для вас. Нет необходимости в merge () / update (), симпатичная абстракция объекта. Это работает, пока сущностью управляют. Изменения, внесенные в отдельную сущность, игнорируются, поскольку провайдер JPA ничего не знает о таких сущностях. Теперь лучшая часть — вы почти никогда не знаете, привязана ли ваша сущность или нет, потому что управление транзакциями прозрачно и почти невидимо. Это означает, что слишком легко изменять только экземпляры POJO в памяти, в то же время полагая, что изменения являются постоянными, и наоборот!

Можем ли мы проверить это? Конечно, мы только что сделали — и потерпели неудачу. В нашем тесте, описанном выше, транзакция охватывает весь метод теста, поэтому каждый объект управляется. Также из-за кэша Hibernate L1 мы получаем тот же экземпляр книги, хотя обновление базы еще не было выпущено. Это еще один случай, когда транзакционные тесты скрывают проблемы, а не выявляют их (см. Пример LazyInitializationException). Изменения распространяются на базу данных, как и ожидалось в тесте, но после развертывания молча игнорируются …

Кстати, я упоминал, что все тесты до сих пор проходят, как только вы избавляетесь от аннотации @Transactional над классом тестового примера? Посмотрите, источники как всегда доступны .

Этот захватывающий. У меня есть транзакционный бизнес-метод deleteAndThrow (book), который удаляет данную книгу и выдает OppsException. Вот мой тест, который подтверждает, что код верен:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
@Test
public void shouldDeleteEntityAndThrowAnException() throws Exception {
  //given
  final Book someBook = findAnyExistingBook();
  
  try {
    //when
    bookService.deleteAndThrow(someBook);
    fail();
  } catch (OppsException e) {
    //then
    final Option<Book> deletedBook = bookService.findBy(someBook.id());
    assertThat(deletedBook.isEmpty()).isTrue();
  }
  
}

Опция <Book> в Scala возвращается (вы заметили, насколько хорошо Java-код взаимодействует со службами и сущностями, написанными в Scala?) Вместо null. Удаленный Book.isEmpty () с истиной означает, что результат не был найден. Похоже, наш код верен: объект был удален, а исключение выдано. Да, вы правы, после развертывания снова ничего не происходит! На этот раз кэш Hibernate L1 знает, что этот конкретный экземпляр книги был удален, поэтому он возвращает ноль даже до сброса изменений в базу данных. Однако OppsException, выброшенное из сервисов, откатывает транзакцию, отбрасывая DELETE! Но тест проходит только потому, что Spring управляет этой крошечной дополнительной транзакцией, и утверждение происходит внутри этой транзакции. Через миллисекунды транзакция откатывается, воскрешая удаленную сущность.

Очевидно, что решением было добавить атрибут noRollbackFor для OppsException (это реальная ошибка, которую я обнаружил в своем коде после удаления транзакционных тестов в пользу другого решения, которое еще предстоит объяснить). Но это не главное. Дело в том — можете ли вы позволить себе писать и поддерживать тесты, которые генерируют ложные срабатывания, убеждая вас, что ваше приложение работает, а оно нет?

О, и я упоминал, что трансациональные тесты действительно протекают здесь и там и не помешают ли вам изменить базу данных тестов? Второй тест не пройден, понимаете почему?

01
02
03
04
05
06
07
08
09
10
11
@Test
public void shouldStoreReviewInSecondThread() throws Exception {
  final Book someBook = findAnyExistingBook();
  
  executorService.submit(new Callable<Review>() {
    @Override
    public Review call() throws Exception {
      return reviewDao.save(new Review("Unicorn", "Excellent!!!1!", someBook));
    }
  }).get();
}
01
02
03
04
05
06
07
08
09
10
@Test
public void shouldNotSeeReviewStoredInPreviousTest() throws Exception {
  //given
  
  //when
  final Iterable<Review> reviews = reviewDao.findAll();
  
  //then
  assertThat(reviews).isEmpty();
}

Еще раз нить получает в пути. Это становится еще интереснее, когда вы пытаетесь выполнить очистку после внешней транзакции в фоновом потоке, которая, очевидно, была зафиксирована. Естественным местом будет удаление созданного Review в методе @After. Но @After выполняется в рамках той же тестовой транзакции, поэтому очистка будет… откатана.

Конечно, я здесь не для того, чтобы жаловаться и ворчать по поводу моих слабых мест в стеке приложений. Я здесь, чтобы дать решения и подсказки. Наша цель — полностью избавиться от транзакционных тестов и зависеть только от транзакций приложений. Это поможет нам избежать всех вышеупомянутых проблем. Очевидно, что мы не можем отбросить функции проверки независимости и повторяемости. Каждый тест должен работать на одной базе данных, чтобы быть надежным. Сначала мы переведем тест JUnit в ScalaTest. Чтобы иметь поддержку Spring для внедрения зависимостей, нам нужна эта небольшая черта:

1
2
3
4
5
6
7
8
trait SpringRule extends Suite with BeforeAndAfterAll { this: AbstractSuite =>
  
  override protected def beforeAll() {
    new TestContextManager(this.getClass).prepareTestInstance(this)
    super.beforeAll();
  }
  
}

Теперь пришло время раскрыть мою идею (если вы нетерпеливы, полный исходный код здесь ). Это далеко не гениально или уникально, но я думаю, что заслуживает некоторого внимания. Вместо того, чтобы запускать все в одной огромной транзакции и откатывать ее, просто позвольте протестированному коду запускать и фиксировать транзакции, где бы и когда бы это ни требовалось и не было настроено. Это означает, что данные фактически записываются в базу данных, а постоянство работает точно так же, как и после развертывания. Где подвох? Надо как-то навести порядок после каждого теста…

Оказывается, это не так сложно. Просто возьмите дамп чистой базы данных и импортируйте его после каждого теста! Дамп содержит все таблицы, ограничения и записи, представленные сразу после развертывания и запуска приложения, но до первого запуска теста. Это как взять резервную копию и восстановить ее! Посмотрите, как это просто с H2:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
trait DbResetRule extends Suite with BeforeAndAfterEach with BeforeAndAfterAll { this: SpringRule =>
  
  @Resource val dataSource: DataSource = null
  
  val dbScriptFile = File.createTempFile(classOf[DbResetRule].getSimpleName + "-", ".sql")
  
  override protected def beforeAll() {
    new JdbcTemplate(dataSource).execute("SCRIPT NOPASSWORDS DROP TO '" + dbScriptFile.getPath + "'")
    dbScriptFile.deleteOnExit()
    super.beforeAll()
  }
  
  override protected def afterEach() {
    super.afterEach()
    new JdbcTemplate(dataSource).execute("RUNSCRIPT FROM '" + dbScriptFile.getPath + "'")
  
  }
  
}
  
trait DbResetSpringRule extends DbResetRule with SpringRule

Дамп SQL (см. Команду H2 SCRIPT ) берется один раз и экспортируется во временный файл. Затем файл сценария SQL выполняется после каждого теста. Хотите верьте, хотите нет, вот и все! Наш тест больше не является транзакционным (поэтому обнаруживаются и тестируются все угловые случаи Hibernate и многопоточности), в то время как мы не жертвовали простотой настройки транзакционных тестов (дополнительная очистка не требуется). Также я могу, наконец, посмотреть на содержимое базы данных во время отладки! Вот один из предыдущих тестов в действии:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
@RunWith(classOf[JUnitRunner])
@ContextConfiguration(classes = Array[Class[_]](classOf[SpringConfiguration]))
class BookServiceTest extends FunSuite with ShouldMatchers with BeforeAndAfterAll with DbResetSpringRule {
  
  @Resource
  val bookService: BookService = null
  
  private def findAnyExistingBook() = bookService.listBooks(new PageRequest(0, 1)).getContent.head
  
  test("should delete entity and throw an exception") {
    val someBook = findAnyExistingBook()
  
    intercept[OppsException] {
      bookService.deleteAndThrow(someBook)
    }
  
    bookService findBy someBook.id should be (None)
  }
}

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

Если честно, есть и минусы, вот некоторые из них:

  • Производительность : хотя не очевидно, что этот подход значительно медленнее, чем откат транзакций все время (некоторые базы данных особенно медленны при откате), можно с уверенностью предположить, что так. Конечно, базы данных в памяти могут иметь некоторые неожиданные характеристики производительности, но будьте готовы к замедлению. Однако я не заметил огромной разницы (возможно, около 10%) на 100 тестов в небольшом проекте.
  • Параллельность : вы больше не можете запускать тесты одновременно. Изменения, сделанные одним потоком (тестом), видны другим, что делает выполнение теста непредсказуемым. Это становится еще более болезненным в отношении вышеупомянутых проблем с производительностью.

Это было бы это. Если вы заинтересованы, дайте этому подходу шанс. Может потребоваться некоторое время, чтобы принять существующую тестовую базу, но обнаружение хотя бы одной скрытой ошибки того стоит, не так ли? А также знать о других весенних ловушках .

Ссылка: весенние подводные камни: транзакционные тесты были признаны вредоносными от нашего партнера по JCG Томаша Нуркевича в блоге NoBlogDefFound .

Статьи по Теме :