在本篇文章中,我们将介绍和解释使用 Elasticsearch 作为外部系统依赖关系测试软件的两种方法。我们将介绍使用模拟测试和集成测试的测试方法,展示它们之间的一些实际区别,并就每种风格的测试方法给出一些提示。
对系统信心的良好测试
好的测试能增强参与创建和维护 IT 系统过程的每个人的信心。测试并不是为了炫酷、快速或人为增加代码覆盖率。测试在确保这一点方面发挥着至关重要的作用:
- 我们希望交付的产品能在生产中发挥作用。
- 系统符合要求和合同。
- 未来不会出现倒退。
- 开发人员(以及其他相关团队成员)确信他们所创建的系统能够正常运行。
当然,这并不意味着测试不能很酷、很快或增加代码覆盖率。我们运行测试套件的速度越快越好。只是,为了缩短测试套件的总体持续时间,我们不应该牺牲自动化测试给我们带来的可靠性、可维护性和信心。
良好的自动测试会让团队成员更加自信:
- 开发人员:他们可以确认自己所做的工作是否有效(甚至在代码离开他们的机器之前)。
- 质量保证团队:他们手动测试的工作量减少。
- 系统运营商和自力更生者:由于系统更易于部署和维护,因此更加轻松。
最后但并非最不重要的一点:系统架构。我们喜欢系统井井有条、易于维护、架构简洁并能实现其目的。不过,有时我们可能会看到这样一种架构,它为了所谓的 "",这样更容易测试 "的借口而牺牲了太多" 。具有很强的可测试性并没有错--只有当编写系统的主要目的是为了可测试,而不是为了满足证明其存在合理性的需求时,我们才会看到 "狗尾续貂 "的情况。
两种测试模拟& 依赖关系
可以从很多方面来看待测试,从而对其进行分类。在这篇文章中,我将只关注划分测试的一个方面:使用模拟(或存根、伪造或......)与使用真实依赖关系。在我们的案例中,依赖关系是 Elasticsearch。
使用 mock 的测试速度非常快,因为它们不需要启动任何外部依赖关系,而且一切都只在内存中进行。自动测试中的 "模拟 "是指使用假对象代替真对象,在不使用实际依赖关系的情况下测试程序的某些部分。这就是需要它们的原因,也是它们在任何快速检测网测试中大放异彩的原因,例如输入验证。例如,无需启动数据库并调用它来验证请求中的负数是否不允许。
然而,引入模拟器会产生一些影响:
- 并不是每件事、每段时间都能轻松模拟,因此模拟会对系统架构产生影响(有时影响很大,有时影响不大)。
- 在模拟系统上运行的测试可能会很快,但开发此类测试可能需要相当长的时间,因为模拟系统通常不是免费提供的,而是要深入反映它们所模拟的系统。了解系统工作原理的人需要以正确的方式编写模拟,而这种知识可以来自实践经验、研究文档等。
- 需要对模拟进行维护。当你的系统依赖于一个外部依赖关系时,如果你需要升级这个依赖关系,就必须有人确保模仿这个依赖关系的 mock 也会随着所有变化而更新:破坏性的、有文档记录的和无文档记录的(这也会对我们的系统产生影响)。当你想升级一个依赖项,但你的测试套件(仅使用模拟)却无法让你确信所有的测试用例都能正常工作时,这种情况就会变得特别痛苦。
- 要确保将精力用于开发和测试系统,而不是模拟,这需要纪律。
因此,很多人主张反其道而行之:永远不要使用 mock(或存根等),而要完全依赖真实的依赖关系。这种方法在演示或系统很小、只有少量测试用例、覆盖范围很大的情况下非常有效。这些测试可以是集成测试(粗略地说:根据一些真实的依赖关系来检查系统的一部分),也可以是端到端测试(同时使用所有真实的依赖关系,并在所有端点检查系统的行为,同时播放用户工作流,以确定系统的可用性和成功性)。使用这种方法的一个明显好处是,我们还(经常无意中)验证了我们对依赖关系的假设,以及我们如何将它们与我们正在开发的系统进行整合。
不过,当测试只使用真实依赖关系时,我们需要考虑以下几个方面:
- 有些测试场景并不需要实际的依赖关系(如验证请求的静态不变性)。
- 此类测试通常不会在开发人员的机器上整套运行,因为等待反馈会耗费太多时间。
- 它们需要更多的 CI 机器资源,而且可能需要更多的时间来调整,以避免浪费时间& 。
- 使用测试数据初始化依赖关系可能并非易事。
- 具有真实依赖关系的测试非常适合在重大重构、迁移或依赖关系升级之前对代码进行封锁。
- 它们更可能是不透明的测试,即不详细说明被测系统的内部结构,只关心测试结果。
甜蜜点:同时使用两种测试
与其只用一种测试对系统进行测试,不如在合理的情况下同时使用两种测试,并尝试改进两种测试的使用。
- 首先运行基于模拟的测试,因为它们速度更快,只有当所有测试都成功后,才运行速度较慢的依赖性测试。
- 在不太需要外部依赖的情况下选择模拟:如果模拟会耗费太多时间,就应该对代码进行大规模修改,而不是依赖外部依赖。
- 只要合理,使用这两种方法测试一段代码是没有问题的。
SystemUnderTest 示例
在接下来的章节中,我们将使用一个示例,该示例可在此处找到。这是一个用 Java 21 编写的小型演示应用程序,使用 Maven 作为构建工具,依赖于 Elasticsearch 客户端,并使用 Elasticsearch 最新添加的ES|QL(Elastic 的新程序化查询语言)。如果 Java 不是你的编程语言,你仍然可以理解我们下面要讨论的概念,并将它们转换到你的堆栈中。只是使用真实的代码示例会让某些事情更容易解释。
BookSearcher 可以帮助我们处理搜索和分析数据,在我们的案例中就是书籍(如之前的一篇文章所示)。
- 例如,我们不确定我们的代码是否向前兼容,也不确定它是否向后兼容,因此我们需要 Elasticsearch 的版本
8.15.x作为唯一的依赖关系(参见isCompatibleWithBackend())。在将生产中的 Elasticsearch 升级到更新的版本之前,我们应首先在测试中对其进行升级,以确保被测系统的行为保持不变。 - 我们可以用它来搜索某一年出版的图书数量(见
numberOfBooksPublishedInYear)。 - 当我们需要分析我们的数据集并找出两个给定年份之间发表论文最多的 20 位作者时,我们也可以使用它(见
mostPublishedAuthorsInYears)。
从模拟测试开始
为了创建测试中使用的模拟,我们将使用 Java 生态系统中非常流行的模拟库Mockito。
我们可以从以下方面入手,在每次测试前重置模拟:
正如我们之前所说,并不是所有东西都能轻松使用模拟测试。但有些事情我们可以做(甚至应该做)。让我们尝试验证一下,目前 Elasticsearch 唯一支持的版本是8.15.x (将来,一旦我们确认我们的系统与未来版本兼容,我们可能会扩大范围):
我们可以通过类似的方式(只需返回一个不同的次版本)验证BookSearcher 是否能与8.16.x 兼容,因为我们还不确定它是否能与 兼容:
现在,让我们看看如何在针对实际 Elasticsearch 进行测试时实现类似的功能。为此,我们将使用Testcontainers 的 Elasticsearch 模块,它只有一个要求:需要访问 Docker,因为它会为你运行 Docker 容器。从某种角度看,Testcontainers 只是操作 Docker 容器的一种方式,但您可以用自己熟悉的编程语言来表达自己的需求,而不是在 Docker 桌面(或类似工具)、CLI 或脚本中进行操作。这样就可以直接从测试代码中获取镜像、启动容器、在测试后收集垃圾、来回复制文件、执行命令、检查日志等。
存根可能是这样的
在本例中,我们依靠 Testcontainers 的 JUnit 与@Testcontainers 和 集成@Container ,这意味着我们无需担心在测试前启动 Elasticsearch 和测试后停止 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。
总体结果可能是这样的
另一个创建ElasticsearchClient 实例的示例可在演示项目中找到。
请注意:
如需在生产环境中创建客户端,请参阅文档。
首次集成测试
我们的第一个测试是验证是否可以使用 Elasticsearch 版本 8.15.x 创建BookSearcher ,测试结果可能如下:
正如你所看到的,我们不需要设置其他任何东西。我们不需要模拟 Elasticsearch 返回的版本,唯一需要做的就是为BookSearcher 提供一个连接到 Elasticsearch 真实实例的客户端,该实例已由 Testcontainers 为我们启动。
集成测试更少关注内部结构
让我们做个小实验:假设我们必须停止使用列索引从结果集中提取数据,而必须依赖列名。因此,在isCompatibleWithBackend 方法中
我们将有
当我们重新运行这两项测试时,我们会发现与真正 Elasticsearch 的集成测试仍然顺利通过。然而,使用模拟的测试停止工作了,因为我们模拟的调用是rs.getInt(int) ,而不是rs.getInt(String) 。为了让它们通过,我们现在必须根据测试套件中的其他用例,要么模拟它们,要么同时模拟它们。
集成测试可以成为杀死苍蝇的大炮
即使不需要外部依赖,集成测试也能验证系统的行为。然而,这样使用它们通常会浪费执行时间和资源。让我们来看看mostPublishedAuthorsInYears(int minYear, int maxYear) 方法。前两行如下
第一条语句是检查一个条件,它不以任何方式依赖于 Elasticsearch(或任何其他外部依赖)。因此,我们不需要启动任何容器,只需验证,如果minYear 大于maxYear ,就会抛出异常。
一个简单的模拟测试就足以确保这一点,而且测试速度快、不占用资源。设置好模拟后,我们就可以开始了:
在此测试用例中,启动依赖关系而不是模拟是一种浪费,因为根本不可能对该依赖关系进行有意义的调用。
但是,要验证从String query = ... 开始的行为,即查询是正确编写的,结果与预期一致:客户端库能够发送正确的请求和响应,语法没有变化,因此使用集成测试更容易,例如
这样,我们就可以放心,当我们将数据输入到 Elasticsearch 时(不管是现在的版本还是我们选择迁移到的未来版本),我们的查询将完全符合我们的预期:数据格式没有改变,查询仍然有效,所有中间件(客户端、驱动程序、安全性等)都将继续工作。我们不必担心模拟的更新问题,唯一需要做的改动就是确保与诸如................8.15 将改变这一点:
如果您决定例如使用传统的 QueryDSL 而不是 ES|QL:从查询中获得的结果(无论使用哪种语言)应该是一样的。
必要时使用两种方法
mostPublishedAuthorsInYears 的情况说明,一种方法可以同时使用两种方法进行测试。也许还应该是这样。
- 只使用模拟系统意味着我们必须维护模拟系统,并且在升级系统时没有信心。
- 只使用集成测试意味着我们浪费了大量资源,却根本不需要这些资源。
让我们回顾一下
- 可以在 Elasticsearch 中同时使用模拟测试和集成测试。
- 使用模拟测试作为快速检测网,只有当测试成功通过后,才启动带有依赖关系的测试(如使用
./mvnw test '-Dtest=!TestInt*' && ./mvnw test '-Dtest=TestInt*'或Failsafe和Surefire插件)。 - 在测试系统行为("行代码" )时使用 mock,与外部依赖关系的整合并不重要(甚至可以省略)。
- 使用集成测试来验证对外部系统的假设以及与外部系统的集成。
- 不要害怕同时使用两种方法进行测试,如果这样做有意义的话。
有人可能会说,对版本(在我们的情况下是8.15.x )如此严格的要求太过分了。仅使用版本标签也可以,但请注意,在本帖中,它代表了不同版本之间可能发生变化的所有其他功能。
在本系列的下一篇中,我们将介绍如何使用测试数据集初始化在测试容器中运行的 Elasticsearch。如果您根据本博客创建了任何内容,或有任何问题,请通过我们的讨论论坛和社区 Slack 频道告诉我们。

