发布于 2026-01-06 1 阅读
0

不要在测试中使用 @Transactional 注解。不要在测试中使用 @Transactional 注解。假阴性是指当生产代码存在 bug 时,本应失败的测试却成功运行。

不要在测试中使用 @Transactional 属性

不要在测试中使用 @Transactional 属性

假阴性是指当生产代码存在错误时,本应失败的测试却成功运行。

不要在测试中使用 @Transactional 属性

如何避免毁掉你的 Spring Boot 应用程序测试套件

照片:[Braulio Cassule](未定义)和 Jesuelson Dacosta照片:Braulio Cassule和 Jesuelson Dacosta

请在GitHubTwitter上关注我点赞收藏这篇文章,帮助它触达更多读者。

源代码

您可以在这里找到本文使用的示例代码。该示例代码包含 3 个分支,第一个分支使用了 @Transactional 注解,第二个分支没有使用,第三个分支则使用了适当的替代方案。

如果您需要运行此项目,则需要在您的计算机上安装PostgreSQL。然后配置以下环境变量:

  • 数据库主机:localhost

  • 数据库名称:避免事务

  • 数据库用户名:postgres

  • 数据库密码:你的密码

当然,您可以根据本地数据库配置提供不同的值。

我建议您创建两个数据库,一个用于生产环境,另一个用于测试环境。这样,您可以使用 cURL 或 Postman 等工具运行测试,而不会干扰手动生产环境测试。您需要在测试和测试之间更改DATABASE_NAME变量。

问题

当我们使用 Spring Boot 进行集成测试时(这些测试会测试代码的多个层,而且速度通常很慢),我们倾向于使用 @Transactional 注解来保证在测试执行后清理数据,因为我们想要保证测试套件的确定性



@SpringBootTest
@Transactional
internal class CartItemsControllerTests {

    ...

    @Test
    fun getAllCartItems() {
        val cart = Cart()
        cart.addProduct(product, 3)
        cartsRepository.save(cart)

        mockMvc.perform(get("/carts/{id}/items", cart.id))
            .andExpect(status().isOk)
            .andExpect(jsonPath("$.[*].id").value(hasItem(cart.items[0].id.toString())))
            .andExpect(jsonPath("$.[*].product.id").value(hasItem(product.id.toString())))
            .andExpect(jsonPath("$.[*].product.name").value(hasItem(product.name)))
            .andExpect(jsonPath("$.[*].quantity").value(3))
    }
}


Enter fullscreen mode Exit fullscreen mode

当然,您也可以使用 @Transactional 注解来注解您的测试类,而不是注解所有测试方法。

像这样创建测试的问题在于,它极易导致测试套件出现大量误报,从而破坏测试结果。误报是指,由于生产代码中的错误,本应失败的测试却成功运行。

大量的漏报会使你的测试套件失去作用,原因有二:

  • 开发人员对测试套件的信心会降低,人们会开始害怕进行更改,因为更改可能会破坏某些东西。

  • 很多 bug 会在开发人员不知情的情况下进入生产环境,除非他们开始进行手动测试,但这反而会适得其反。

假阴性是指当生产代码存在错误时,本应失败的测试却成功运行。

如果在with-transactional git 分支中运行所有测试,它们都会成功运行:

这里有2个假阴性结果。这里有2个假阴性结果。

接下来,让我们尝试使用 cURL 或 Postman 等工具来执行应用程序并调用端点。

延迟加载不起作用

如果您尝试创建购物车并向其中添加商品,将会收到 500 HTTP 错误。您可以将预生成的商品 ID 为 *2bdb8e93–3832–47b6–8bbc-d6b5db277989* 的产品用于购物车商品:

控制台日志中抛出的异常是LazyInitializationException

org.hibernate.**LazyInitializationException**: failed to lazily initialize a collection of role: com.example.avoidtransactional.domain.model.Cart._items, could not initialize proxy - no Session
Enter fullscreen mode Exit fullscreen mode

当 Hibernate 尝试延迟加载Cart._items列表时抛出了此异常。延迟加载在数据库会话关闭时不起作用,而这种情况通常会在您使用存储库检索实体后立即发生。



val product =  productsRepository.findByIdOrNull(cartItem.productId)
if (product == null) {
    return ResponseEntity.notFound().build()
}

