使用Testcontainers、jOOQ和Flyway实现数据库的生产模拟测试


Testcontainers 库帮助我们使用 jOOQ 代码生成器工具从数据库生成 java 代码,我们能够使用我们在生产中使用的相同类型的数据库 PostgreSQL 编写测试,而不是使用模拟或内存数据库.

因为我们总是从数据库的当前状态生成代码,所以我们可以确保我们的代码与数据库的变化同步,我们可以自由地进行任何代码重构,并仍然确保应用程序按预期工作。

在本指南中,您将学习如何

  • 创建一个支持 jOOQ 的 Spring Boot 应用程序
  • 使用 Testcontainers、Flyway 和 Maven 插件生成 jOOQ 代码
  • 使用 jOOQ 实现基本的数据库操作
  • 使用 jOOQ 实现加载复杂对象图的逻辑
  • 使用 Testcontainers 测试 jOOQ 持久层

先决条件

我们将使用jOOQ和 Postgres 创建一个 Spring Boot 项目。我们将使用Flyway数据库迁移来创建我们的数据库表。我们将配置testcontainers-jooq-codegen-maven-plugin以使用 Testcontainers 和 Flyway 迁移脚本生成 jOOQ 代码。

我们将使用 jOOQ 来实现我们的持久层存储库来管理用户、帖子和评论。然后我们将使用 Spring Boot 测试支持和 Testcontainers Postgres 模块测试存储库。

通过选择 Maven 作为构建工具,从Spring Initializr创建一个新的 Spring Boot 项目,并添加启动器 JOOQ Access Layer、Flyway Migration、Spring Boot DevTools、PostgreSQL Driver和Testcontainers。

jOOQ(jOOQ 面向对象查询)是一个流行的开源库,它提供了一个流畅的 API 来构建类型安全的 SQL 查询。

为了利用 jOOQ 提供的 TypeSafe DSL 的优势,我们需要从我们的数据库表、视图和其他对象生成 Java 代码,这将允许我们使用流畅且直观的 API 与数据库进行交互。

在生产级应用程序中,强烈建议使用数据库迁移工具(例如 Flyway或Liquibase)将任何更改应用于数据库。
因此,通过从数据库生成 jOOQ java 代码来构建和测试应用程序的通常过程是:

  • 使用 Testcontainers 创建数据库实例
  • 应用 Flyway 或 Liquibase 数据库迁移
  • 运行 jOOQ 代码生成器以从数据库对象生成 Java 代码。
  • 运行集成测试

jOOQ 代码生成可以作为 Maven 构建过程的一部分使用 testcontainers-jooq-codegen-maven-plugin自动生成。

使用 jOOQ,数据库是第一位的。因此,让我们开始使用 Flyway 迁移脚本创建我们的数据库结构。

创建 Flyway 数据库迁移脚本
在我们的示例应用程序中,我们有用户、帖子和评论表。让我们按照 Flyway 命名约定创建我们的第一个迁移脚本。
创建src/main/resources/db/migration/V1__create_tables.sql

create table users
(
    id         bigserial not null,
    name       varchar   not null,
    email      varchar   not null,
    created_at timestamp,
    updated_at timestamp,
    primary key (id),
    constraint user_email_unique unique (email)
);

create table posts
(
    id         bigserial                    not null,
    title      varchar                      not null,
    content    varchar                      not null,
    created_by bigint references users (id) not null,
    created_at timestamp,
    updated_at timestamp,
    primary key (id)
);

create table comments
(
    id         bigserial                    not null,
    name       varchar                      not null,
    content    varchar                      not null,
    post_id    bigint references posts (id) not null,
    created_at timestamp,
    updated_at timestamp,
    primary key (id)
);

ALTER SEQUENCE users_id_seq RESTART WITH 101;
ALTER SEQUENCE posts_id_seq RESTART WITH 101;
ALTER SEQUENCE comments_id_seq RESTART WITH 101;

请注意,在 SQL 脚本的末尾,我们已将数据库序列值设置为以 101 开头,以便我们可以插入一些示例数据以及用于测试的主键值。

使用 Maven 插件配置 jOOQ 代码生成
pom.xml配置testcontainers-jooq-codegen-maven-plugin如下:

<properties>
    <testcontainers.version>1.18.3</testcontainers.version>
    <testcontainers-jooq-codegen-maven-plugin.version>0.0.2</testcontainers-jooq-codegen-maven-plugin.version>
    <jooq.version>3.18.3</jooq.version>
    <postgresql.version>42.6.0</postgresql.version>
</properties>

