使用 Mock 和真实 Elasticsearch 测试 Java 代码

了解如何使用 Mock 和 Testcontainers 为 Elasticsearch 编写自动化测试

在这篇文章中,我们将介绍并解释两种使用 Elasticsearch 作为外部系统依赖项来测试软件的方法。我们将介绍使用 Mock 的测试以及集成测试,展示它们之间的一些实际差异,并提供一些关于每种风格的建议。

良好的测试提升系统信心

良好的测试能够提升参与 IT 系统创建和维护的每个人的信心。测试的目的并非追求酷炫、快速或人为地提高代码覆盖率。测试在确保以下方面发挥着至关重要的作用:

  • 我们想要交付的内容将在生产环境中正常工作。
  • 系统满足需求和契约。
  • 未来不会出现回归问题。
  • 开发人员(以及其他相关团队成员)相信他们创建的内容将能够正常工作。

当然,这并不意味着测试不能追求酷炫、快速或提高代码覆盖率。测试套件运行得越快越好。只是在追求减少测试套件整体持续时间的过程中,我们不应牺牲自动化测试带给我们的可靠性、可维护性和信心。

良好的自动化测试使各个团队成员更有信心

  • 开发人员:他们可以确认他们正在做的事情是否有效(甚至在他们正在处理的代码离开他们的机器之前)。
  • 质量保证团队:他们需要手动测试的内容减少了。
  • 系统操作员和 SRE:更加轻松,因为系统更容易部署和维护。

最后但并非最不重要的是:系统的架构。我们喜欢系统组织良好、易于维护,并且架构简洁且能够满足其目的。但是,有时我们可能会看到一种架构为了“以这种方式更容易测试”的借口而牺牲太多。追求良好的可测试性并没有错——只有当系统主要为了可测试性而编写,而不是为了满足其存在的理由时,我们才会看到这种情况,即“尾巴摇动狗”。

两种类型的测试

测试可以从许多方面进行观察和分类。在这篇文章中,我将重点关注测试划分的一个方面:使用 Mock(或存根、模拟等)与使用真实依赖项。在我们的例子中,依赖项是 Elasticsearch。

使用 Mock 的测试非常快,因为它们不需要启动任何外部依赖项,并且所有操作都只在内存中进行。在自动化测试中,Mock 是指使用伪对象代替真实对象来测试程序的某些部分,而无需使用实际依赖项。这就是它们所需的原因,也是它们在任何快速检测网络测试中(例如输入验证)脱颖而出的原因。例如,无需启动数据库并对其进行调用,只需验证请求中的负数是否不被允许即可。

但是,引入 Mock 会带来一些影响

  • 并非所有内容和每次都可以轻松地进行 Mock,因此 Mock 会对系统的架构产生影响(有时很好,有时不那么好)。
  • 基于 Mock 运行的测试可能很快,但开发此类测试可能需要相当长的时间,因为深度反映其模拟系统的 Mock 通常不会免费提供。了解系统工作原理的人员需要以正确的方式编写 Mock,而这种知识可以来自实践经验、研究文档等等。
  • Mock 需要维护。当您的系统依赖于外部依赖项,并且您需要升级此依赖项时,有人必须确保模拟依赖项的 Mock 也随着所有更改而更新:中断性更改、已记录和未记录的更改(这也会对我们的系统产生影响)。当您想升级依赖项但您的(仅使用 Mock 的)测试套件无法让您确信所有测试用例都保证能够正常工作时,这种情况尤其令人头疼。
  • 需要保持自律,以确保工作重点放在开发和测试系统上,而不是 Mock 上。

由于这些原因,许多人提倡完全相反的方向:永远不要使用 Mock(或存根等),而是仅依赖真实依赖项。这种方法在演示或系统很小且只有少数测试用例产生巨大覆盖率的情况下非常有效。此类测试可以是集成测试(粗略地说:检查系统的一部分与一些真实依赖项)或端到端测试(同时使用所有真实依赖项并检查系统在所有端上的行为,同时播放定义系统可用性和成功的用户工作流)。使用这种方法的一个明显好处是,我们也(通常无意中)验证了我们对依赖项的假设,以及我们如何将它们与我们正在处理的系统集成。