val cart = cartsRepository.findByIdOrNull(cartId)
if (cart == null) {
    return ResponseEntity.notFound().build()
}

cart.addProduct(product, cartItem.quantity)
cartsRepository.save(cart) // with @Transactional there's no need of this line


Enter fullscreen mode Exit fullscreen mode

问题出在第 11 行。Cart.addProduct ()方法内部操作的是购物车商品的惰性集合,这引发了异常。

如果从CartItemsController类中移除 @Transactional 注解,则其中的测试将失败,并出现相同的LazyInitializationException异常。

org.springframework.web.util.**NestedServletException**: Request processing failed; nested exception is org.hibernate.**LazyInitializationException**: failed to lazily initialize a collection of role: com.example.avoidtransactional.domain.model.Cart._items, could not initialize proxy - no Session
Enter fullscreen mode Exit fullscreen mode

你可以将 Git 分支切换到without-transactional,我已从所有测试中移除了 @Transactional 注解。仍然能够成功运行的两个测试之所以通过,是因为它们没有预先获取集合。

移除 @Transactional 后,漏报的情况就会显现出来。移除 @Transactional 后,漏报的情况就会显现出来。

为什么之前所有的测试都没有失败?原来,@Transactional 注解将包含的代码包装在一个事务中——这样可以维护事务中包含的所有代码的数据库会话,这对于成功延迟加载集合是必需的。

在 JPA/Hibernate 中,处理集合的推荐方法是手动使用 eager 模式获取集合,同时保持集合的 lazy 状态,这样可以只获取所需内容,避免NM 性能问题。但为了简洁起见,我在without-transactional-replacement 分支中将获取模式更改为了 EAGER

实体会自动保存

在测试中使用 @Transactional 注解的另一个不易察觉的问题是,它会为被测代码启用自动提交功能。这意味着在 @Transactional 注解的代码中获取的实体会在测试结束时自动保存。

即使我们在第 16 行没有显式调用save(),下面的方法测试仍然应该通过。只需确保你处于with-transactional分支即可。



@RestController
@RequestMapping("carts/{cart_id}/items")
class CartItemsController(
    private val cartsRepository: CartsRepository,
    private val productsRepository: ProductsRepository) {

    @PostMapping
    fun addItemToCart(
        @PathVariable("cart_id") cartId: UUID,
        @RequestBody cartItem: CartItemInputModel
    ) : ResponseEntity<Any> {

        ...

        cart.addProduct(product, cartItem.quantity)
        //cartsRepository.save(cart) // with @Transactional there's no need of this line

        val savedProductItem = cart.items.first { x -> x.product.id == cartItem.productId }
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(mapToCartItemViewModel(savedProductItem))
    }
}


Enter fullscreen mode Exit fullscreen mode

但是,如果您发送请求将商品添加到购物车,则新商品不会保存到数据库中。

数据库难以用于排查测试失败问题

使用 @Transactional 的另一个问题是,您无法使用数据库来调查失败原因,因为数据实际上从未保存到数据库中,因为事务总是在测试方法执行结束时回滚。

因此,为了方便起见,我们可以不在事务中包装我们的测试,以便保存数据,而只在执行测试方法之前清理数据库

建议的解决方案

我们不能只移除 @Transactional 注解而不做任何其他操作,因为我们需要保证所有测试彼此独立(参见 Martin Fowler 关于测试中非确定性的文章)。

如果将 git 分支更改为without-transactional-replacement,您会发现测试正在使用 JUnit 5 扩展进行注释,该扩展会在执行测试方法之前清理所有数据。



@SpringBootTest
@ExtendWith(PostgresDbCleanerExtension::class)
internal class CartsControllerTests {
    @Autowired
    lateinit var cartsRepository: CartsRepository

    @Autowired
    lateinit var webApplicationContext: WebApplicationContext

    ...
}


Enter fullscreen mode Exit fullscreen mode

PostgresDbCleanerExtension扩展了BeforeEachCallback接口,该接口实现了beforeEach方法,该方法在执行每个测试方法之前调用,以清理数据库。



class PostgresDbCleanerExtension : BeforeEachCallback {
    companion object {
        private val LOGGER = LoggerFactory.getLogger(PostgresDbCleanerExtension::class.java)

        private val TABLES_TO_IGNORE = listOf(
            TableData("databasechangelog"),
            TableData("databasechangeloglock")
        )
    }

