1. 概述

如今,Quarkus 让我们能快速构建健壮且结构清晰的应用。但测试呢?它支持得怎么样?

本文将深入探讨 如何对 Quarkus 应用进行有效测试。我们会覆盖 Quarkus 提供的核心测试能力,包括依赖注入与管理、Mock 技术、测试配置剖面(Profile)、Quarkus 特有的注解用法,甚至如何测试原生可执行文件(Native Executable)。

对于有经验的开发者来说,这些内容能帮你避开常见“坑位”,写出更可靠、更高效的测试代码。

2. 环境准备

我们基于之前搭建的 QuarkusIO 项目 进行扩展。

首先,添加以下 Maven 依赖:

关键依赖说明:

  • quarkus-resteasy-jackson:提供 REST 接口支持与 JSON 序列化
  • quarkus-hibernate-orm-panache:简化 JPA 操作
  • quarkus-jdbc-h2 + quarkus-test-h2:嵌入式数据库支持,测试专用
  • quarkus-junit5-mockito:集成 JUnit 5 与 Mockito
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-resteasy-jackson</artifactId>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-hibernate-orm-panache</artifactId>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-jdbc-h2</artifactId>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-junit5-mockito</artifactId>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-test-h2</artifactId>
</dependency>

接下来,定义领域模型:

public class Book extends PanacheEntity {
    private String title;
    private String author;
}

创建 Panache 风格的 Repository,支持模糊查询:

public class BookRepository implements PanacheRepository {

    public Stream<Book> findBy(String query) {
        return find("author like :query or title like :query", with("query", "%"+query+"%")).stream();
    }
}

封装业务逻辑的服务层:

public class LibraryService {

    @Inject
    BookRepository bookRepository;

    public Set<Book> find(String query) {
        if (query == null) {
            return bookRepository.findAll().stream().collect(toSet());
        }
        return bookRepository.findBy(query).collect(toSet());
    }
}

最后,通过 REST 接口暴露服务:

@Path("/library")
public class LibraryResource {

    @Inject
    LibraryService libraryService;

    @GET
    @Path("/book")
    public Set<Book> findBooks(@QueryParam("query") String query) {
        return libraryService.find(query);
    }
}

3. 使用 @Alternative 提供测试专用实现

在写测试前,我们希望数据库里有初始数据。Quarkus 的 CDI 支持通过 @Alternative 注解提供测试专用 Bean。

创建一个 TestBookRepository,继承自 BookRepository,并在初始化时插入测试数据:

@Priority(1)
@Alternative
@ApplicationScoped
public class TestBookRepository extends BookRepository {

    @PostConstruct
    public void init() {
        persist(
            new Book("Dune", "Frank Herbert"),
            new Book("Foundation", "Isaac Asimov")
        );
    }
}

⚠️ 注意:

  • 将此类放在 test 目录下
  • @Priority(1) 确保测试时优先使用该实现
  • @Alternative 标记为可替换实现

这是一种全局 Mock 策略,所有测试都会使用这个预填充数据的 Repository。后面我们会介绍更细粒度的 Mock 方式。

4. HTTP 集成测试

使用 @QuarkusTest 注解可启动完整的 Quarkus 应用上下文,适合测试 REST 接口。

@QuarkusTest
class LibraryResourceIntegrationTest {

    @Test
    void whenGetBooksByTitle_thenBookShouldBeFound() {
        given()
            .contentType(ContentType.JSON)
            .param("query", "Dune")
        .when()
            .get("/library/book")
        .then()
            .statusCode(200)
            .body("size()", is(1))
            .body("title", hasItem("Dune"))
            .body("author", hasItem("Frank Herbert"));
    }
}

@QuarkusTest 会自动启动应用,测试结束后关闭。

4.1 使用 @TestHTTPResource 注入接口地址

避免硬编码 URL,使用 @TestHTTPResource 注入:

@TestHTTPResource("/library/book")
URL libraryEndpoint;

