集成测试的详细指南

391 阅读12分钟

测试是一个必不可少的东西。如果你的软件项目缺乏自动化测试,你就无法确定它是否能运行。有几十个很棒的CI/CD工具将测试的观点提升到一个新的水平(例如Semaphore)。

今天的测试不仅仅是IDE中的绿色和红色图标。有意义的图表、描述性的日志、覆盖率的检查,甚至个别的运行结果,都可以让现代的开发者使用。今天,测试是高效软件开发的一个完整过程。

当开发人员谈论测试时,他们通常是指 单元测试.但是集成测试呢?有些人说它们 "太复杂",并试图避免它们。这是一种不好的做法。

工具、库和CI/CD环境可以使集成测试像单元测试一样简单明了!你问,这怎么可能呢?让我们来了解一下。

单元测试与集成测试

什么是单元测试

单元测试的目的是为了验证单个组件的行为。顺便说一下,关于 "单元 "这个词的定义有一点模糊不清的地方。它是一个类吗?一个函数?或者是整个包?

这取决于你的偏好。首先,让我们定义一下测试驱动开发范式。TDD是一种在业务代码之前编写测试的软件工程方法。你可以在这里阅读更多关于TDD的信息。TDD有两个流派。 底特律学派(或经典派)和伦敦学派(或模仿派)。

底特律学派提倡由内向外的设计,这是一个规则。因此,当我们开始在一个项目上工作时,领域模型是第一位的。API层是最后要开发的东西。下图显示了底特律TDD的步骤。

单元测试驱动架构流程。你可以把它比作一个洋葱,每个单元测试都覆盖了一个新的应用层。验收测试是最后一步,以验证整个软件产品的功能是否符合预期。

关于TDD的底特律学派,还有一个重要的细节。单元测试可以与许多组件(即类)互动。当你意识到你需要一个新的类时,你就在你的测试套件中把它实例化。在底特律的案例中,"单元 "描述了测试的隔离性,但没有描述你必须一次验证一个类的事实。

伦敦TDD学派将这种范式颠倒过来(即由外向内)。它宣称,应用程序开发应该从API开始。而领域模型则是在最后一刻才开发。请看下面的图表。

在伦敦学校的案例中,验收测试引领开发。单元测试是完全相互隔离的。单元测试一次只需要验证一个组件,任何类的依赖关系都应该被模拟

关于单元测试的原则,有两种相反的意见。那么我们该如何定义它呢?我想用这种方式来说明这个定义。

单元测试是一种自动化测试,具有以下特点。

  1. 单元测试行为不依赖于运行中的应用程序之外的任何状态。
  2. 单元测试可以在不影响对方的情况下并行运行。

我们测试组件本身,但不测试它们与外部系统的交互。为了测试这些相互作用,我们需要进行集成测试。

什么是集成测试

集成测试是一种自动化测试,具有以下特点:

  1. 集成测试验证了组件与外部系统(数据库、消息队列等)的交互。
  2. 集成测试在平行运行时可能会相互影响。

集成测试的目标是验证一个应用程序与外部依赖的交互。不是一个存根或模拟,而是实际的实例。

此外,集成测试不需要一次性启动整个应用程序。该图将数据库、消息队列和邮件服务绑定区分为三个独立的集成测试。端到端(E2E)测试的功能是验证整个系统的正确性。

你可以把E2E测试当作集成测试的一个超集。

集成测试的好处

那些谴责集成测试过于复杂和不必要的开发者可能会问:为什么集成测试如此重要?你确定我们不能摆脱它们而专注于单元测试吗?

虽然集成测试确实带来了一些障碍(我们将在后面讨论)。事实是,有时单元测试是不够的。

假设我们正在开发一个在线书店。当用户打开一个书卡时,还应该包括它的平均评分。我们还想跟踪每一个查看图书信息的请求,这样分析师就可以有更多的数据作为商业决策的依据。

下面是一个可能的使用Spring Boot框架的Java实现。

public interface BookRepository extends JpaRepository<Book, Long> {

  @Query("""
      SELECT b.id as id, b.name as name, AVG(r.value) as avgRating
          FROM Book b
      LEFT JOIN b.reviews r
      WHERE b.id = :id;""")
  Optional<BookCard> findBookWithAverageRating(@Param("id") long bookId);

}