但是,当测试仅使用真实依赖项时,我们需要考虑以下方面

  • 某些测试场景不需要实际依赖项(例如,验证请求的静态不变性)。
  • 此类测试通常不会在开发人员的机器上的整个套件中运行,因为等待反馈需要花费太长时间。
  • 它们需要 CI 机器上的更多资源,并且可能需要更多时间来调整以避免浪费时间和资源。
  • 使用测试数据初始化依赖项可能并非易事。
  • 使用真实依赖项的测试非常适合在进行重大重构、迁移或依赖项升级之前隔离代码。
  • 它们更有可能是不透明的测试,即不详细说明被测系统的内部细节,而是关注其结果。

最佳方案:两者兼用

与其仅使用一种类型的测试来测试您的系统,不如在有意义的地方同时依赖这两种类型的测试,并尝试改进您对这两种测试的使用。

  • 首先运行基于 Mock 的测试,因为它们的速度快得多,并且只有在所有测试都成功后,才在之后运行速度较慢的依赖项测试。
  • 在不需要外部依赖项的场景中选择 Mock:如果 Mock 需要花费太多时间,则应为了 Mock 而大量更改代码;依赖外部依赖项。
  • 使用这两种方法测试一段代码并没有错,只要它有意义。

SystemUnderTest 示例

在接下来的部分中,我们将使用一个可以在 此处找到的示例。这是一个用 Java 21 编写的微型演示应用程序,使用 Maven 作为构建工具,依赖于 Elasticsearch 客户端并使用 Elasticsearch 的最新添加功能,使用 ES|QL(Elastic 的新的过程化查询语言)。如果 Java 不是您的编程语言,您仍然应该能够理解我们将在下面讨论的概念并将它们转换为您的技术栈。只是使用真实的代码示例使某些事情更容易解释。

BookSearcher 帮助我们处理搜索和分析数据,在本例中是书籍(如 之前的一篇文章中所示)。

  • 它需要 Elasticsearch 版本完全为 8.15.x 作为其唯一的依赖项(请参阅 isCompatibleWithBackend()),例如,因为我们不确定我们的代码是否向前兼容,并且我们确定它不向后兼容。在将生产环境中的 Elasticsearch 升级到较新版本之前,我们应首先在测试中对其进行升级,以确保被测系统的行为保持不变。
  • 我们可以使用它来搜索在特定年份出版的书籍数量(请参阅 numberOfBooksPublishedInYear)。
  • 当我们需要分析数据集并找出两个给定年份之间出版量最多的 20 位作者时,我们也可以使用它(请参阅 mostPublishedAuthorsInYears)。
public class BookSearcher {

    private final ElasticsearchClient esClient;

    public BookSearcher(ElasticsearchClient esClient) {
        this.esClient = esClient;
        if (!isCompatibleWithBackend()) {
            throw new UnsupportedOperationException("This is not compatible with backend");
        }
    }

    private boolean isCompatibleWithBackend() {
        try (ResultSet rs = esClient.esql().query(ResultSetEsqlAdapter.INSTANCE, """
            show info
            | keep version
            | dissect version "%{major}.%{minor}.%{patch}"
            | keep major, minor
            | limit 1""")) {
            if (!rs.next()) {
                throw new RuntimeException("No version found");
            }
            return rs.getInt(1) == 8 && rs.getInt(2) == 15;
        } catch (SQLException | IOException e) {
            throw new RuntimeException(e);
        }
    }

    public int numberOfBooksPublishedInYear(int year) {
        try (ResultSet rs = esClient.esql().query(ResultSetEsqlAdapter.INSTANCE, """
            from books
            | where year == ?
            | stats published = count(*) by year
            | limit 1000""", year)) {

            if (rs.next()) {
                return rs.getInt("published");
            }
        } catch (SQLException | IOException e) {
            throw new RuntimeException(e);
        }
        return 0;
    }


