头图

【注】本文译自: Testing JPA Queries with Spring Boot and @DataJpaTest - Reflectoring

除了单元测试,集成测试在生产高质量的软件中起着至关重要的作用。一种特殊的集成测试处理我们的代码和数据库之间的集成。
通过 @DataJpaTest 注释,Spring Boot 提供了一种便捷的方法来设置一个具有嵌入式数据库的环境,以测试我们的数据库查询。
在本教程中,我们将首先讨论哪些类型的查询值得测试,然后讨论创建用于测试的数据库模式和数据库状态的不同方法。

 代码示例

本文附有 GitHub 上的工作代码示例

依赖

在本教程中,除了通常的 Spring Boot 依赖项之外,我们使用 JUnit Jupiter 作为我们的测试框架,使用 H2 作为内存数据库。

dependencies {
  compile('org.springframework.boot:spring-boot-starter-data-jpa')
  compile('org.springframework.boot:spring-boot-starter-web')
  runtime('com.h2database:h2')
  testCompile('org.springframework.boot:spring-boot-starter-test')
  testCompile('org.junit.jupiter:junit-jupiter-engine:5.2.0')
}

测试什么?

首先要回答我们自己的问题是我们需要测试什么。 让我们考虑一个负责 UserEntity 对象的 Spring Data 存储库:

interface UserRepository extends CrudRepository<UserEntity, Long> {
    // query methods
}

我们有不同的选项来创建查询。让我们详细看看其中的一些,以确定我们是否应该用测试来覆盖它们。

推断查询

第一个选项是创建一个推断查询:

UserEntity findByName(String name);

我们不需要告诉 Spring Data 要做什么,因为它会自动从方法名称的名称推断 SQL 查询。
这个特性的好处是 Spring Data 还会在启动时自动检查查询是否有效。如果我们将方法重命名为 findByFoo() 并且 UserEntity 没有属性 foo ,Spring Data 会向我们抛出一个异常来指出这一点:

org.springframework.data.mapping.PropertyReferenceException:
  No property foo found for type UserEntity!

因此,只要我们至少有一个测试尝试在我们的代码库中启动 Spring 应用程序上下文,我们就不需要为我们的推断查询编写额外的测试。

请注意,对于从 findByNameAndRegistrationDateBeforeAndEmailIsNotNull() 等长方法名称推断出的查询,情况并非如此。这个方法名很难掌握,也很容易出错,所以我们应该测试它是否真的符合我们的预期。

话虽如此,将此类方法重命名为更短、更有意义的名称并添加 @Query 注释以提供自定义 JPQL 查询是一种很好的做法。

使用 @Query 自定义 JPQL 查询

如果查询变得更复杂,提供自定义 JPQL 查询是有意义的:

@Query("select u from UserEntity u where u.name = :name")
UserEntity findByNameCustomQuery(@Param("name") String name);

与推断查询类似,我们可以免费对这些 JPQL 查询进行有效性检查。使用 Hibernate 作为我们的 JPA 提供者,如果发现无效查询,我们将在启动时得到一个 QuerySyntaxException

org.hibernate.hql.internal.ast.QuerySyntaxException:
unexpected token: foo near line 1, column 64 [select u from ...]

但是,自定义查询比通过单个属性查找条目要复杂得多。例如,它们可能包括与其他表的连接或返回复杂的 DTO 而不是实体。
那么,我们应该为自定义查询编写测试吗?令人不满意的答案是,我们必须自己决定查询是否复杂到需要测试。

使用 @Query 的本地查询

另一种方法是使用本地查询

@Query(
  value = "select * from user as u where u.name = :name",
  nativeQuery = true)
UserEntity findByNameNativeQuery(@Param("name") String name);

我们没有指定 JPQL 查询(它是对 SQL 的抽象),而是直接指定一个 SQL 查询。此查询可能使用特定数据库的 SQL 方言。
需要注意的是,Hibernate 和 Spring Data 都不会在启动时验证本地查询。由于查询可能包含特定于数据库的 SQL,因此 Spring Data 或 Hibernate 无法知道要检查什么。
因此,本地查询是集成测试的主要候选者。但是,如果他们真的使用特定数据库的 SQL,那么这些测试可能不适用于嵌入式内存数据库,因此我们必须在后台提供一个真实的数据库(比如,在持续集成管道中按需设置的 docker 容器中)。

