spring 测试 事务_Spring陷阱:事务测试被认为是有害的

spring 测试 事务

Spring杀手级功能之一是容器内集成测试 。 尽管EJB多年来一直缺乏此功能(Java EE 6终于解决了这个问题,但是我尚未进行测试),但是Spring从一开始就允许您从Web层开始,通过所有服务来测试整个堆栈。到数据库的方式。

数据库是有问题的部分。 首先,您需要使用内存中的独立数据库(例如H2)将测试与外部数据库分离。 Spring在很大程度上帮助了这一点,尤其是现在有了配置文件和嵌入式数据库支持 。 第二个问题更加微妙。 尽管典型的Spring应用程序几乎是完全无状态的(无论好坏),但数据库固有地是有状态的。 这使集成测试变得复杂,因为编写测试的第一个原则是它们应该彼此独立且可重复。 如果一个测试将某些内容写入数据库,则另一个测试可能会失败; 而且由于数据库更改,相同的测试可能在后续调用中失败。

显然,Spring还可以通过一个非常巧妙的技巧来解决此问题 :在运行每个测试之前,Spring会启动一个新事务。 整个测试(包括其设置和拆除)在同一事务中运行,该事务在最后回滚。 这意味着测试期间所做的所有更改在数据库中都是可见的,就像它们被持久化一样。 但是,每次测试后的回滚都会清除所有更改,而下一个测试将在一个全新的数据库上进行。 辉煌!

不幸的是,这不是关于Spring集成测试优势的另一篇文章。 我想我已经编写了成百上千个这样的测试,而我真的很感谢Spring框架提供的透明支持。 但是我也遇到了这个舒适功能带来的众多怪异和不一致之处。 更糟糕的是,通常所谓的事务测试实际上隐藏了错误,使开发人员确信该软件可以工作,而部署后却会失败! 这是一个不详尽但令人大开眼界的问题集合:

@Test
public void shouldThrowLazyInitializationExceptionWhenFetchingLazyOneToManyRelationship() throws Exception {
  //given
  final Book someBook = findAnyExistingBook();
 
  //when
  try {
    someBook.reviews().size();
    fail();
  } catch(LazyInitializationException e) {
    //then
  }
}

这是Hibernate和spring集成测试中的一个已知问题。 Book是一个数据库实体,与“评论”之间存在一对多的关系,默认情况下是惰性的。 findAnyExistingBook()只是从事务服务中读取测试书。 现在有点理论了:只要将实体绑定到会话(如果使用JPA,则为EntityManager),它就可以延迟并透明地加载关系。 在我们的情况下,这意味着:只要它在交易范围之内。 实体离开交易的那一刻,它就变得分离。 在此生命周期阶段,实体不再连接到session / EntityManager(已经提交并关闭),并且任何获取懒惰属性的方法都将引发可怕的LazyInitializationException。 此行为实际上是在JPA中标准化的(异常类本身除外,它是特定于供应商的)。

在本例中,我们正在调用.reviews()(Scala风格的“ getter”,我们也将尽快将测试用例转换为ScalaTest),并期望看到Hibernate异常。 但是,不会引发异常,并且应用程序会继续运行。 这是因为整个测试都在事务内运行,并且Book实体永远不会超出事务范围。 延迟加载在Spring集成测试中始终有效。

公平地说,我们在现实生活中永远不会看到这样的测试(除非您要进行测试以确保给定的集合是惰性的-不太可能)。 在现实生活中,我们正在测试仅在测试中起作用的业务逻辑。 但是,在部署之后,我们开始遇到LazyInitializationException。 但是我们测试了! Spring集成测试支持不仅隐藏了该问题 ,而且还鼓励开发人员使用OpenSessionInViewFilterOpenEntityManagerInViewFilter 。 换句话说:我们的测试不仅没有发现代码中的错误,而且还大大恶化了我们的整体体系结构和性能。 不是我所期望的。

