测试是一个必不可少的东西。如果你的软件项目缺乏自动化测试,你就无法确定它是否能运行。有几十个很棒的CI/CD工具将测试的观点提升到一个新的水平(例如Semaphore)。
今天的测试不仅仅是IDE中的绿色和红色图标。有意义的图表、描述性的日志、覆盖率的检查,甚至个别的运行结果,都可以让现代的开发者使用。今天,测试是高效软件开发的一个完整过程。
当开发人员谈论测试时,他们通常是指 单元测试.但是集成测试呢?有些人说它们 "太复杂",并试图避免它们。这是一种不好的做法。
工具、库和CI/CD环境可以使集成测试像单元测试一样简单明了!你问,这怎么可能呢?让我们来了解一下。
单元测试与集成测试
什么是单元测试
单元测试的目的是为了验证单个组件的行为。顺便说一下,关于 "单元 "这个词的定义有一点模糊不清的地方。它是一个类吗?一个函数?或者是整个包?
这取决于你的偏好。首先,让我们定义一下测试驱动开发范式。TDD是一种在业务代码之前编写测试的软件工程方法。你可以在这里阅读更多关于TDD的信息。TDD有两个流派。 底特律学派(或经典派)和伦敦学派(或模仿派)。
底特律学派提倡由内向外的设计,这是一个规则。因此,当我们开始在一个项目上工作时,领域模型是第一位的。API层是最后要开发的东西。下图显示了底特律TDD的步骤。
单元测试驱动架构流程。你可以把它比作一个洋葱,每个单元测试都覆盖了一个新的应用层。验收测试是最后一步,以验证整个软件产品的功能是否符合预期。
关于TDD的底特律学派,还有一个重要的细节。单元测试可以与许多组件(即类)互动。当你意识到你需要一个新的类时,你就在你的测试套件中把它实例化。在底特律的案例中,"单元 "描述了测试的隔离性,但没有描述你必须一次验证一个类的事实。
伦敦TDD学派将这种范式颠倒过来(即由外向内)。它宣称,应用程序开发应该从API开始。而领域模型则是在最后一刻才开发。请看下面的图表。
在伦敦学校的案例中,验收测试引领开发。单元测试是完全相互隔离的。单元测试一次只需要验证一个组件,任何类的依赖关系都应该被模拟。
关于单元测试的原则,有两种相反的意见。那么我们该如何定义它呢?我想用这种方式来说明这个定义。
单元测试是一种自动化测试,具有以下特点。
- 单元测试行为不依赖于运行中的应用程序之外的任何状态。
- 单元测试可以在不影响对方的情况下并行运行。
我们测试组件本身,但不测试它们与外部系统的交互。为了测试这些相互作用,我们需要进行集成测试。
什么是集成测试
集成测试是一种自动化测试,具有以下特点:
- 集成测试验证了组件与外部系统(数据库、消息队列等)的交互。
- 集成测试在平行运行时可能会相互影响。
集成测试的目标是验证一个应用程序与外部依赖的交互。不是一个存根或模拟,而是实际的实例。
此外,集成测试不需要一次性启动整个应用程序。该图将数据库、消息队列和邮件服务绑定区分为三个独立的集成测试。端到端(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;
}
}
该查询不会产生运行时异常,但其行为并不总是正确的。你看,我们把JOIN (INNER JOIN 的别名)而不是LEFT JOIN 。这意味着我们不会找到没有角色的用户。
你可以手动测试上述的问题。只要在本地启动应用程序,用Postman发送HTTP请求,对吗?那么,我认为这种方法是不正确的。
测试的目的是对业务功能进行自动验证,以提高交付管道的效率。如果你知道你的代码在合并到主分支之前已经被完全测试过了,那么部署新版本就容易多了。但是,如果产品只经过了部分验证,那么这样的改变就有更大的可能会让生产完全脱轨。
对于这个问题,甚至有一个专门的术语:恐惧驱动的开发。你是否曾经害怕重构你的代码?你是否曾经因为合并了前一天没有真正验证过的东西而睡不好觉?不要责怪自己,因为你并不孤单。缺少集成测试会导致这种现象。
我的观点是,单元测试对于验证业务逻辑是完美的,因为它们与实现细节解耦。然而,在现实中,软件应用程序与外部系统交互,我们必须检查这些交互来测试我们的产品。集成测试是解决这个问题的完美方法。
集成测试的陷阱
我们已经详细地谈到了集成测试的重要性,但没有谈到与实施集成测试有关的问题。
实施集成测试从一开始就会很棘手。我们该如何开始呢?最初的步骤是环境安装,这比想象的要难:
- 应用程序可能会与大量的外部依赖关系互动(例如PostgreSQL、Kafka、MongoDB等)。
- 我们还必须使设置在任何机器上都是可重复的,因为大多数项目是由一群开发人员创建的。
- 维护问题依然存在。例如,如果有一天我们的产品开始依赖另一个外部服务,我们必须相应地更新环境。
- 最后,这整个领域必须在持续集成过程中运行。
已知的模式
纵观软件开发的历史,程序员提出了几种模式来处理集成测试问题。
手动环境配置
这个想法很简单。如果你的应用程序依赖于X,在你的计算机上安装X。每次你运行集成测试时,根据配置文件输入属性。
似乎是一种自然的方法,对吗?毕竟这就是我们启动应用程序的方法。所以,测试部分应该没有什么不同,对吗?算是吧。有一些问题:
- 开发人员完全负责维护环境。如果有人升级了数据库版本,团队中的每个人都应该重复。否则,测试的角度就不可靠了。
- 这给配置增加了额外的困难。开放端口管理是最小的问题。
- 这种技术不适用于CI。
你可能不同意最后一点。例如,如果应用程序需要PostgreSQL,我们可以在远程服务器上运行一个实例。然后任何人都可以在拉动请求构建期间连接到它。
假设有两个不同项目的两个构建同时运行。他们可能需要完全不同的表结构。如果他们都连接到同一个数据库,都不会成功。
另外,你可以尝试在每次CI构建中动态地创建和销毁数据库,但这将是相当费力的。有一些更好的方法,我们将在后面研究。
Vagrant
Vagrant使手动方法自动化。你需要在Vagrant文件中声明你的依赖关系,并执行vagrant up命令。然后,该工具会运行一堆虚拟机。每一个都代表一个特定的外部依赖。
有什么好处?
- 该工具遵循基础设施即代码(IaC)方法。整个系统配置是一个简单的文本文件。开发人员可以通过拉动请求来改变它。
- 你不必担心版本问题或任何基础设施的替代问题。Vagrant会接收所有的变化。
- 不需要手动管理。忘记开放端口问题或复杂的配置障碍。
不幸的是,有一些无法解决的问题。
- 你必须拥有一台强大的机器来使用Vagrant,因为虚拟机需要比实体服务启动多得多的资源。
- 初始化不会足够快。
- CI整合很棘手(有时是不可能的)。技术上来说,Vagrant不是一个测试工具,而是一个开发工具。它的主要目的是为本地开发过程准备环境。即使你可以用它来测试,它也不能解决主要问题。
Docker-Compose
Docker在软件开发方面进行了一场革命。说实话,它并没有发明什么新东西。Docker使用了已经存在了很长时间的Linux命名空间和CG组。此外,这些解决方案已经存在(例如LXC容器),但Docker使容器的使用变得透明和用户友好。最简单的情况是只需要一条docker运行命令。
Docker-Compose是下一个进化的步骤。它允许几个容器按需运行。人们应该创建一个docker-compose.yml文件,以声明的方式定义所有需要的服务。
听起来像是一个解决集成测试问题的绝佳机会。让我们来看看Docker-Compose提供了什么:
- Docker-Compose像Vagrant一样遵循IaC方法。
- 由于Docker是一个跨平台的工具,环境在任何地方都是可重复的。
- 零配置。你只需要安装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节点上是开放的?嗯,有一个黑客可以克服这个限制:
- 用一个占位符(例如:
$MYSQL_PORT)来代替实际的端口号。 - 运行一个特殊的脚本,它将检查所有的端口并选择第一个可访问的端口。然后,这个过程会将占位符替换为找到的端口。
- 运行容器。
- 运行测试。
- 停止容器。
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的好处是什么?
- 简单的配置。你可以使用与应用程序的代码相同的语言,以你想要的方式调整容器。
- 环境容易重现。即使不能在机器上安装Docker,也不是什么大问题,因为你可以配置库来连接到远程主机上的Docker服务。
- 几十个现成的解决方案被打包成Docker容器供你使用。如果你没有找到你需要的那个,你可以随时应用通用容器。
- 虽然Java是Testcontainers的主要语言,但也有很多其他选择--例如,Rust、Python、Go、Scala或NodeJS。
这一切听起来很有希望,但潜在的问题呢?我们已经表明,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为你做了这件事
我听到有人说,高代码覆盖率并不能证明代码的质量。那么,我可以肯定地说,缺乏集成测试是一个错误产品的标志。
现在就说这么多。如果你有任何问题或建议,请在下面留下你的意见。
谢谢你的阅读!