@DataJpaTest 简介

为了测试 Spring Data JPA 存储库或任何其他与 JPA 相关的组件,Spring Boot 提供了 @DataJpaTest 注解。我们可以将它添加到单元测试中,它将设置一个 Spring 应用程序上下文:

@ExtendWith(SpringExtension.class)
@DataJpaTest
class UserEntityRepositoryTest {

  @Autowired private DataSource dataSource;
  @Autowired private JdbcTemplate jdbcTemplate;
  @Autowired private EntityManager entityManager;
  @Autowired private UserRepository userRepository;

  @Test
  void injectedComponentsAreNotNull(){
    assertThat(dataSource).isNotNull();
    assertThat(jdbcTemplate).isNotNull();
    assertThat(entityManager).isNotNull();
    assertThat(userRepository).isNotNull();
  }
}
@ExtendWith
本教程中的代码示例使用 @ExtendWith 注解告诉 JUnit 5 启用 Spring 支持。从 Spring Boot 2.1 开始,我们不再需要加载 SpringExtension,因为它作为元注解包含在 Spring Boot 测试注解中,例如 @DataJpaTest、@WebMvcTest 和 @SpringBootTest。本教程中的代码示例使用 @ExtendWith 注解告诉 JUnit 5 启用 Spring 支持。从 Spring Boot 2.1 开始,我们不再需要加载 SpringExtension,因为它作为元注解包含在 Spring Boot 测试注解中,例如 @DataJpaTest@WebMvcTest@SpringBootTest

这样创建的应用程序上下文将不包含我们的 Spring Boot 应用程序所需的整个上下文,而只是它的一个“切片”,其中包含初始化任何 JPA 相关组件(如我们的 Spring Data 存储库)所需的组件。
例如,如果需要,我们可以将 DataSource@JdbcTemplate@EntityManage 注入我们的测试类。此外,我们可以从我们的应用程序中注入任何 Spring Data 存储库。上述所有组件将自动配置为指向嵌入式内存数据库,而不是我们可能在 application.propertiesapplication.yml 文件中配置的“真实”数据库。
请注意,默认情况下,包含所有这些组件(包括内存数据库)的应用程序上下文在所有 @DataJpaTest 注解的测试类中的所有测试方法之间共享。
这就是为什么在默认情况下每个测试方法都在自己的事务中运行的原因,该事务在方法执行后回滚。这样,数据库状态在测试之间保持原始状态,并且测试保持相互独立。

创建数据库模式

在我们可以测试对数据库的任何查询之前,我们需要创建一个 SQL 模式来使用。让我们看看一些不同的方法来做到这一点。

使用 Hibernate ddl-auto

默认情况下,@DataJpaTest 会配置 Hibernate 为我们自动创建数据库模式。对此负责的属性是 spring.jpa.hibernate.ddl-auto,Spring Boot 默认将其设置为 create-drop,这意味着模式在运行测试之前创建并在测试执行后删除。
因此,如果我们对 Hibernate 为我们创建模式感到满意,我们就不必做任何事情。

使用 schema.sql

Spring Boot 支持在应用程序启动时执行自定义 schema.sql 文件。
如果 Spring 在类路径中找到 schema.sql 文件,则将针对数据源执行该文件。 这会覆盖上面讨论的 Hibernate 的 ddl-auto 配置。
我们可以使用属性 spring.datasource.initialization-mode 控制是否应该执行 schema.sql。默认值是嵌入的,这意味着它只会对嵌入的数据库执行(即在我们的测试中)。如果我们将其设置为 always,它将始终执行。
以下日志输出确认文件已被执行:

Executing SQL script from URL [file:.../out/production/resources/schema.sql]

设置 Hibernate 的 ddl-auto 配置以在使用脚本初始化架构时进行验证是有意义的,以便 Hibernate 在启动时检查创建的模式是否与实体类匹配:

@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestPropertySource(properties = {
        "spring.jpa.hibernate.ddl-auto=validate"
})
class SchemaSqlTest {
  ...
}

使用 Flyway