@Service
public class BookService {

  private final BookRepository bookRepository;
  private final AuditService auditService;

  public BookCard getBookById(long bookId) {
    final var book =
        bookRepository.findBookWithAverageRating(bookId)
            .orElseThrow();
    auditService.bookRequested(book);
    return book;
  }

}

我们可以在这里为BookService类写一个单元测试,但这足以确保代码的正确性吗?答案是否定的。因为这段代码是不正确的。有一个小细节,很容易被忽略。请看一下这个查询行。

WHERE b.id = :id;

这个分号;会在运行时引起一个异常。所以,即使你只在单元测试中拥有100%的代码覆盖率,你仍然可能错过这样的事情。

也许这个例子还不够有说服力。Spring开发者(尤其是Spring Data的开发者)往往会注意到这样的细节。让我们来讨论一些更复杂的东西。

假设我们需要通过ID来检索一个有角色的用户,让我们写一个可能的查询。

@Transactional(readOnly = true)
public class UserRepository {

  @PersistenceContext
  private EntityManager em;

  public Optional<UserView> findByIdWithRoles(Long userId) {
    List<Tuple> tuples = em.createQuery("""
            SELECT u.id, u.name, r.name FROM User u
            JOIN u.userRoles ur
            JOIN ur.role r
            WHERE u.id = :id""", Tuple.class)
        .setParameter("id", userId)
        .getResultList();
    // transform to dto
    return userView;
  }

}

该查询不会产生运行时异常,但其行为并不总是正确的。你看,我们把JOININNER JOIN 的别名)而不是LEFT JOIN 。这意味着我们不会找到没有角色的用户。

你可以手动测试上述的问题。只要在本地启动应用程序,用Postman发送HTTP请求,对吗?那么,我认为这种方法是不正确的。

测试的目的是对业务功能进行自动验证,以提高交付管道的效率。如果你知道你的代码在合并到主分支之前已经被完全测试过了,那么部署新版本就容易多了。但是,如果产品只经过了部分验证,那么这样的改变就有更大的可能会让生产完全脱轨。

对于这个问题,甚至有一个专门的术语:恐惧驱动的开发。你是否曾经害怕重构你的代码?你是否曾经因为合并了前一天没有真正验证过的东西而睡不好觉?不要责怪自己,因为你并不孤单。缺少集成测试会导致这种现象。

我的观点是,单元测试对于验证业务逻辑是完美的,因为它们与实现细节解耦。然而,在现实中,软件应用程序与外部系统交互,我们必须检查这些交互来测试我们的产品。集成测试是解决这个问题的完美方法。

集成测试的陷阱

我们已经详细地谈到了集成测试的重要性,但没有谈到与实施集成测试有关的问题。

实施集成测试从一开始就会很棘手。我们该如何开始呢?最初的步骤是环境安装,这比想象的要难:

  1. 应用程序可能会与大量的外部依赖关系互动(例如PostgreSQLKafka、MongoDB等)。
  2. 我们还必须使设置在任何机器上都是可重复的,因为大多数项目是由一群开发人员创建的。
  3. 维护问题依然存在。例如,如果有一天我们的产品开始依赖另一个外部服务,我们必须相应地更新环境。
  4. 最后,这整个领域必须在持续集成过程中运行。

已知的模式

纵观软件开发的历史,程序员提出了几种模式来处理集成测试问题。

手动环境配置

这个想法很简单。如果你的应用程序依赖于X,在你的计算机上安装X。每次你运行集成测试时,根据配置文件输入属性。

似乎是一种自然的方法,对吗?毕竟这就是我们启动应用程序的方法。所以,测试部分应该没有什么不同,对吗?算是吧。有一些问题:

  1. 开发人员完全负责维护环境。如果有人升级了数据库版本,团队中的每个人都应该重复。否则,测试的角度就不可靠了。
  2. 这给配置增加了额外的困难。开放端口管理是最小的问题。
  3. 这种技术不适用于CI。