    @Throws(Exception::class)
    override fun beforeEach(context: ExtensionContext) {
        val dataSource = getDataSourceFromYamlProperties("application.yml")
        cleanDatabase(dataSource)
    }

    ...

    private fun cleanDatabase(dataSource: DataSource) {
        try {
            dataSource.connection.use { connection ->
                connection.autoCommit = false
                val tablesToClean = loadTablesToClean(connection)
                cleanTablesData(tablesToClean, connection)
                connection.commit()
            }
        } catch (e: SQLException) {
            LOGGER.error(String.format("Failed to clean database due to error: \"%s\"", e.message))
            e.printStackTrace()
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

从application.yml加载数据库属性并将其绑定到javax.sql.DataSource之后,我们使用java.sql.DatabaseMetaData类来检索所有数据库表



@Throws(SQLException::class)
private fun loadTablesToClean(connection: Connection): List<TableData> {
    val databaseMetaData = connection.metaData
    val resultSet = databaseMetaData.getTables(
        connection.catalog, null, null, arrayOf("TABLE"))

    val tablesToClean = mutableListOf<TableData>()
    while (resultSet.next()) {
        val table = TableData(
            schema = resultSet.getString("TABLE_SCHEM"),
            name = resultSet.getString("TABLE_NAME")
        )

        if (!TABLES_TO_IGNORE.contains(table)) {
            tablesToClean.add(table)
        }
    }

    return tablesToClean
}


Enter fullscreen mode Exit fullscreen mode

使用 TRUNCATE 查询清除所有表,但TABLES_TO_IGNORE列表中的表除外,这些表与数据库迁移历史记录相关:



@Throws(SQLException::class)
private fun cleanTablesData(tablesNames: List<TableData>, connection: Connection) {
    if (tablesNames.isEmpty()) {
        return
    }
    val stringBuilder = StringBuilder("TRUNCATE ")
    for (i in tablesNames.indices) {
        if (i == 0) {
            stringBuilder.append(tablesNames[i].fullyQualifiedTableName)
        } else {
            stringBuilder
                .append(", ")
                .append(tablesNames[i].fullyQualifiedTableName)
        }
    }
    connection.prepareStatement(stringBuilder.toString())
        .execute()
}


Enter fullscreen mode Exit fullscreen mode

使用 TRUNCATE 命令速度稍慢,您也可以禁用外键约束检查,并使用 DELETE 命令删除所有数据,这样速度更快。

我们采用的 TRUNCATE 方法速度较慢,可能是因为它会立即回收磁盘空间清除所有垃圾数据

解决生产环境中的漏洞

您还会注意到,我们将延迟加载 fetch 替换为立即加载,以便解决与LazyInitializationException相关的生产错误。

既然我们已经去掉了 @Transactional 注解,并且发现了测试套件中的误报,那么让我们修复实际的错误,以便测试能够通过。

我已将有问题的Cart._items属性中的 fetch 类型更改为 FetchType.EAGER 。但您最好还是按照Vlad Mihalcea在这篇文章中描述的方式手动进行 eager fetch 操作。



@Entity
@Table(name = "carts")
class Cart : DomainEntity() {
    @OneToMany(fetch = FetchType.EAGER, cascade = [CascadeType.ALL], orphanRemoval = true)
    private val _items = mutableListOf<ProductItem>()

    ...

}


Enter fullscreen mode Exit fullscreen mode

如果在without-transactional-replacement分支中运行测试,所有测试都应该通过。

所有测试均通过,无假阴性结果。所有测试均通过,无假阴性结果。

结论

本文已经阐述了为什么在测试套件中使用 @Transactional 不是一个好主意。

@Transactional 允许出现一些不希望出现的行为,导致我们的测试套件出现误报,这会影响开发人员进行更改的信心,并使测试套件变得无用。

在测试中使用 @Transactional 也会使故障排除变得困难,因为实际上并没有数据保存到数据库中。

始终建议手动清理数据,而不是依赖事务,并且最好在测试执行之前进行清理,因为您需要通过分析数据库来了解测试失败的原因。

感谢您阅读本文,请点赞送出独角兽,帮助文章触达更多读者。

别忘了在GithubTwitter上关注我!

参考

文章来源:https://dev.to/henrykeys/don-t-use-transactional-in-tests-40eb