    public List<MostPublished> mostPublishedAuthorsInYears(int minYear, int maxYear) {
        assert minYear <= maxYear;
        String query = """
            from books
            | where year >= ? and year <= ?
            | stats first_published = min(year), last_published = max(year), times = count (*) by author
            | eval years_published = last_published - first_published
            | sort years_published desc
            | drop years_published
            | limit 20
            """;

        try {
            Iterable<MostPublished> published = esClient.esql().query(
                ObjectsEsqlAdapter.of(MostPublished.class),
                query,
                minYear,
                maxYear);

            List<MostPublished> mostPublishedAuthors = new ArrayList<>();
            for (MostPublished mostPublished : published) {
                mostPublishedAuthors.add(mostPublished);
            }
            return mostPublishedAuthors;
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public record MostPublished(
        String author,
        @JsonProperty("first_published") int firstPublished,
        @JsonProperty("last_published") int lastPublished,
        int times
    ) {
        public MostPublished {
            assert author != null;
            assert firstPublished <= lastPublished;
            assert times > 0;
        }
    }
}

首先使用 Mock 进行测试

为了创建测试中使用的 Mock,我们将使用 Mockito,这是 Java 生态系统中一个非常流行的 Mock 库。

我们可以从以下开始,在每次测试之前重置 Mock

public class BookSearcherMockingTest {

    ResultSet mockResultSet;
    ElasticsearchClient esClient;
    ElasticsearchEsqlClient esql;

    @BeforeEach
    void setUpMocks() {
        mockResultSet = mock(ResultSet.class);
        esClient = mock(ElasticsearchClient.class);
        esql = mock(ElasticsearchEsqlClient.class);

    }
}

如前所述,并非所有内容都可以轻松地使用 Mock 进行测试。但有些事情我们可以(并且可能甚至应该)这样做。让我们尝试验证一下,目前 Elasticsearch 唯一支持的版本是 8.15.x(将来,一旦我们确认我们的系统与未来版本兼容,我们可能会扩展范围)

@Test
void canCreateSearcherWithES_8_15() throws SQLException, IOException{
    // when
    when(esClient.esql()).thenReturn(esql);
    when(esql.query(eq(ResultSetEsqlAdapter.INSTANCE), anyString())).thenReturn(mockResultSet);
    when(mockResultSet.next()).thenReturn(true).thenReturn(false);
    when(mockResultSet.getInt(1)).thenReturn(8);
    when(mockResultSet.getInt(2)).thenReturn(15);

    // then
    Assertions.assertDoesNotThrow(() -> new BookSearcher(esClient));
}

我们可以类似地验证(只需返回不同的次要版本),我们的 BookSearcher 尚未与 8.16.x 兼容,因为我们不确定它是否将与之兼容

@Test
void cannotCreateSearcherWithoutES_8_15() throws SQLException, IOException {
    // when
    when(esClient.esql()).thenReturn(esql);
    when(esql.query(eq(ResultSetEsqlAdapter.INSTANCE), anyString())).thenReturn(mockResultSet);
    when(mockResultSet.next()).thenReturn(true).thenReturn(false);
    when(mockResultSet.getInt(1)).thenReturn(8);
    when(mockResultSet.getInt(2)).thenReturn(16);

    // then
    Assertions.assertThrows(UnsupportedOperationException.class, () -> new BookSearcher(esClient));
}

现在让我们看看如何在针对真实 Elasticsearch 进行测试时实现类似的功能。为此,我们将使用 Testcontainers 的 Elasticsearch 模块,它只有一个要求:它需要访问 Docker,因为它会为您运行 Docker 容器。从某种角度来看,Testcontainers 仅仅是一种操作 Docker 容器的方式,但您无需在 Docker Desktop(或类似工具)中、在您的 CLI 或脚本中执行此操作,而可以在您熟悉的编程语言中表达您的需求。这使得直接从测试代码中获取镜像、启动容器、在测试后对其进行垃圾回收、来回复制文件、执行命令、检查日志等成为可能。

存根可能如下所示

@Testcontainers
public class BookSearcherIntTest {

    static final String ELASTICSEARCH_IMAGE = "docker.elastic.co/elasticsearch/elasticsearch:8.15.0";
    static final JacksonJsonpMapper JSONP_MAPPER = new JacksonJsonpMapper();

    RestClientTransport transport;
    ElasticsearchClient client;

    @Container
    ElasticsearchContainer elasticsearch = new ElasticsearchContainer(ELASTICSEARCH_IMAGE);

    @BeforeEach
    void setupClient() {
        transport = // setup transport here
        client = new ElasticsearchClient(transport);
    }

    @AfterEach
    void closeClient() throws IOException {
        if (transport != null) {
            transport.close();
        }
    }

}

在本例中,我们依赖于Testcontainers 的 JUnit 集成,使用 @Testcontainers@Container 注解,这意味着我们无需担心在测试之前启动 Elasticsearch 并在之后停止它。我们唯一需要做的是在每个测试之前创建客户端并在每个测试之后关闭它(以避免资源泄漏,这可能会影响更大的测试套件)。

使用 @Container 注解非静态字段意味着,每个测试都会启动一个新的容器,因此我们不必担心陈旧数据或重置容器的状态。但是,对于许多测试,这种方法的性能可能不佳,因此我们将在后续的文章中将其与其他方案进行比较。

注意

通过依赖 docker.elastic.co(Elastic 的官方 Docker 镜像仓库),您可以避免耗尽 Docker Hub 上的配额。

建议在测试和生产环境中使用相同版本的依赖项,以确保最大程度的兼容性。出于此原因,我们还建议精确选择版本,因此 Elasticsearch 镜像没有 latest 标签。

在测试中连接到 Elasticsearch

Elasticsearch Java 客户端 能够连接到在测试容器中运行的 Elasticsearch,即使启用了安全性和 SSL/TLS(8.x 版本的默认设置,因此我们不必在容器声明中指定任何与安全相关的内容)。假设您在生产中使用的 Elasticsearch 也启用了 TLS 和某些安全功能,建议尽可能采用接近生产场景的集成测试设置,因此不要在测试中禁用它们。

如何获取连接所需的数据,假设容器分配给字段或变量 elasticsearch

  • elasticsearch.getHost() 将为您提供容器运行的主机(大多数情况下可能是 "localhost",但请不要硬编码,因为有时根据您的设置,它可能是另一个名称,因此应始终动态获取主机)。
  • elasticsearch.getMappedPort(9200) 将为您提供连接到容器内运行的 Elasticsearch 所需的主机端口(因为每次启动容器时,外部端口都不同,因此这也必须是动态调用)。
  • 除非被覆盖,否则默认用户名和密码分别为 "elastic""changeme"
  • 如果在容器设置期间未指定 SSL/TLS 证书,并且未禁用安全连接(8.x 版本的默认行为),则会生成自签名证书。要信任它(例如,像cURL 可以做的那样),可以使用 elasticsearch.caCertAsBytes() 获取证书(它返回 Optional<byte[]>),或者另一种便捷的方法是使用 createSslContextFromCa() 获取 SSLContext

总体结果可能如下所示

BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("elastic", "changeme"));

// Create a low level rest client
RestClient restClient = RestClient.builder(new HttpHost(elasticsearch.getHost(), elasticsearch.getMappedPort(9200), "https"))
    .setHttpClientConfigCallback(httpClientBuilder ->
        httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider)
            .setSSLContext(elasticsearch.createSslContextFromCa())
    )
    .build();