你可能不同意最后一点。例如,如果应用程序需要PostgreSQL,我们可以在远程服务器上运行一个实例。然后任何人都可以在拉动请求构建期间连接到它。

假设有两个不同项目的两个构建同时运行。他们可能需要完全不同的表结构。如果他们都连接到同一个数据库,都不会成功。

另外,你可以尝试在每次CI构建中动态地创建和销毁数据库,但这将是相当费力的。有一些更好的方法,我们将在后面研究。

Vagrant

Vagrant使手动方法自动化。你需要在Vagrant文件中声明你的依赖关系,并执行vagrant up命令。然后,该工具会运行一堆虚拟机。每一个都代表一个特定的外部依赖。

有什么好处?

  1. 该工具遵循基础设施即代码(IaC)方法。整个系统配置是一个简单的文本文件。开发人员可以通过拉动请求来改变它。
  2. 你不必担心版本问题或任何基础设施的替代问题。Vagrant会接收所有的变化。
  3. 不需要手动管理。忘记开放端口问题或复杂的配置障碍。

不幸的是,有一些无法解决的问题。

  1. 你必须拥有一台强大的机器来使用Vagrant,因为虚拟机需要比实体服务启动多得多的资源。
  2. 初始化不会足够快。
  3. CI整合很棘手(有时是不可能的)。技术上来说,Vagrant不是一个测试工具,而是一个开发工具。它的主要目的是为本地开发过程准备环境。即使你可以用它来测试,它也不能解决主要问题。

Docker-Compose

Docker在软件开发方面进行了一场革命。说实话,它并没有发明什么新东西。Docker使用了已经存在了很长时间的Linux命名空间和CG组。此外,这些解决方案已经存在(例如LXC容器),但Docker使容器的使用变得透明和用户友好。最简单的情况是只需要一条docker运行命令。

Docker-Compose是下一个进化的步骤。它允许几个容器按需运行。人们应该创建一个docker-compose.yml文件,以声明的方式定义所有需要的服务。

听起来像是一个解决集成测试问题的绝佳机会。让我们来看看Docker-Compose提供了什么:

  1. Docker-Compose像Vagrant一样遵循IaC方法。
  2. 由于Docker是一个跨平台的工具,环境在任何地方都是可重复的。
  3. 零配置。你只需要安装Docker,然后执行docker-compose up命令。

那么,Docker-Compose是关键吗?嗯,差不多。CI整合是可能的,但相当棘手。

问题在于Docker-Compose本身的性质。你看,Docker容器在默认情况下是无法从主机操作系统中访问的。你必须指定接受数据包的端口,并将它们传输到容器中。毫不奇怪,这些端口不应该被任何其他程序使用。由于docker-compose.yml 是一个普通的文本文件,所以有必要静态地定义端口。例如,这里有一个运行MySQL数据库的可能方式。

version: '3.3'
services:
  db:
    image: mysql:5.7
    environment:
      MYSQL_DATABASE: 'db'
      MYSQL_USER: 'user'
      MYSQL_PASSWORD: 'password'
      MYSQL_ROOT_PASSWORD: 'password'
    ports:
      - '5555:3306'
    expose:
      - '3306'

端口5555接受连接。好的,到目前为止看起来不错。我们怎么知道这个端口在CI节点上是开放的?嗯,有一个黑客可以克服这个限制:

  1. 用一个占位符(例如:$MYSQL_PORT )来代替实际的端口号。
  2. 运行一个特殊的脚本,它将检查所有的端口并选择第一个可访问的端口。然后,这个过程会将占位符替换为找到的端口。
  3. 运行容器。
  4. 运行测试。
  5. 停止容器。

1.容器可能在构建崩溃时继续运行

假设出了问题,操作系统杀死了运行构建的进程。容器会发生什么?没什么。它们会像往常一样继续运行。如果这样的情况发生了几次,这将导致不必要的资源消耗。

但是,这个问题并不是世界末日。我们可以创建一个按计划运行的作业,终止闲置的容器。然而,这并不是唯一的问题。

2.构建本身可能在Docker-容器内运行

这是许多CI供应商的常见做法。它有助于孤立地运行不同的构建。但这意味着人们失去了运行Docker容器的机会。