<build>
    <plugins>
        <plugin>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-jooq-codegen-maven-plugin</artifactId>
            <version>${testcontainers-jooq-codegen-maven-plugin.version}</version>
            <dependencies>
                <dependency>
                    <groupId>org.testcontainers</groupId>
                    <artifactId>postgresql</artifactId>
                    <version>${testcontainers.version}</version>
                </dependency>
                <dependency>
                    <groupId>org.postgresql</groupId>
                    <artifactId>postgresql</artifactId>
                    <version>${postgresql.version}</version>
                </dependency>
            </dependencies>
            <executions>
                <execution>
                    <id>generate-jooq-sources</id>
                    <goals>
                        <goal>generate</goal>
                    </goals>
                    <phase>generate-sources</phase>
                    <configuration>
                        <database>
                            <type>POSTGRES</type>
                            <containerImage>postgres:15.3-alpine</containerImage>
                        </database>
                        <flyway>
                            <locations>
                                filesystem:src/main/resources/db/migration
                            </locations>
                        </flyway>
                        <jooq>
                            <generator>
                                <database>
                                    <includes>.*</includes>
                                    <excludes>flyway_schema_history</excludes>
                                    <inputSchema>public</inputSchema>
                                </database>
                                <target>
                                    <packageName>com.testcontainers.demo.jooq</packageName>
                                    <directory>target/generated-sources/jooq</directory>
                                </target>
                            </generator>
                        </jooq>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

让我们了解一下插件配置。

  • 由于我们使用的是PostgreSQL数据库,我们已经将postgres JDBC驱动和Testcontainers postgresql库配置为该插件的依赖项。
  • 在<configuration>/<database>部分,我们配置了数据库的类型,POSTGRES,我们想用它来生成代码,并指定了Docker镜像名称,postgres:15.3-alpine,它将被用于创建数据库实例。
  • 在<configuration>/<flyway>部分,我们已经指定了Flyway迁移脚本的路径位置。
  • 我们还配置了生成代码的packageName和目标位置。你可以配置官方jooq-code-generator插件所支持的所有配置选项。

该插件使用Testcontainers来启动PostgreSQL容器的实例,应用Flyway迁移,然后使用jOOQ代码生成工具生成java代码。

有了这个配置,现在如果你运行./mvnw清洁包,那么你可以在target/generated-sources/jooq目录下找到生成的代码。

创建模型类
我们可能想创建自己的模型类来表示我们想为各种使用情况返回的数据结构。想象一下,我们正在建立一个REST API,我们可能想要返回的响应只有我们表中的列值的一个子集。

因此,让我们创建用户、帖子和评论类,如下所示:

package com.testcontainers.demo.domain;

public record User(Long id, String name, String email) {}

package com.testcontainers.demo.domain;

import java.time.LocalDateTime;
import java.util.List;

public record Post(
        Long id,
        String title,
        String content,
        User createdBy,
        List<Comment> comments,
        LocalDateTime createdAt,
        LocalDateTime updatedAt) {}

package com.testcontainers.demo.domain;

import java.time.LocalDateTime;

public record Comment(Long id, String name, String content, LocalDateTime createdAt, LocalDateTime updatedAt) {}


使用jOOQ实现基本的数据库操作
让我们用jOOQ来实现创建新用户和通过电子邮件获取用户的方法,如下所示:

package com.testcontainers.demo.domain;

import static com.testcontainers.demo.jooq.tables.Users.USERS;
import static org.jooq.Records.mapping;

import java.time.LocalDateTime;
import java.util.Optional;
import org.jooq.DSLContext;
import org.springframework.stereotype.Repository;

@Repository
class UserRepository {
    private final DSLContext dsl;

    UserRepository(DSLContext dsl) {
        this.dsl = dsl;
    }

    public User createUser(User user) {
        return this.dsl
                .insertInto(USERS)
                .set(USERS.NAME, user.name())
                .set(USERS.EMAIL, user.email())
                .set(USERS.CREATED_AT, LocalDateTime.now())
                .returningResult(USERS.ID, USERS.NAME, USERS.EMAIL)
                .fetchOne(mapping(User::new));
    }

    public Optional<User> getUserByEmail(String email) {
        return this.dsl
                .select(USERS.ID, USERS.NAME, USERS.EMAIL)
                .from(USERS)
                .where(USERS.EMAIL.equalIgnoreCase(email))
                .fetchOptional(mapping(User::new));
    }
}

你可以看到jOOQ DSL看起来与SQL非常相似,但却是用Java编写的。通过使用jOOQ生成的代码,我们可以使我们的代码与数据库结构保持同步,也可以从类型安全中受益。