// The RestClientTransport is mainly for serialization/deserialization
RestClientTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());

// The official Java API Client for Elasticsearch
ElasticsearchClient client = new ElasticsearchClient(transport);

另一个创建 ElasticsearchClient 实例的示例可以在演示项目中找到。

注意:

有关在生产环境中创建客户端的信息,请参阅文档

第一个集成测试

我们的第一个测试验证我们可以使用 Elasticsearch 8.15.x 版本创建 BookSearcher,可能如下所示

@Test
void canCreateClientWithContainerRunning_8_15() {
    Assertions.assertDoesNotThrow(() -> new BookSearcher(client));
}

如您所见,我们不需要设置任何其他内容。我们不需要模拟 Elasticsearch 返回的版本,我们唯一需要做的是为 BookSearcher 提供连接到 Elasticsearch 真实实例的客户端,该实例已由 Testcontainers 为我们启动。

集成测试不太关心内部细节

让我们做一个小的实验:假设我们必须停止使用列索引提取结果集中的数据,但必须依赖列名。因此,在方法 isCompatibleWithBackend 中,而不是

return rs.getInt(1) == 8 && rs.getInt(2) == 15;

我们将拥有

return rs.getInt("major") == 8 && rs.getInt("minor") == 15;

当我们重新运行这两个测试时,我们会注意到,使用真实 Elasticsearch 的集成测试仍然没有任何问题地通过。但是,使用模拟的测试停止工作,因为我们模拟了诸如 rs.getInt(int) 之类的调用,而不是 rs.getInt(String)。要使它们通过,我们现在必须改为模拟它们,或者同时模拟它们,具体取决于我们在测试套件中使用的其他用例。

集成测试可能是杀鸡焉用牛刀