目前,实现某些端到端功能时,我典型的工作流程是编写后端测试,实现包括REST API的后端,并在一切运行顺利时进行部署并继续使用GUI。 后者是完全使用AJAX / JavaScript编写的,因此我只需要部署一次并经常替换便宜的客户端文件。 在此阶段,我不想回到服务器来修复未发现的错误。

抑制LazyInitializationException是Spring集成测试中最著名的问题之一。 但这只是冰山一角。 这是一个更复杂的示例(它再次使用JPA,但此问题在普通JDBC和任何其他持久性技术中也很明显):

@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事务属性中观察到“ I”。 在提交事务之前,测试线程所做的更改对其他线程/连接不可见。 但是我们知道测试事务已提交! 这个小展示展示了在事务支持下编写多线程集成测试有多么困难。 几周前,当我想对启用JDBCJobStore的 Quartz调度程序进行集成测试时,我学到了很难的方法。 无论我多么努力,这些工作从未被解雇。 原来,我是在Spring事务范围内安排在Spring管理的测试中的工作。 由于从未提交过事务,因此外部调度程序和工作线程无法在数据库中看到新的作业记录。 您花了几个小时调试此类问题?

在谈论调试时,对数据库相关的测试失败进行故障排除时会弹出相同的问题。 我可以将此简单的H2 Web控制台(浏览到localhost:8082)bean添加到我的测试配置中:

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

但是在逐步进行测试时,我永远不会看到测试所做的更改。 我无法手动运行查询以查明为什么返回错误结果。 另外,在进行故障排除时,我无法即时修改数据以更快地周转。 我的数据库位于另一个维度。

请仔细阅读下一个测试,时间不长:

@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实体修改的惯用方式是从数据库中加载对象,使用setter执行必要的更改,仅此而已。 当实体分离时,JPA将发现它已被修改并为您发出UPDATE。 不需要merge()/ update(),可爱的对象抽象。 只要管理实体,此方法就起作用。 对分离的实体所做的更改将被静默忽略,因为JPA提供程序对此类实体一无所知。 现在最好的部分–您几乎不知道您的实体是否已附加,因为事务管理是透明的并且几乎是不可见的。 这意味着只修改内存中的POJO实例,同时仍然认为更改是持久的,反之亦然,这太容易了!

我们可以测试吗? 当然,我们只是做了–失败了。 在我们的测试中,交易涉及整个测试方法,因此每个实体都受到管理。 同样由于Hibernate L1缓存,即使尚未发布数据库更新,我们也可以获取完全相同的图书实例。 这是事务测试隐藏问题而不是揭示问题的另一种情况(请参阅LazyInitializationException示例)。 更改将如测试中所预期的那样传播到数据库,但是在部署后会被静默忽略……

顺便说一句,我是否提到,一旦您摆脱了测试用例类的@Transactional批注,到目前为止,所有测试都通过了? 看看,来源永远是可用的

这是令人兴奋的。 我有一个事务性的deleteAndThrow(book)业务方法,该方法删除给定的书并引发OppsException。 这是我通过的测试,证明代码正确:

@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();
  }
 
}

返回Scala的Option <Book> (您是否已经注意到Java代码与用Scala编写的服务和实体交互得很好吗?),而不是null。 如果deleteBook.isEmpty()的结果为true,则表示未找到结果。 因此,似乎我们的代码是正确的:实体已删除,并且引发了异常。 是的,您是正确的,它在再次部署后会静默失败! 这次Hibernate L1缓存知道该特定的book实例已删除,因此即使在刷新更改到数据库之前它也返回null。 但是,从服务抛出的OppsException会回滚事务,并丢弃DELETE! 但是测试通过了,只是因为Spring管理着这个微小的额外事务,并且断言发生在该事务内。 毫秒后,事务回滚,恢复已删除的实体。

