在 SPRING BOOT 测试中使用 TESTCONTAINERS 的最佳方式


如果您使用 Testcontainers JUnit 5 扩展将容器与 Spring Boot 测试集成,您最终会遇到两个系统尝试在整个生命周期内管理资源的场景,这并不理想。

@TestConfiguration已经解决这个问题。

以这个应用程序为例:

import java.util.List;
import java.util.UUID;
 
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;
import org.springframework.data.neo4j.repository.Neo4jRepository;
import org.springframework.data.neo4j.repository.config.EnableNeo4jRepositories;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
 
@SpringBootApplication
@EnableNeo4jRepositories(considerNestedRepositories = true)
public class MyApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
 
    @Node
    public record Movie(@Id @GeneratedValue(GeneratedValue.UUIDGenerator.class) String id, String title) {
 
        Movie(String title) {
            this(UUID.randomUUID().toString(), title);
        }
    }
 
    interface MovieRepository extends Neo4jRepository<Movie, String> {
    }
 
    @RestController
    static class MovieController {
 
        private final MovieRepository movieRepository;
 
        public MovieController(MovieRepository movieRepository) {
            this.movieRepository = movieRepository;
        }
 
        @GetMapping("/movies")
        public List<Movie> getMovies() {
            return movieRepository.findAll();
        }
    }
}

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>neo4j</artifactId>
    <scope>test</scope>
</dependency>

我们使用 @TestConfiguration 提供额外的测试配置。

与 @Configuration 相比,@TestConfiguration 有两方面的优势:
与普通的 @Configuration 类不同,它不会阻止 @SpringBootConfiguration 的自动检测。
还有:除非它是测试类的内部静态类,否则必须明确导入。

下面的代码有一个@ServiceConnection的@Bean方法。
该方法返回一个 Neo4jContainer Testcontainer。该容器被标记为可重用。
由于默认情况下我们不会关闭该资源,所以我们让 Testcontainers 来清理它。
当标记为可重复使用时,它将保持活性,这意味着第二次测试运行会更快。

下面定义为上下文提供了足够的信息,因此 Spring 可以调用该容器,将 Neo4j 的所有连接都重新连接到该容器上,这样一切就都能正常运行了。

import java.util.Map;
 
import org.springframework.boot.devtools.restart.RestartScope;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.Neo4jContainer;
 
@TestConfiguration(proxyBeanMethods = false)
public class ContainerConfig {
 
    @Bean
    @ServiceConnection
    @RestartScope
    public Neo4jContainer<?> neo4jContainer() {
        return new Neo4jContainer<>("neo4j:5")
            .withLabels(Map.of("com.testcontainers.desktop.service", "neo4j"))
            .withReuse(true);
    }
}


具体操作如下。请注意如何导入配置,以及没有使用属性。

import static org.assertj.core.api.Assertions.assertThat;
 
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
 
@SpringBootTest
@Import(ContainerConfig.class)
class MyApplicationTests {
 
    @Test
    void repositoryIsConnectedAndUsable(
        @Autowired MyApplication.MovieRepository movieRepository
    ) {
        var movie = movieRepository.save(new MyApplication.Movie("Barbieheimer"));
        assertThat(movie.id()).isNotNull();
    }
}

平心而论,官方文档中还有很多其他方法,但我最喜欢这种方法。它非常明确,而且只使用一套注释(来自 Spring),因此简洁明了。

现在谈谈 @RestartScope。
该注解的存在是有原因的:

  1. 您的类路径上可能有 Springs devtools,它将在必要时重启上下文。
  2. 当上下文重启时,容器也会重启,从而使可重用标记失效。
  3. 注解会保留原始 Bean。

这有什么意义?

新的 Testcontainers 支持与 Quarkus 引入的 "开发人员服务 "概念配合得非常好。

最初,我们只想进行测试驱动开发,但后来发生了一些事情,事情变得越来越仓促,最终,探索性工作变得非常有趣:因此,将您的应用程序与数据库或服务的运行实例结合在一起,感觉就像 "附带电池 "一样,会让您的工作效率非常高。

org.springframework.boot.SpringApplication 有一个新的 with 方法,用于使用附加配置来增强自动配置的应用程序。我们可以像这样在测试范围中的附加主类中使用上述 ContainerConfig:

import org.springframework.boot.SpringApplication;
 
public class MyApplicationWithDevServices {
 
    public static void main(String[] args) {
        SpringApplication.from(MyApplication::main)
            .with(ContainerConfig.class)
            .run(args);
    }
}

启动该应用程序后,向 http://localhost:8080/movies 提出的请求会立即生效,并连接到容器中运行的 Neo4j 实例。

现在是最后的精彩时刻,我在容器上添加的不祥标签怎么样了?
我是 Testcontainers Cloud 的忠实用户,我的机器上运行着他们的服务。这会自动将任何 Testcontainers 容器请求重定向到云上,我的机器上就不需要 Docker 了。
从几天前开始,可以为在云中运行的容器定义固定端口映射,如此处所述 设置固定端口,轻松调试开发服务。

我的机器配置如下:

more /Users/msimons/.config/testcontainers/services/neo4j.toml 
 
# This example selects neo4j instances and forwards port 7687 to 7687 on the client.
# Same for the Neo4j HTTP port
# Instances are found by selecting containers with label "com.testcontainers.desktop.service=neo4j".
 
# ports defines which ports to proxy.
# local-port indicates which port to listen on the client machine. System ports (0 to 1023) are not supported.
# container-port indicates which port to proxy. If unset, container-port will default to local-port.
ports = [
  {local-port = 7687, container-port = 7687},
  {local-port = 7474, container-port = 7474}
]

这样,我就可以在众所周知的 Neo4j 端口下访问由 MyApplicationWithDevServices 启动的 Neo4j 实例,从而实现以下功能:

# Use Cypher-Shell to create some data
cypher-shell -uneo4j -ppassword "CREATE (:Movie {id: randomUuid(), title: 'Dune 2'})"
# 0 rows
# ready to start consuming query after 15 ms, results consumed after another 0 ms
# Added 1 nodes, Set 2 properties, Added 1 labels
 
# Request the data from the application running with dev services
http localhost:8080/movies                                                           
 
# HTTP/1.1 200 
# Connection: keep-alive
# Content-Type: application/json
# Date: Thu, 27 Jul 2023 13:32:58 GMT
# Keep-Alive: timeout=60
# Transfer-Encoding: chunked
#
# [
#    {
#        "id": "824ec97e-0a97-4516-8189-f0bf5eb215fe",
#        "title": "Dune 2"
#    }
# ]