例如,where条件where(USERS.EMAIL.equalIgnoreCase(email))期望email值为一个字符串。如果你试图传递任何非字符串的值,如where(USERS.EMAIL.equalIgnoreCase(123)),那么它将给你一个编译器错误,防止你在编译时而不是在运行时犯错。

Spring Boot提供了多种方法,可以根据你测试的 "单元 "的范围来编写测试。如果你只想测试存储库,那么你可以使用@JdbcTest、@DataJpaTest、@JooqTest等测试切片注解,而如果你想通过加载整个应用环境来编写集成测试,那么你可以使用@SpringBootTest注解。

在编写测试之前,让我们创建一个SQL脚本,通过创建src/test/resources/test-data.sql文件来设置测试数据,如下所示:

DELETE FROM comments;
DELETE FROM posts;
DELETE FROM users;

INSERT INTO users(id, name, email) VALUES
(1, 'Siva', 'siva@gmail.com'),
(2, 'Oleg', 'oleg@gmail.com');

INSERT INTO posts(id, title, content, created_by, created_at) VALUES
(1, 'Post 1 Title', 'Post 1 content', 1, CURRENT_TIMESTAMP),
(2, 'Post 2 Title', 'Post 2 content', 2, CURRENT_TIMESTAMP);

INSERT INTO comments(id, name, content, post_id, created_at) VALUES
(1, 'Ron', 'Comment 1', 1, CURRENT_TIMESTAMP),
(2, 'James', 'Comment 2', 1, CURRENT_TIMESTAMP),
(3, 'Robert', 'Comment 3', 2, CURRENT_TIMESTAMP);

使用@JooqTest分片注解编写资源库测试
通过使用@JooqTest,SpringBoot只加载持久层组件,并自动配置jOOQ的DSLContext。

为了测试存储库,我们需要有一个正在运行的Postgres数据库实例。我们将使用Testcontainers特殊的JDBC URL来轻松启动Postgres数据库,并编写如下测试:

package com.testcontainers.demo.domain;

import static org.assertj.core.api.Assertions.assertThat;

import org.jooq.DSLContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jooq.JooqTest;
import org.springframework.test.context.jdbc.Sql;

@JooqTest(
        properties = {"spring.test.database.replace=none", "spring.datasource.url=jdbc:tc:postgresql:15.3-alpine:///db"
        })
@Sql(
"/test-data.sql")
class UserRepositoryJooqTest {

    @Autowired
    DSLContext dsl;

    UserRepository repository;

    @BeforeEach
    void setUp() {
        this.repository = new UserRepository(dsl);
    }

    @Test
    void shouldCreateUserSuccessfully() {
        User user = new User(null,
"John", "john@gmail.com");

        User savedUser = repository.createUser(user);

        assertThat(savedUser.id()).isNotNull();
        assertThat(savedUser.name()).isEqualTo(
"John");
        assertThat(savedUser.email()).isEqualTo(
"john@gmail.com");
    }

    @Test
    void shouldGetUserByEmail() {
        User user = repository.getUserByEmail(
"siva@gmail.com").orElseThrow();

        assertThat(user.id()).isEqualTo(1L);
        assertThat(user.name()).isEqualTo(
"Siva");
        assertThat(user.email()).isEqualTo(
"siva@gmail.com");
    }
}

让我们了解一下这个测试中发生了什么:

  • 我们使用了@JooqTest slice test注解,只加载持久层组件和自动配置DSLContext。
  • 我们使用了Testcontainers的特殊JDBC URL作为spring.datasource.url属性值,这将自动启动一个PostgreSQL容器并配置Spring上下文将其作为数据源。
  • 由于我们添加了flyway-core依赖,Spring Boot将自动执行放在src/main/resources/db/migration目录下的Flyway迁移。
  • 我们已经注入了jOOQ的DSLContext,并使用JUnit的@BeforeEach回调方法实例化了UserRepository。
  • 最后,我们的测试调用了UserRepository方法并验证了预期的返回值。

使用@SpringBootTest编写集成测试
我们可以使用@SpringBootTest注解来编写集成测试,它将加载整个应用环境。虽然你也可以用@SpringBootTest来使用Testcontainers的特殊JDBC url,但让我们看看如何用Spring Boot 3.1.0中引入的ServiceConnection支持来使用Testcontainers。