Flyway 是一种数据库迁移工具,允许指定多个 SQL 脚本来创建数据库模式。它会跟踪目标数据库上已经执行了这些脚本中的哪些脚本,以便只执行之前没有执行过的脚本。
要激活 Flyway,我们只需要将依赖项放入我们的 build.gradle 文件中(如果我们使用 Maven,则类似):

compile('org.flywaydb:flyway-core')

如果我们没有专门配置 Hibernate 的 ddl-auto 配置,它会自动退出,因此 Flyway 具有优先权,并且默认情况下会针对我们的内存数据库测试执行它在文件夹 src/main/resources/db/migration 中找到的所有 SQL 脚本。
同样,将 ddl-auto 设置为 validate 是有意义的,让 Hibernate 检查 Flyway 生成的模式是否符合我们的 Hibernate 实体的期望:

@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestPropertySource(properties = {
        "spring.jpa.hibernate.ddl-auto=validate"
})
class FlywayTest {
  ...
}

在测试中使用 Flyway 的价值

如果我们在生产中使用 Flyway,也能在上面描述的那样在 JPA 测试中使用它,那就太好了。只有这样我们才能在测试时知道 flyway 脚本按预期工作。
但是,这仅适用于脚本包含在生产数据库和测试中使用的内存数据库(我们的示例中为 H2 数据库)上都有效的 SQL。如果不是这种情况,我们必须在我们的测试中禁用 Flyway,方法是将 spring.flyway.enabled 属性设置为 false,并将 spring.jpa.hibernate.ddl-auto 属性设置为 create-drop 以让 Hibernate 生成模式。
无论如何,让我们确保将 ddl-auto 属性在生产配置文件中设置为 validate!这是我们抵御 Flyway 脚本错误的最后一道防线!无论如何,让我们确保将 ddl-auto 属性在生产配置文件中设置为 validate!这是我们抵御 Flyway 脚本错误的最后一道防线!

使用 Liquibase

Liquibase 是另一种数据库迁移工具,其工作方式类似于 Flyway,但支持除 SQL 之外的其他输入格式。例如,我们可以提供定义数据库架构的 YAML 或 XML 文件。
我们只需添加依赖项即可激活它:

compile('org.liquibase:liquibase-core')

默认情况下,Liquibase 将自动创建在 src/main/resources/db/changelog/db.changelog-master.yaml 中定义的模式。
同样,设置 ddl-autovalidate 是有意义的:

@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestPropertySource(properties = {
        "spring.jpa.hibernate.ddl-auto=validate"
})
class LiquibaseTest {
  ...
}

在测试中使用 Liquibase 的价值

由于 Liquibase 允许多种输入格式充当 SQL 上的抽象层,因此即使它们的 SQL 方言不同,也可以跨多个数据库使用相同的脚本。这使得在我们的测试和生产中使用相同的 Liquibase 脚本成为可能。
不过,YAML 格式非常敏感,而且我最近在维护大型 YAML 文件集合时遇到了麻烦。这一点,以及尽管我们实际上必须为不同的数据库编辑这些文件的抽象,最终导致转向 Flyway。

填充数据库

现在我们已经为我们的测试创建了一个数据库模式,我们终于可以开始实际的测试了。在数据库查询测试中,我们通常会向数据库添加一些数据,然后验证我们的查询是否返回正确的结果。
同样,有多种方法可以将数据添加到我们的内存数据库中,所以让我们逐一讨论。

使用 data.sql

schema.sql 类似,我们可以使用包含插入语句的 data.sql 文件来填充我们的数据库。上述规则同样适用。

可维护性

data.sql 文件迫使我们将所有 insert 语句放在一个地方。每一个测试都将依赖于这个脚本来设置数据库状态。这个脚本很快就会变得非常大并且难以维护。如果有需要冲突数据库状态的测试怎么办?
因此,应谨慎考虑这种方法。

手动插入实体

为每个测试创建特定数据库状态的最简单方法是在运行被测查询之前在测试中保存一些实体:

@Test
void whenSaved_thenFindsByName() {
  userRepository.save(new UserEntity(
          "Zaphod Beeblebrox",
          "zaphod@galaxy.net"));
  assertThat(userRepository.findByName("Zaphod Beeblebrox")).isNotNull();
}