在测试中使用:

@Test
void whenGetBooksByTitle_thenBookShouldBeFound() {
    given()
        .param("query", "Dune")
    .when()
        .get(libraryEndpoint)
    .then()
        .statusCode(200);
}

甚至可以直接读取流:

@Test
void whenGetBooks_thenBooksShouldBeFound() throws IOException {
    assertTrue(
        IOUtils.toString(libraryEndpoint.openStream(), defaultCharset())
               .contains("Asimov")
    );
}

4.2 使用 @TestHTTPEndpoint 简化路径管理

更进一步,使用 @TestHTTPEndpoint 基于资源类自动推导路径:

@TestHTTPEndpoint(LibraryResource.class)
@TestHTTPResource("book")
URL libraryEndpoint;

或者直接在类上标注,让 REST-assured 自动拼接路径:

@QuarkusTest
@TestHTTPEndpoint(LibraryResource.class)
class LibraryHttpEndpointIntegrationTest {

    @Test
    void whenGetBooks_thenShouldReturnSuccessfully() {
        given()
            .contentType(ContentType.JSON)
        .when()
            .get("book") // 自动变成 /library/book
        .then()
            .statusCode(200);
    }
}

✅ 这种方式解耦了测试与具体路径,重构时更安全。

5. 上下文与依赖注入(CDI)

Quarkus 测试本身就是 CDI Bean,因此可以使用 @Inject 注入任何 Bean。

测试 LibraryService

@QuarkusTest
class LibraryServiceIntegrationTest {

    @Inject
    LibraryService libraryService;

    @Test
    void whenFindByAuthor_thenBookShouldBeFound() {
        assertFalse(libraryService.find("Frank Herbert").isEmpty());
    }
}

测试 BookRepository 时需要注意事务:

@QuarkusTransactionalTest
class BookRepositoryIntegrationTest {

    @Inject
    BookRepository bookRepository;

    @Test
    void givenBookInRepository_whenFindByAuthor_thenShouldReturnBookFromRepository() {
        assertTrue(bookRepository.findBy("Herbert").findAny().isPresent());
    }
}

⚠️ 失败原因:缺少事务上下文。解决方案:

创建自定义注解,组合 @QuarkusTest@Transactional

@QuarkusTest
@Stereotype
@Transactional
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface QuarkusTransactionalTest {
}

✅ 这样以后所有需要事务的测试都可以直接使用 @QuarkusTransactionalTest,简洁又统一。

6. Mock 技术

Mock 是测试的核心。Quarkus 提供了多种方式,从全局替换到局部 Mock。

6.1 @Mock 注解

@Mock@Alternative 的简化版,内部组合了 @Alternative@Priority(1),使用更简洁。

6.2 @QuarkusMock:动态注册 Mock

如果不想定义额外类,可以在测试中动态创建 Mock:

@QuarkusTest
class LibraryServiceQuarkusMockUnitTest {

    @Inject
    LibraryService libraryService;

    @BeforeEach
    void setUp() {
        BookRepository mock = Mockito.mock(BookRepository.class);
        when(mock.findBy("Asimov"))
            .thenReturn(Stream.of(
                new Book("Foundation", "Isaac Asimov"),
                new Book("I Robot", "Isaac Asimov")
            ));
        QuarkusMock.installMockForType(mock, BookRepository.class);
    }

    @Test
    void whenFindByAuthor_thenBooksShouldBeFound() {
        assertEquals(2, libraryService.find("Asimov").size());
    }
}

✅ 适合临时 Mock,无需创建额外类。

6.3 @InjectMock:最简洁的字段级 Mock

更推荐使用 @InjectMock,直接在字段上声明 Mock:

@QuarkusTest
class LibraryServiceInjectMockUnitTest {

    @Inject
    LibraryService libraryService;

    @InjectMock
    BookRepository bookRepository;