在另一个Docker容器中运行Docker容器是可能的其要求是:

  • 外层容器必须以特权模式启动(-privileged=true)。
  • 你应该在运行的容器内安装docker。

这种方法的问题是,你通常无法控制运行你的构建的容器的属性。在这种情况下,Docker-Compose并不是一个可行的解决方案。

拥抱Testcontainers

Testcontainers是一个Java库,它在测试开始运行时将所需的依赖项创建为Docker容器,并在测试完成后最终将其销毁。

你可能会指出,这个解决方案与Docker-Compose并无太大区别。我们仍然要处理构建崩溃时的空闲容器,以及构建在Docker容器内自己运行的可能性。我可以说,Testcontainers克服了这些障碍。我们以后会看到它是如何做到的。

下面是一个用JUnit5集成Testcontainers的简单Java测试。我从库的文档中提取了这个代码例子。

@Testcontainers
class MixedLifecycleTests {

  // will be shared between test methods
  @Container
  private static final MySQLContainer MY_SQL_CONTAINER = new MySQLContainer();

  // will be started before and stopped after each test method
  @Container
  private PostgreSQLContainer postgresqlContainer = new PostgreSQLContainer()
      .withDatabaseName("foo")
      .withUsername("foo")
      .withPassword("secret");

  @Test
  void test() {
    assertTrue(MY_SQL_CONTAINER.isRunning());
    assertTrue(postgresqlContainer.isRunning());
  }

}

Docker-Compose方法和Testcontainers的关键区别是动态配置。容器被描述为普通的Java代码。这在配置环境方面提供了更多的灵活性。

也没有明确的端口映射。你问,应用程序如何连接到实例?Testcontainers在幕后完成了这项工作。它扫描了可用的端口,并选择了一个开放的端口。

使用Testcontainers的好处是什么?

  1. 简单的配置。你可以使用与应用程序的代码相同的语言,以你想要的方式调整容器。
  2. 环境容易重现。即使不能在机器上安装Docker,也不是什么大问题,因为你可以配置库来连接到远程主机上的Docker服务。
  3. 几十个现成的解决方案被打包成Docker容器供你使用。如果你没有找到你需要的那个,你可以随时应用通用容器
  4. 虽然Java是Testcontainers的主要语言,但也有很多其他选择--例如,RustPythonGoScalaNodeJS

这一切听起来很有希望,但潜在的问题呢?我们已经表明,Docker-Compose集成到CI管道中可能是一个挑战。Testcontainers是否也有同样的问题?

1.容器可能在构建崩溃时继续运行。

Testcontainers确实有这样的问题。不过,自从Ryuk之后,容器的实现就不再有意义了。这个想法很简单。除了强制性的项目依赖之外,Testcontainers启动Ryuk。它的工作是通过发送心跳请求来跟踪其他容器的健康状态。当一个容器停止响应时,Ryuk会将其与相应的镜像、网络和卷一起删除。

2.构建本身可以在Docker-container内运行。

该库可以检测到应用程序本身是在一个Docker容器内。为了克服这个障碍,你应该应用Docker 虫洞模式

下面是代码示例:

docker run -it --rm \
       -v $PWD:$PWD \
       -w $PWD \
       -v /var/run/docker.sock:/var/run/docker.sock \
       maven:3 \
       mvn test

当你运行构建时,你应该将卷和工作目录映射为当前目录,并且还要挂载docker.sock文件。Testcontainers会做剩下的事情。

你可能不需要自己执行这些配置。因为市场上大多数CI/CD工具,如Semaphore,默认支持这种模式。

总结

最后,我可以说,集成测试确实很艰难(但值得!)。另一方面,它从未像今天这样简单。现代技术的蓬勃发展(如Docker、Testcontainers、CI/CD工具)使它变得明显而直接。Semaphore当然也是如此,它支持运行Docker容器以及Testcontainers库的开箱即用。这意味着你不需要处理任何复杂的配置--Semaphore为你做了这件事

我听到有人说,高代码覆盖率并不能证明代码的质量。那么,我可以肯定地说,缺乏集成测试是一个错误产品的标志。

现在就说这么多。如果你有任何问题或建议,请在下面留下你的意见。

谢谢你的阅读!