package com.testcontainers.demo.domain;

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.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.test.context.jdbc.Sql;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@SpringBootTest
@Sql("/test-data.sql")
@Testcontainers
class UserRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
"postgres:15.3-alpine");

    @Autowired
    UserRepository repository;

    @Test
    void shouldCreateUserSuccessfully() {
        User user = new User(null,
"John", "john@gmail.com");

        User savedUser = repository.createUser(user);

        assertThat(savedUser.id()).isNotNull();
        assertThat(savedUser.name()).isEqualTo(
"John");
        assertThat(savedUser.email()).isEqualTo(
"john@gmail.com");
    }

    @Test
    void shouldGetUserByEmail() {
        User user = repository.getUserByEmail(
"siva@gmail.com").orElseThrow();

        assertThat(user.id()).isEqualTo(1L);
        assertThat(user.name()).isEqualTo(
"Siva");
        assertThat(user.email()).isEqualTo(
"siva@gmail.com");
    }
}

让我们了解一下这里发生了什么:

  • 我们使用了@SpringBootTest注解,它加载了整个应用程序的上下文,因为这样我们就可以直接注入UserRepository Bean。
  • 我们使用了Testcontainers JUnit 5扩展注解@Testcontainers和@Container来启动一个PostgreSQL容器,并使用@ServiceConnection来自动配置数据源属性。
  • 我们使用@Sql("/test-data.sql")初始化了测试数据。
  • 这些测试与我们在上一节中使用@JooqTest slice注解编写的测试类似。


使用jOOQ获取复杂的对象树
到目前为止,我们已经看到使用jOOQ来执行非常基本的数据库操作。但当涉及到用复杂的查询、存储过程等来查询数据库时,jOOQ就大放异彩了。

在我们的数据库模型中,我们有从Post到User的Many-To-One关系,从Post到Comment的One-To-Many关系。

让我们看看我们如何使用jOOQ强大的MULTISET功能,通过一个单一的查询,获得一个给定postId的帖子,以及创建的用户和它的评论。

package com.testcontainers.demo.domain;

import static com.testcontainers.demo.jooq.Tables.COMMENTS;
import static com.testcontainers.demo.jooq.tables.Posts.POSTS;
import static org.jooq.Records.mapping;
import static org.jooq.impl.DSL.multiset;
import static org.jooq.impl.DSL.row;
import static org.jooq.impl.DSL.select;

import java.util.Optional;
import org.jooq.DSLContext;
import org.springframework.stereotype.Repository;

@Repository
class PostRepository {
    private final DSLContext dsl;

    PostRepository(DSLContext dsl) {
        this.dsl = dsl;
    }

    public Optional<Post> getPostById(Long id) {
        return this.dsl
                .select(
                        POSTS.ID,
                        POSTS.TITLE,
                        POSTS.CONTENT,
                        row(POSTS.users().ID, POSTS.users().NAME, POSTS.users().EMAIL)
                                .mapping(User::new)
                                .as("createdBy"),
                        multiset(select(
                                                COMMENTS.ID,
                                                COMMENTS.NAME,
                                                COMMENTS.CONTENT,
                                                COMMENTS.CREATED_AT,
                                                COMMENTS.UPDATED_AT)
                                        .from(COMMENTS)
                                        .where(POSTS.ID.eq(COMMENTS.POST_ID)))
                                .as(
"comments")
                                .convertFrom(r -> r.map(mapping(Comment::new))),
                        POSTS.CREATED_AT,
                        POSTS.UPDATED_AT)
                .from(POSTS)
                .where(POSTS.ID.eq(id))
                .fetchOptional(mapping(Post::new));
    }
}

我们使用jOOQ的嵌套记录支持来加载Post-to-User的ManyToOne关联,以及MULTISET功能来加载Post-to-Comments的OneToMany关联。

我们可以为PostRepository写集成测试,如下所示:

package com.testcontainers.demo.domain;

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.test.context.jdbc.Sql;

@SpringBootTest(
        properties = {"spring.test.database.replace=none", "spring.datasource.url=jdbc:tc:postgresql:15.3-alpine:///db"
        })
@Sql(
"/test-data.sql")
class PostRepositoryTest {

    @Autowired
    PostRepository repository;

    @Test
    void shouldGetPostById() {
        Post post = repository.getPostById(1L).orElseThrow();

        assertThat(post.id()).isEqualTo(1L);
        assertThat(post.title()).isEqualTo(
"Post 1 Title");
        assertThat(post.content()).isEqualTo(
"Post 1 content");
        assertThat(post.createdBy().id()).isEqualTo(1L);
        assertThat(post.createdBy().name()).isEqualTo(
"Siva");
        assertThat(post.createdBy().email()).isEqualTo(
"siva@gmail.com");
        assertThat(post.comments()).hasSize(2);
    }
}

运行测试:
./mvnw test

你应该看到所有的测试都是PASS。你还可以注意到,在测试执行完毕后,容器会自动停止并被移除。