    @BeforeEach
    void setUp() {
        when(bookRepository.findBy("Frank Herbert"))
            .thenReturn(Stream.of(
                new Book("Dune", "Frank Herbert"),
                new Book("Children of Dune", "Frank Herbert")
            ));
    }

    @Test
    void whenFindByAuthor_thenBooksShouldBeFound() {
        assertEquals(2, libraryService.find("Frank Herbert").size());
    }
}

✅ 写法最简单,语义清晰,推荐日常使用。

6.4 @InjectSpy:仅监控不替换

如果只想验证方法是否被调用,而不替换行为,使用 @InjectSpy

@QuarkusTest
class LibraryResourceInjectSpyIntegrationTest {

    @InjectSpy
    LibraryService libraryService;

    @Test
    void whenGetBooksByAuthor_thenBookShouldBeFound() {
        given()
            .contentType(ContentType.JSON)
            .param("query", "Asimov")
        .when()
            .get("/library/book")
        .then()
            .statusCode(200);

        verify(libraryService).find("Asimov");
    }
}

✅ 适合验证接口调用链,保持原有逻辑执行。

7. 测试配置剖面(Test Profiles)

有时我们需要在不同配置下运行测试,比如切换数据库、修改接口路径等。Quarkus 的 TestProfile 正是为此设计。

实现 QuarkusTestProfile 接口:

public class CustomTestProfile implements QuarkusTestProfile {

    @Override
    public Map<String, String> getConfigOverrides() {
        return Collections.singletonMap("quarkus.resteasy.path", "/custom");
    }

    @Override
    public Set<Class<?>> getEnabledAlternatives() {
        return Collections.singleton(TestBookRepository.class);
    }

    @Override
    public String getConfigProfile() {
        return "custom-profile";
    }
}

application.properties 中添加剖面配置:

%custom-profile.quarkus.datasource.jdbc.url=jdbc:h2:file:./testdb

编写测试:

@QuarkusTest
@TestProfile(CustomTestProfile.class)
class CustomLibraryResourceManualTest {

    public static final String BOOKSTORE_ENDPOINT = "/custom/library/book";

    @Test
    void whenGetBooksGivenNoQuery_thenAllBooksShouldBeReturned() {
        given()
            .contentType(ContentType.JSON)
        .when()
            .get(BOOKSTORE_ENDPOINT)
        .then()
            .statusCode(200)
            .body("size()", is(2))
            .body("title", hasItems("Foundation", "Dune"));
    }
}

⚠️ 注意:

  • 测试前 Quarkus 会重启应用以加载新剖面
  • 代价是启动时间变长,但换来极大的灵活性

8. 测试原生可执行文件

Quarkus 支持将应用编译为原生镜像(Native Image),并提供专用测试支持。

@QuarkusIntegrationTest
@QuarkusTestResource(H2DatabaseTestResource.class)
class NativeLibraryResourceIT extends LibraryHttpEndpointIntegrationTest {
}

执行构建与测试:

mvn verify -Pnative

✅ 关键点说明:

  • @QuarkusIntegrationTest:指示测试运行在原生镜像上
  • @QuarkusTestResource:启动外部服务(如数据库),因为原生镜像不嵌入数据库
  • 测试代码本身不运行在原生环境中,只有被测应用是原生的

你还需要安装 GraalVM 来构建原生镜像。

扩展用法:通过实现 QuarkusTestResourceLifecycleManager 接口,可启动自定义资源,如 TestContainers:

@QuarkusTestResource(OurCustomResourceImpl.class)

9. 总结

Quarkus 提供了极其强大且灵活的测试支持。从基础的依赖注入、Mock,到高级的测试剖面、原生镜像测试,几乎覆盖了所有场景。

✅ 核心优势:

  • 测试即 CDI Bean,无缝集成
  • 多种 Mock 方式,按需选择
  • 支持复杂环境模拟
  • 原生测试能力领先

完整代码已上传至 GitHub,建议结合实践加深理解。


原始标题:Testing Quarkus Applications