显然,解决方案是为OppsException添加noRollbackFor属性(这是我在放弃事务性测试以支持其他解决方案后在我的代码中发现的实际错误,目前尚待解释)。 但这不是重点。 关键是– 您真的可以负担起编写和维护正在生成假阳性的测试,说服您的应用程序正常运行的能力,而事实并非如此?

哦,我是否提到过跨语言测试实际上在这里和那里泄漏,并且不会阻止您修改测试数据库? 第二次测试失败,您知道为什么吗?

@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();
}
@Test
public void shouldNotSeeReviewStoredInPreviousTest() throws Exception {
  //given
 
  //when
  final Iterable<Review> reviews = reviewDao.findAll();
 
  //then
  assertThat(reviews).isEmpty();
}

线程再次陷入困境。 当您尝试在显然已提交的后台线程中进行外部事务处理后进行清理时,它会变得更加有趣。 自然的地方是在@After方法中删除创建的Review。 但是@After是在同一测试事务中执行的,因此清理将…回滚。

当然,我并不是在抱怨我最喜欢的应用程序堆栈弱点。 我在这里提供解决方案和提示。 我们的目标是完全摆脱事务测试,仅依赖于应用程序事务。 这将有助于我们避免上述所有问题。 显然,我们不能放弃测试的独立性和可重复性功能。 每个测试必须在同一数据库上工作才能可靠。 首先,我们将把JUnit测试转换为ScalaTest。 为了获得Spring依赖注入支持,我们需要这个微小的特征:

trait SpringRule extends Suite with BeforeAndAfterAll { this: AbstractSuite =>
 
  override protected def beforeAll() {
    new TestContextManager(this.getClass).prepareTestInstance(this)
    super.beforeAll();
  }
 
}

现在是时候揭示我的想法了(如果您不耐烦,请在此处查看完整的源代码 )。 它远非独创性或独特性,但我认为它值得关注。 无需在一个巨大的事务中运行所有内容并将其回滚,只需让经过测试的代码在需要和配置的任何地方,任何时间启动和提交事务即可。 这意味着数据实际上已写入数据库,并且持久性的工作原理与部署后完全相同。 渔获物在哪里? 每次测试后,我们都必须以某种方式清理混乱……

事实证明它并不那么复杂。 只需转储干净的数据库,然后在每次测试后将其导入! 转储包含在部署和应用程序启动之后,第一次测试运行之前立即存在的所有表,约束和记录。 就像备份并从中还原一样! 看看H2有多简单:

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和多线程的极端情况都已被发现和测试),而我们并没有牺牲事务性测试设置的简便性(不需要额外的清理)。 我最终还可以在调试时查看数据库内容! 这是进行中的先前测试之一:

@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或任何您喜欢的东西获得完全相同的结果。 当然,您也可以添加一些巧妙的优化方法-标记或发现未修改数据库的测试,选择更快的转储,导入方法等。

老实说,还有一些弊端,以下是我脑海中的一些缺点:

  • 性能 :尽管这种方法并不总是比回滚事务慢得多(某些数据库回滚的速度特别慢),但并不明显,但可以肯定地说。 当然,内存数据库可能具有一些意外的性能特征,但要为速度变慢做好准备。 但是,我没有在一个小项目中观察到每100个测试有巨大的差异(大约10%)。
  • 并发 :您不再可以同时运行测试。 一个线程(测试)所做的更改对其他线程可见,从而使测试执行无法预测。 对于上述性能问题,这甚至变得更加痛苦。

就是这样。 如果您有兴趣,请给这种方法一个机会。 采用您现有的测试基础可能需要一些时间,但是即使发现一个隐藏的错误也值得,您是不是认为呢? 并且还要注意其他Spring陷阱

参考: 春天的陷阱: NoBlogDefFound Blog上的 JCG合作伙伴 Tomasz Nurkiewicz 认为有害的事务测试

相关文章 :

翻译自: https://www.javacodegeeks.com/2011/12/spring-pitfalls-transactional-tests.html

spring 测试 事务

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值