编辑并重新运行 Spring Boot 单元测试,无需重新加载上下文以加快测试速度

Posted

技术标签:

【中文标题】编辑并重新运行 Spring Boot 单元测试,无需重新加载上下文以加快测试速度【英文标题】:Edit and re-run spring boot unit test without reloading context to speed up tests 【发布时间】:2021-08-05 18:10:28 【问题描述】:

我有一个 Spring Boot 应用程序,并使用 postgres 测试容器 (https://www.testcontainers.org/) 和 JUnit 编写了单元测试。测试有 @SpringBootTest 注解,它会在运行测试之前加载上下文并启动一个测试容器。

在我相对较旧的 Macbook 上加载上下文和启动容器大约需要 15 秒,但测试本身非常快(每个小于 100 毫秒)。因此,在包含 100 次测试的完整构建中,这并不重要。这是 15 秒的一次性成本。 但是在 IDE 中单独开发/调试测试变得非常慢。每个测试都会产生 15 秒的启动成本。

我知道 IntelliJ 和 Springboot 在应用程序运行时支持类的热重载。是否有类似的解决方案/建议可以为单元测试做同样的事情?即保持上下文加载和 testcontainer(DB) 运行,但只重新编译修改后的测试类并再次运行选定的测试。

【问题讨论】:

我确实考虑过。有点像有一个类加载器,它会查找更改的类文件,重新加载并运行它。也许这是唯一的出路。 找到了这个视频,展示了如何从 jshell 访问 spring 上下文。那是我可以尝试进行交互式编码的另一种方法。 youtube.com/watch?v=lTrzahYq5ok 看看静态测试容器,这意味着您只有一个实例用于所有测试 【参考方案1】:

我相信有一个简单的解决方案可以解决您的问题。您还没有指定在测试中究竟如何运行测试容器,但是我对以下方法有成功的经验:

对于在本地运行的测试 - 在您的笔记本电脑上启动一次 postgres 服务器(比如在工作日开始时或其他时间)。它可以是 dockerized 进程,甚至可以是常规的 postgresql 安装。

在测试期间,spring boot 应用程序并不真正知道它与测试容器交互 - 它获取主机/端口/凭据,仅此而已 - 它根据这些参数创建一个 DataSource。

因此,对于您的本地开发,您可以修改与测试容器的集成,以便只有在没有“LOCAL.TEST.MODE”环境时才会启动实际的测试容器。定义的变量(基本上你可以选择任何名称 - 它不存在)。

然后,在您的笔记本电脑上定义 ENV 变量(或者您可以为此使用系统属性 - 任何对您更好的方法),然后配置 spring boot 的数据源以获取本地安装的属性(如果定义了该系统属性):

简而言之,它可能是这样的:

@Configuration
@ConditionalOnProperty(name = "test.local.mode", havingValue = "true", matchIfMissing = false)
public class MyDbConfig 
    @Bean
    public DataSource dataSource () 
      // create a data source initialized with local credentials
    


当然,可以实现更“聪明”的配置属性解决方案,这完全取决于您如何与测试容器集成以及数据源初始化的实际属性来自哪里,但想法将保持不变:

在您的本地环境中。您实际上将使用本地安装的 PostgreSQL 服务器,甚至不会启动测试容器 由于 postgresql 中包括 DDL 在内的所有操作都是事务性的,因此您可以在测试中添加 @Transactional 注释,spring 将回滚测试所做的所有更改,以免数据库充满垃圾数据。

相对于测试容器,这种方法有一个显着的优势:

如果您的测试失败并且一些数据保留在数据库中,您可以在本地进行检查,因为服务器将保持活动状态。因此,您将能够使用 PG Admin 或其他工具连接到数据库并检查状态...

更新 1

基于 op 的评论

我明白你的意思,基本上,你提到了两个不同的问题,我将尝试分别提及

问题 1 应用程序上下文大约需要 10-12 秒才能启动。

好的,这需要调查。可能有一些 bean 被缓慢初始化。所以你应该明白为什么应用程序启动如此缓慢:

Spring 的代码(扫描、bean 定义填充等)适用于一秒钟的粒子,通常本身不是瓶颈 - 它必须在某处您的应用程序。

检查 bean 启动时间有点超出了这个问题的范围,尽管肯定有方法可以这样做,例如: see this thread 和较新的弹簧版本,如果您使用执行器 this here。所以我假设你会弄清楚为什么它开始缓慢

不管怎样,你可以用这些信息做什么,以及如何让应用程序上下文加载过程更快? 好吧,显然您可以从配置中排除慢速 bean/bean 集,也许您在测试中根本不需要它,或者至少可以使用 @MockBean 代替 - 这取决于实际用例。 在某些情况下,它还可以提供配置,这些配置仍然会加载该慢速 bean,但会改变其行为以使其不会变慢。

我还可以指出“普遍适用的想法”,无论您的实际代码库如何,都可以提供帮助。

首先,如果您正在运行共享完全相同配置的不同测试用例(IDE 中的多选测试并同时运行它们),那么 Spring Boot 足够智能,不会重新初始化应用程序语境。这称为“在缓存中缓存应用程序上下文”。 Here is one of the numerous tutorials关于这个话题。

另一种方法是使用惰性 bean 初始化。在 spring 2.2+ 中有一个属性

spring:
  main:
    lazy-initialization: true

当然,如果您不打算在生产中使用它,请在您选择的src/test/resource 的配置文件中定义它。只要符合命名约定,spring-boot 也会在测试期间读取它。如果您对此有技术问题。 (再次超出问题的范围),然后考虑阅读this tutorial

如果您的 spring boot 早于 2.2,您可以尝试“手动”执行此操作:here is how

我想提到的最后一个方向是 - 重新考虑您的测试实施。如果您有一个要测试的大项目,这一点尤其重要。通常,应用程序有分层,如服务、DAO-s、控制器,你知道的。我的观点是,涉及 DB 的测试应该只用于 DAO 层——这是您测试 SQL 查询的地方。 业务逻辑代码通常不需要数据库连接,通常可以在完全不使用 spring 的单元测试中覆盖。因此,您可以只运行 DAO 的配置,而不是使用启动整个应用程序上下文的 @SpringBootTest 注释,这可能会启动得更快,并且“慢 bean”属于应用程序的其他部分。 Spring boot 甚至有一个特殊的注解(它们对所有东西都有注解;))@DataJpaTest.