即使不需要外部依赖项,集成测试也能够验证系统的行为。但是,以这种方式使用它们通常会浪费执行时间和资源。让我们看看方法 mostPublishedAuthorsInYears(int minYear, int maxYear)。前两行如下所示

assert minYear <= maxYear;
String query = // here goes the query

第一个语句正在检查一个条件,该条件无论如何都不依赖于 Elasticsearch(或任何其他外部依赖项)。因此,我们不需要启动任何容器来仅仅验证,如果 minYear 大于 maxYear,则会抛出异常。

一个简单的模拟测试,它也很快并且不占用大量资源,足以确保这一点。设置好模拟后,我们可以简单地执行以下操作

BookSearcher systemUnderTest = new BookSearcher(esClient);

Assertions.assertThrows(
    AssertionError.class,
    () -> systemUnderTest.mostPublishedAuthorsInYears(2012, 2000)
);

此测试用例中启动依赖项而不是模拟将是浪费的,因为没有机会对此依赖项进行有意义的调用。

但是,要验证以 String query = ... 开头的行为,查询是否正确编写,是否按预期生成结果:客户端库能够发送正确的请求和响应,没有语法更改,因此使用集成测试更容易,例如

@BeforeEach
void setupDataInContainer() {
    // here we initialise data in the Elasticsearch running in a container
}

@Test
void shouldGiveMostPublishedAuthorsInGivenYears() {
    var systemUnderTest = new BookSearcher(client);
    var list = systemUnderTest.mostPublishedAuthorsInYears(1800, 2010);
    Assertions.assertEquals("Beatrix Potter", list.get(12).author(), "Beatrix Potter was 13th most published author between 1800 and 2010");
}

这样,我们可以放心,当我们将数据馈送到 Elasticsearch(在我们选择迁移到的当前或任何未来版本中)时,我们的查询将准确地提供我们期望的结果:数据格式没有更改,查询仍然有效,并且所有中间件(客户端、驱动程序、安全性等)都将继续工作。我们不必担心保持模拟的最新状态,确保与例如 8.15 兼容所需的唯一更改是更改此内容

static final String ELASTICSEARCH_IMAGE = "docker.elastic.co/elasticsearch/elasticsearch:8.15.0";

如果您决定例如使用旧的 QueryDSL 而不是 ES|QL,也会发生同样的情况:您从查询中接收的结果(无论语言如何)应该仍然相同。

根据需要使用这两种方法

方法 mostPublishedAuthorsInYears 的情况说明了可以使用这两种方法测试单个方法。也许甚至应该这样做。

  • 仅使用模拟意味着我们必须维护模拟,并且在升级系统时没有信心。
  • 仅使用集成测试意味着我们正在浪费大量资源,而根本不需要它们。

让我们回顾一下

  • 可以使用 Elasticsearch 的模拟和集成测试。
  • 使用模拟测试作为快速检测网络,并且只有在它们成功通过后,才开始使用依赖项的测试(例如,使用 ./mvnw test '-Dtest=!TestInt*' && ./mvnw test '-Dtest=TestInt*'FailsafeSurefire 插件)。
  • 在测试系统(“代码行”)的行为时使用模拟,其中与外部依赖项的集成并不重要(甚至可以跳过)。
  • 使用集成测试来验证您对外部系统及其集成的假设。
  • 不要害怕使用这两种方法进行测试——如果它有意义——根据上面的要点。

有人可能会注意到,对版本(在本例中为 8.15.x)如此严格要求有点过分。仅使用版本标签就可以,但请注意,在本篇文章中,它代表了版本之间可能发生的所有其他更改。

系列的下一部分中,我们将探讨使用测试数据初始化在测试容器中运行的 Elasticsearch 的方法。如果您根据本博文构建了任何内容,或者您在我们的Discuss 论坛社区 Slack 频道上有任何问题,请告知我们。

Elasticsearch 拥有众多新功能,可帮助您为您的用例构建最佳搜索解决方案。深入了解我们的示例笔记本以了解更多信息,开始免费云试用,或立即在您的本地计算机上试用 Elastic。

想要获得 Elastic 认证?了解下一场Elasticsearch 工程师培训何时开始!

准备好构建最先进的搜索体验了吗?

构建足够先进的搜索体验并非易事。Elasticsearch 由数据科学家、机器学习运维工程师、软件工程师以及许多其他同样热爱搜索的人员共同驱动。让我们携手合作,构建能够为您带来所需结果的魔法搜索体验。

亲自试用