• 作者:老汪软件技巧
  • 发表时间:2024-10-06 15:01
  • 浏览量:

作者:来自 ElasticPiotr Przybyl

在本文中,我们将介绍并解释两种使用 Elasticsearch 作为外部系统依赖项来测试软件的方法。我们将介绍使用模拟测试和集成测试的测试,展示它们之间的一些实际差异,并给出一些关于每种风格的提示。

良好的测试可增强系统信心

良好的测试可以增强参与 IT 系统创建和维护过程的每个人的信心。测试不是为了炫酷、快速或人为地增加代码覆盖率。测试在确保以下方面发挥着至关重要的作用:

当然,这并不意味着测试不能炫酷、快速或增加代码覆盖率。我们运行测试套件的速度越快越好。只是,为了减少测试套件的总体持续时间,我们不应该牺牲自动化测试给我们带来的可靠性、可维护性和信心。

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

最后但并非最不重要的一点是:系统的架构。我们喜欢组织有序、易于维护、架构清晰且能满足其目的的系统。但是,有时我们可能会看到一种架构为了所谓的 “这样更易于测试” 的借口而牺牲了太多。易于测试并没有错 —— 只有当系统主要是为了可测试而不是为了满足证明其存在的需求而编写时,我们才会看到本末倒置的情况。

两种测试

有很多种方式可以查看测试,从而对其进行分类。在这篇文章中,我将只关注划分测试的一个方面:使用模拟(或存根/stub、伪造或...)与使用真实依赖项。在我们的例子中,依赖项是 Elasticsearch。

使用模拟的测试非常快,因为它们不需要启动任何外部依赖项,并且一切都只发生在内存中。自动化测试中的模拟是指使用假对象代替真实对象来测试程序的某些部分而不使用实际依赖项。这就是需要它们的原因,也是它们在任何快速检测网络测试中大放异彩的原因,例如输入验证。例如,无需启动数据库并对其进行调用,只需验证请求中的负数是否不允许。

但是,引入模拟有几个含义:

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

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

最佳实践:两者兼用

与其仅使用一种测试方式,不如在合适的场景下同时使用两种测试方式,并尽量优化两者的使用。

SystemUnderTest 的示例

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

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


1.  public class BookSearcher {
3.      private final ElasticsearchClient esClient;
5.      public BookSearcher(ElasticsearchClient esClient) {
6.          this.esClient = esClient;
7.          if (!isCompatibleWithBackend()) {
8.              throw new UnsupportedOperationException("This is not compatible with backend");
9.          }
10.      }
12.      private boolean isCompatibleWithBackend() {
13.          try (ResultSet rs = esClient.esql().query(ResultSetEsqlAdapter.INSTANCE, """
14.              show info
15.              | keep version
16.              | dissect version "%{major}.%{minor}.%{patch}"
17.              | keep major, minor
18.              | limit 1""")) {
19.              if (!rs.next()) {
20.                  throw new RuntimeException("No version found");
21.              }
22.              return rs.getInt(1) == 8 && rs.getInt(2) == 15;
23.          } catch (SQLException | IOException e) {
24.              throw new RuntimeException(e);
25.          }
26.      }
28.      public int numberOfBooksPublishedInYear(int year) {
29.          try (ResultSet rs = esClient.esql().query(ResultSetEsqlAdapter.INSTANCE, """
30.              from books
31.              | where year == ?
32.              | stats published = count(*) by year
33.              | limit 1000""", year)) {
35.              if (rs.next()) {
36.                  return rs.getInt("published");
37.              }
38.          } catch (SQLException | IOException e) {
39.              throw new RuntimeException(e);
40.          }
41.          return 0;
42.      }
45.      public List mostPublishedAuthorsInYears(int minYear, int maxYear) {
46.          assert minYear <= maxYear;
47.          String query = """
48.              from books
49.              | where year >= ? and year <= ?
50.              | stats first_published = min(year), last_published = max(year), times = count (*) by author
51.              | eval years_published = last_published - first_published
52.              | sort years_published desc
53.              | drop years_published
54.              | limit 20
55.              """;
57.          try {
58.              Iterable published = esClient.esql().query(
59.                  ObjectsEsqlAdapter.of(MostPublished.class),
60.                  query,
61.                  minYear,
62.                  maxYear);
64.              List mostPublishedAuthors = new ArrayList<>();
65.              for (MostPublished mostPublished : published) {
66.                  mostPublishedAuthors.add(mostPublished);
67.              }
68.              return mostPublishedAuthors;
69.          } catch (IOException e) {
70.              throw new RuntimeException(e);
71.          }
72.      }
74.      public record MostPublished(
75.          String author,
76.          @JsonProperty("first_published") int firstPublished,
77.          @JsonProperty("last_published") int lastPublished,
78.          int times
79.      ) {
80.          public MostPublished {
81.              assert author != null;
82.              assert firstPublished <= lastPublished;
83.              assert times > 0;
84.          }
85.      }
86.  }

首先使用模拟进行测试

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

我们可以从以下内容开始,在每次测试之前重置模拟:


1.  public class BookSearcherMockingTest {
3.      ResultSet mockResultSet;
4.      ElasticsearchClient esClient;
5.      ElasticsearchEsqlClient esql;
7.      @BeforeEach
8.      void setUpMocks() {
9.          mockResultSet = mock(ResultSet.class);
10.          esClient = mock(ElasticsearchClient.class);
11.          esql = mock(ElasticsearchEsqlClient.class);
13.      }
14.  }

正如我们之前所说,并非所有东西都可以使用模拟轻松测试。但有些东西我们可以(甚至应该)。让我们尝试验证目前唯一支持的 Elasticsearch 版本是 8.15.x(将来,一旦我们确认我们的系统与未来版本兼容,我们可能会扩展范围):