这是基于整个 spring 测试包仅用于集成测试的想法,通常,您开始 spring 的测试是集成测试,您可能更喜欢尽可能使用单元测试,因为它们速度更快,并且不使用外部依赖项:数据库、远程服务等。

第二个问题:架构经常不同步

在我目前的方法中,测试容器启动,liquibase 应用我的架构,然后执行测试。一切都在 IDE 中完成,更方便一些。

我承认我没有使用过 liquibase,我们使用的是 flyway,但我相信答案会是一样的。

简而言之 - 这将继续这样工作,您无需更改任何内容。

我会解释的。

Liquibase 应该与 spring 应用程序上下文一起启动,它应该应用迁移,这是真的。但在实际应用迁移之前,它应该检查迁移是否已经应用,如果数据库是同步的,它什么也不做。为此,Flyway 在数据库中维护了一个表,我确信 liquibase 使用了类似的机制。

因此,只要您不创建表格或进行测试,您就可以开始了:

假设您是第一次启动 Postgres 服务器,您“在工作日开始时”运行的第一个测试,按照上述用例将创建一个模式并部署所有表、索引、等在 liquibase 迁移的帮助下,然后将开始测试。

但是,现在当您开始第二次测试时 - 迁移将已应用。这相当于在非测试场景(暂存、生产等)中重新启动应用程序本身 - 重新启动本身不会真正将所有迁移应用到数据库。这里也是一样...

好的,这是简单的情况,但您可能会在测试中填充数据(嗯,您应该是;))这就是为什么我提到有必要在原始测试中添加 @Transactional 注释回答。

此注释在运行测试中的所有代码之前创建一个事务并人为回滚它 - 读取,删除测试中填充的所有数据,尽管测试已通过

现在让事情变得更复杂,如果您在测试中创建表、更改现有表上的列怎么办?好吧,仅此一项就会使您的 liquibase 即使在生产场景中也很疯狂,因此您可能不应该这样做,但是再次将 @Transactional 放在测试本身上会有所帮助,因为 PostgreSQL 的 DDL(只是为了澄清 DDL = 数据定义语言,所以我的意思是像ALTER TABLE 这样的命令,基本上是任何改变现有模式的命令)命令也是事务性的。我知道例如 Oracle 并没有在事务中运行 DDL 命令,但从那时起事情可能已经发生了变化。

【讨论】:

公平点,我确实尝试过这种方法。但是启动测试容器只需要大约 5 秒。其他 10-12 秒用于 Spring Boot 上下文。像您建议的那样,postgres 服务器的另一个问题是架构经常不同步。在我目前的方法中,测试容器启动,liquibase 应用我的模式,然后执行测试。一切都在 IDE 中完成,这更方便一些。 @user1280213,我已经发布了关于答案的重大更新,试图参考您在评论中提出的问题。请阅读更新,让我知道它是否适合您的情况。 感谢马克的回复。 1. 关于不使用 @SpringBootTest 注释,我同意您的观点。此时,我的代码库相当简单,我只是为其余的 api 控制器编写测试。所以我主要需要测试中的完整上下文。 2. Liquibase - 如果数据库保持活动状态,它不会重复应用模式。但就我目前而言,每次我运行测试时,数据库都会重新启动。因此应用了完整的迁移脚本。这就是我希望避免的。基本上将 Spring 上下文保持在内存中并运行 DB,直到我完成编辑和调试单个测试。 我不确定我是否在关注。如果您像我建议的那样使用独立的数据库服务器在本地工作,那么 liquibase 实际上不会运行任何东西 - 肯定会在第二次测试中检查迁移状态,但架构将是“最新的”。检查应该是对迁移的小表或其他东西的单个查询,所以它会非常快(可能大约 1ms)。为此,您不需要将 Spring Boot 上下文保存在内存中。想想常规的应用程序重启 - 重启后,迁移不会运行,至少它们不应该运行,除非你在设置中有问题...... 是的。 Liquibase 只会检查而不运行迁移。但我发现即使在这种情况下,加载也需要几秒钟(也在较新的笔记本电脑上)。正如我之前发布的那样,我尝试过使用外部数据库,它确实有助于减少运行测试的时间。但我觉得我们可以做得更好。像这样的东西真正有帮助 - youtube.com/watch?v=lTrzahYq5ok(Jshell 中的 Spring boot)。【参考方案2】:

我不认为你可以保持上下文加载。

可以做的是从测试容器中激活可重复使用的容器功能。它可以防止容器在测试运行后被破坏。

您必须确保您的测试是幂等的,或者它们会在完成后删除对容器所做的所有更改。

简而言之,您应该将 .withReuse(true) 添加到容器定义中,并将 testcontainers.reuse.enable=true 添加到 ~/.testcontainers。属性(这是您的 homedir 中的一个文件)

以下是我如何定义我的测试容器以使用 Oracle 测试我的代码。

import org.testcontainers.containers.BindMode;
import org.testcontainers.containers.OracleContainer;

public class StaticOracleContainer 
    public static OracleContainer getContainer() 
        return LazyOracleContainer.ORACLE_CONTAINER;
    

    private static class LazyOracleContainer 
        private static final OracleContainer ORACLE_CONTAINER = makeContainer();

        private static OracleContainer makeContainer() 
            final OracleContainer container = new OracleContainer()
                    // Username which testcontainers is going to use
                    // to find out if container is up and running
                    .withUsername("SYSTEM")
                    // Password which testcontainers is going to use
                    // to find out if container is up and running
                    .withPassword("123")
                    // Tell testcontainers, that those ports should
                    // be mapped to external ports
                    .withExposedPorts(1521, 5500)
                    // Oracle database is not going to start if less
                    // than 1gb of shared memory is available, so this is necessary
                    .withSharedMemorySize(2147483648L)
                    // This the same as giving the container
                    // -v /path/to/init_db.sql:/u01/app/oracle/scripts/startup/init_db.sql
                    // Oracle will execute init_db.sql, after container is started
                    .withClasspathResourceMapping("init_db.sql"
                            , "/u01/app/oracle/scripts/startup/init_db.sql"
                            , BindMode.READ_ONLY)
                     // Do not destroy container
                     .withReuse(true)
;

            container.start();
            return container;
        
    

如您所见,这是一个单例。我需要它来手动控制测试容器的生命周期,以便我可以使用可重复使用的容器 如果你想知道如何使用这个单例将 Oracle 添加到 Spring 测试上下文中,可以查看我的使用 testcontainers 的示例。 https://github.com/poxu/testcontainers-spring-demo

不过,这种方法存在一个问题。 Testcontainers 永远不会停止可重用容器。您必须手动停止并销毁容器。

【讨论】:

【参考方案3】:

我无法想象一些用于测试的热重载魔法标志 - 有太多东西会弄脏 spring 上下文、弄脏数据库等。

在我看来,这里最简单的做法是用手动容器启动本地替换测试容器初始化程序,并将数据库的属性更改为指向该容器。如果您想要为此进行一些自动化 - 您可以在启动脚本之前添加(如果您使用 IntelliJ ...)来执行类似的操作:docker start postgres || docker run postgres (linux),如果容器未运行,它将启动容器,如果不执行任何操作它正在运行。

通常 IDE 重新编译只是更改受影响的类,并且 Spring 上下文可能不会在没有容器启动的情况下启动 15 秒,除非您还有很多 bean 需要配置...

【讨论】:

仅供参考,我发现了一个有趣的网站,您可以将其包含在您的答案中。 testcontainers.org【参考方案4】:

我正在尝试学习使用 Spring Boot 进行测试,如果此答案不相关,请见谅。

我遇到了this video,它建议使用以下组合(按使用最多到最少的顺序):

使用 @Mock 注释的 Mockito 单元测试,尽可能不使用 Spring 上下文 当您想要涉及一些 Spring 上下文时,使用 @WebMvcTest 注释对测试进行切片 当您想要涉及整个 Spring 上下文时,使用 @SpringBootTest 注解进行集成测试

【讨论】:

以上是关于编辑并重新运行 Spring Boot 单元测试,无需重新加载上下文以加快测试速度的主要内容,如果未能解决你的问题,请参考以下文章

Spring Boot 单元测试运行整个程序

Spring Boot 不运行单元测试

Spring Boot maven 多模块项目——单元测试(应用上下文)

Spring Boot DataJpaTest 单元测试恢复到 H2 而不是 mySql

使用 spring boot 运行单元测试,“没有可运行的方法”错误

Spring Boot / JUnit,为多个配置文件运行所有单元测试