这对于上面示例中的简单实体来说很容易。但在实际项目中,这些实体的构建和与其他实体的关系通常要复杂得多。此外,如果我们想测试比 findByName 更复杂的查询,很可能我们需要创建比单个实体更多的数据。这很快变得非常令人厌烦。
控制这种复杂性的一种方法是创建工厂方法,可能结合 Objectmother 和 Builder 模式。
在 Java 代码中“手动”对数据库进行编程的方法比其他方法有很大的优势,因为它是重构安全的。代码库中的更改会导致我们的测试代码中出现编译错误。在所有其他方法中,我们必须运行测试才能收到有关重构导致的潜在错误的通知。使用

Spring DBUnit

DBUnit 是一个支持将数据库设置为某种状态的库。Spring DBUnit 将 DBUnit 与 Spring 集成在一起,因此它可以自动与 Spring 的事务等一起工作。
要使用它,我们需要向 Spring DBUnit 和 DBUnit 添加依赖项:

compile('com.github.springtestdbunit:spring-test-dbunit:1.3.0')
compile('org.dbunit:dbunit:2.6.0')

然后,对于每个测试,我们可以创建一个包含所需数据库状态的自定义 XML 文件:

<?xml version="1.0" encoding="UTF-8"?>
<dataset>
    <user
        id="1"
        name="Zaphod Beeblebrox"
        email="zaphod@galaxy.net"
    />
</dataset>

默认情况下,XML 文件(我们将其命名为 createUser.xml)位于测试类旁边的类路径中。
在测试类中,我们需要添加两个 TestExecutionListeners 来启用 DBUnit 支持。要设置某个数据库状态,我们可以在测试方法上使用 @DatabaseSetup

@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestExecutionListeners({
        DependencyInjectionTestExecutionListener.class,
        TransactionDbUnitTestExecutionListener.class
})
class SpringDbUnitTest {

  @Autowired
  private UserRepository userRepository;

  @Test
  @DatabaseSetup("createUser.xml")
  void whenInitializedByDbUnit_thenFindsByName() {
    UserEntity user = userRepository.findByName("Zaphod Beeblebrox");
    assertThat(user).isNotNull();
  }
}

对于更改数据库状态的测试查询,我们甚至可以使用 @ExpectedDatabase 来定义数据库在测试预期处于的状态。
但是请注意,自 2016 年以来,Spring DBUnit 没有再维护

@DatabaseSetup 不起作用?

在我的测试中,我遇到了 @DatabaseSetup 注释被默默忽略的问题。原来有一个 ClassNotFoundException 因为找不到某些 DBUnit 类。不过,这个异常被吞了。
原因是我忘记包含对 DBUnit 的依赖,因为我认为 Spring Test DBUnit 可递进地含它。因此,如果您遇到相同的问题,请检查您是否包含了这两个依赖项。

使用 @Sql

一个非常相似的方法是使用 Spring 的 @Sql 注解。我们没有使用 XML 来描述数据库状态,而是直接使用 SQL:

INSERT INTO USER
            (id,
             NAME,
             email)
VALUES      (1,
             'Zaphod Beeblebrox',
             'zaphod@galaxy.net');

在我们的测试中,我们可以简单地使用 @Sql 注解来引用 SQL 文件来填充数据库:

@ExtendWith(SpringExtension.class)
@DataJpaTest
class SqlTest {

  @Autowired
  private UserRepository userRepository;

  @Test
  @Sql("createUser.sql")
  void whenInitializedByDbUnit_thenFindsByName() {
    UserEntity user = userRepository.findByName("Zaphod Beeblebrox");
    assertThat(user).isNotNull();
  }

}

如果我们需要多个脚本,我们可以使用 @SqlGroup 来组合它们。

结论

为了测试数据库查询,我们需要创建模式并用一些数据填充它的方法。由于测试应该相互独立,因此最好对每个测试分别执行此操作。
对于简单的测试和简单的数据库实体,通过创建和保存 JPA 实体手动创建状态就足够了。对于更复杂的场景,@DatabaseSetup@Sql 提供了一种在 XML 或 SQL 文件中外部化数据库状态的方法。


信码由缰
65 声望8 粉丝

“码”界老兵,分享程序人生。