1.  @Test
2.  void canCreateSearcherWithES_8_15() throws SQLException, IOException{
3.      // when
4.      when(esClient.esql()).thenReturn(esql);
5.      when(esql.query(eq(ResultSetEsqlAdapter.INSTANCE), anyString())).thenReturn(mockResultSet);
6.      when(mockResultSet.next()).thenReturn(true).thenReturn(false);
7.      when(mockResultSet.getInt(1)).thenReturn(8);
8.      when(mockResultSet.getInt(2)).thenReturn(15);
10.      // then
11.      Assertions.assertDoesNotThrow(() -> new BookSearcher(esClient));
12.  }

我们可以通过类似的方式(只需返回不同的次要版本)验证,我们的 BookSearcher 还不能与 8.16.x 一起使用,因为我们不确定它是否与它兼容:


1.  @Test
2.  void cannotCreateSearcherWithoutES_8_15() throws SQLException, IOException {
3.      // when
4.      when(esClient.esql()).thenReturn(esql);
5.      when(esql.query(eq(ResultSetEsqlAdapter.INSTANCE), anyString())).thenReturn(mockResultSet);
6.      when(mockResultSet.next()).thenReturn(true).thenReturn(false);
7.      when(mockResultSet.getInt(1)).thenReturn(8);
8.      when(mockResultSet.getInt(2)).thenReturn(16);
10.      // then
11.      Assertions.assertThrows(UnsupportedOperationException.class, () -> new BookSearcher(esClient));
12.  }

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

存根可能看起来像这样:


1.  @Testcontainers
2.  public class BookSearcherIntTest {
4.      static final String ELASTICSEARCH_IMAGE = "docker.elastic.co/elasticsearch/elasticsearch:8.15.0";
5.      static final JacksonJsonpMapper JSONP_MAPPER = new JacksonJsonpMapper();
7.      RestClientTransport transport;
8.      ElasticsearchClient client;
10.      @Container
11.      ElasticsearchContainer elasticsearch = new ElasticsearchContainer(ELASTICSEARCH_IMAGE);
13.      @BeforeEach
14.      void setupClient() {
15.          transport = // setup transport here
16.          client = new ElasticsearchClient(transport);
17.      }
19.      @AfterEach
20.      void closeClient() throws IOException {
21.          if (transport != null) {
22.              transport.close();
23.          }
24.      }
26.  }

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

使用 @Container 注释非静态字段意味着将为每个测试启动一个新容器,因此我们不必担心过时的数据或重置容器的状态。但是,对于许多测试,这种方法可能表现不佳,因此我们将在下一篇文章中将其与其他替代方案进行比较。

注意:

通过依赖 docker.elastic.co(Elastic 的官方 Docker 镜像存储库),你可以避免耗尽 Docker hub 上的限制。

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

在测试中连接到 Elasticsearch

_模拟写代码_java代码模拟ajax请求

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

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

整体结果可能如下所示:


1.  BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
2.  credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("elastic", "changeme"));
4.  // Create a low level rest client
5.  RestClient restClient = RestClient.builder(new HttpHost(elasticsearch.getHost(), elasticsearch.getMappedPort(9200), "https"))
6.      .setHttpClientConfigCallback(httpClientBuilder ->
7.          httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider)
8.              .setSSLContext(elasticsearch.createSslContextFromCa())
9.      )
10.      .build();
12.  // The RestClientTransport is mainly for serialization/deserialization
13.  RestClientTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
15.  // The official Java API Client for Elasticsearch
16.  ElasticsearchClient client = new ElasticsearchClient(transport);

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

注意:

有关在生产环境中创建客户端,请。

第一次集成测试

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


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

如你所见,我们不需要设置任何其他内容。我们不需要模拟 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) 方法。前两行如下:


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

第一条语句检查一个条件,该条件不以任何方式依赖于 Elasticsearch(或任何其他外部依赖项)。因此,我们不需要启动任何容器来验证,如果 minYear 大于 maxYear,则会引发异常。

一个简单的模拟测试,速度快且不占用大量资源,足以确保这一点。设置模拟后,我们可以简单地进行以下操作:


1.  BookSearcher systemUnderTest = new BookSearcher(esClient);
3.  Assertions.assertThrows(
4.      AssertionError.class,
5.      () -> systemUnderTest.mostPublishedAuthorsInYears(2012, 2000)
6.  );

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

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


1.  @BeforeEach
2.  void setupDataInContainer() {
3.      // here we initialise data in the Elasticsearch running in a container
4.  }
6.  @Test
7.  void shouldGiveMostPublishedAuthorsInGivenYears() {
8.      var systemUnderTest = new BookSearcher(client);
9.      var list = systemUnderTest.mostPublishedAuthorsInYears(1800, 2010);
10.      Assertions.assertEquals("Beatrix Potter", list.get(12).author(), "Beatrix Potter was 13th most published author between 1800 and 2010");
11.  }

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

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

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

必要时使用两种方法

mostPublishedAuthorsInYears 方法的案例表明,可以使用两种方法测试单个方法。也许甚至应该这样做。

让我们回顾一下

有人可能会观察到,对版本(在我们的例子中是 8.15.x)如此严格太过分了。仅使用版本标签是可以的,但请注意,在本文中,它代表了版本之间可能发生变化的所有其他功能。

在下一期中,我们将介绍如何使用测试数据集初始化在测试容器中运行的 Elasticsearch。如果你根据本博客构建了任何内容,或者在我们的讨论论坛和社区 Slack 频道上有任何疑问,请告诉我们。

准备好自己尝试一下了吗?开始免费试用。

想要获得 Elastic 认证?了解下一期 Elasticsearch 工程师培训何时举行!

原文:Testing your Java code with mocks and real Elasticsearch — Search Labs