测试汽车发动机是一回事,但测试整辆汽车是另一回事。由于一辆汽车通常包含许多组件,测试单个部件不一定保证它们能够很好地协同工作。即使每个组件的个体功能都证明是良好的,我们仍然需要验证它们能够无缝集成。
另一方面,我们不能从一开始就测试整辆汽车。隔离和追踪出现的故障会相当困难,更重要的是,在测试整辆汽车之后再回过头来修复单个组件会很昂贵。
因此,我们需要兼顾两者,分别测试各个组件以提高对其行为的信心并尽早发现问题,同时也要验证这些不同的组件是否能够真正协同工作。
同样的概念也适用于软件,区别在于——显然——我们的最终产品是一个应用程序而不是一辆汽车。软件应用程序通常由多个组件构成,每个组件在整个应用程序行为中扮演特定的角色或功能。例如,身份验证服务用于验证用户身份,购物车服务用于存储用户购买的商品,支付服务用于处理支付交易。
为了确保我们的应用程序按预期工作,我们需要测试每个单独的服务,以及测试多个服务协同工作时的流程。在软件术语中,单个组件测试称为单元测试,而聚合组件测试称为集成测试。
在本文中,我们将比较这两种测试技术并解释每种技术的运作方式。在我们深入比较之前,让我们先概述一下软件测试以及为什么我们需要它。
软件测试是软件开发生命周期中的一个关键阶段。它确保软件满足一组业务需求并按预期工作。这些需求和期望通常是宽泛的术语,因软件而异,应根据全面的测试策略进行验证。
软件中有许多方面需要验证。例如,我们需要检查它是否不包含错误或错误,在高负载下提供一定水平的性能,并满足整体所需的用户功能。
这些不同的验证通过使用不同的测试方法来解决,这些方法可以粗略地分类如下:
还有其他类型的测试方法可以用于特定验证,但这些是最常见和最广泛的类型。
单元测试是一种软件测试,它验证软件中代码的各个部分——称为单元。单元是可以在隔离状态下逻辑测试的最小代码段。我们所说的逻辑是指,它在被测试时对开发人员提供有用的价值。术语“单元”没有严格的定义。它可以从单个函数或类到一组相关函数,其理念是它本身执行一项特定任务,被认为是整个软件功能中的一个小型构建块。
单元测试也被认为是一种自动化测试。单元测试本身实际上是用代码实现的,它执行实际的软件代码。它提供测试输入并验证输出是否与预期结果匹配。
与其他类型的测试不同,单元测试并非旨在验证业务需求或用户期望,而是针对开发人员的工作。换句话说,单元测试旨在验证开发人员实现的代码片段是否按预期正确执行。
单元测试的这种特定特性要求编写测试的人员非常了解被测试的代码。这就是为什么单元测试通常由开发人员自己编写的原因。
让我们用一个简单的例子来演示这一点,假设我们有一个基本的函数用于比较两个数字
public class numOperations {
public int compare(int num1, int num2) {
if (num1 > num2) return 1;
else if (num1 < num2) return -1;
return 0;
}
}
为了验证它是否正常工作,我们创建了单独的代码来测试该函数的不同场景:
public class numOperationsTest {
@Test
public void scenario1() {
numOperations mytest = new numOperations();
int value = mytest.compare(3, 2);
Assertions.assertEquals(1, value);
}
@Test
public void scenario2() {
numOperations mytest = new numOperations();
int value = mytest.compare(2, 3);
Assertions.assertEquals(-1, value);
}
@Test
public void scenario3() {
numOperations mytest = new numOperations();
int value = mytest.compare(2, 2);
Assertions.assertEquals(0, value);
}
}
这里的测试代码使用真实的函数代码,并为不同的场景提供不同的输入。例如,scenario1 函数使用 compare 函数,第一个输入大于第二个,它评估结果将等于 1,如 compare 函数预期的那样。如果结果与预期输出不匹配,测试将失败。
上面的示例可以被认为是一个单元测试,我们可以看到它专注于代码行为并验证软件的特定部分,即比较函数。它是用 Java 编写的,但同样的理念也适用于任何其他语言。
单元测试最明显的特征是它只测试软件的单个部分。被测试的代码(通常称为被测系统或 SUT)应该与其他组件或依赖项隔离进行验证。例如,单元测试不应该与数据库或网络服务通信。
这种隔离提供了以下好处:
隔离的概念在单元测试中并不总是微不足道的。某些组件可能与其他组件有很强的依赖关系,并且它们之间必须进行交互才能使被测试的部分按预期工作。例如,要测试写入数据库的模块的行为,我们需要有一个正在运行的现有数据库,以便该模块能够成功执行。
那么,我们如何在模块上应用隔离,并允许它在没有依赖项的情况下执行呢?答案是使用测试替身。
测试替身是模仿真实依赖项的对象,但实际上并没有提供其全部功能。它们使被测试组件能够像与依赖项交互一样正常运行。这样,它们有助于保持单元测试的隔离性(不使用真实依赖项),同时还能提供顺利的执行。
测试替身是一个通用术语,它有不同的类型和实现方式。两种最常见的测试替身类型是模拟和存根。
模拟和存根都模拟软件的一个组件以隔离被测系统,但它们的工作方式不同。
模拟验证被测系统是否向其使用的依赖项发送了正确的调用。当使用模拟时,我们会验证我们组件的测试结果,以及它对模拟对象的交互。作为测试的一部分,我们对模拟对象设置期望,即它应该接收哪些调用,并验证被测系统是否正确地发出了这些调用。这种类型的验证称为行为验证。
另一方面,存根是预先编程了对调用的特定响应。它们专注于被测系统的状态,而不管它与其他组件的交互如何。存根提供的响应通常是硬编码和预定义的,这使得存根具有可预测性。例如,我们可以配置一个存根,使其对请求以成功或失败的方式做出响应,然后验证被测系统是否正确地处理了响应。这种类型的验证称为状态验证。
上图示例展示了一个简化的支付交易场景。在正常流程中,每当发生交易时,都会调用相应的账户对象,从该账户的总金额中扣除交易金额。这在交易类和账户类之间建立了依赖关系。
为了用单元测试验证交易类的行为,我们需要将其与依赖项隔离。因此,我们需要使用一个测试替身来替换账户类并模拟其行为。
在第一种情况下,我们对账户使用模拟对象。作为测试代码的一部分,我们对模拟对象设置期望,即它等待来自交易对象的扣除金额调用。然后,我们的测试将验证此调用是否已发送到模拟对象,以及我们的交易是否成功。这样,我们已经验证了被测组件,以及它与依赖项的交互。
这部分的简单实现可能如下所示:
//expectations
accountMock.expects(once()).method("deductAmount")
.with(eq(amount_of_money))
.will(returnValue(true));
//exercise
transaction.execute((Account) accountMock.proxy());
//verify
accountMock.verify();
assertTrue(transaction.isSuccessful());
在第二种情况下,我们对账户使用存根对象。我们将存根配置为在收到 deduct amount 调用时以预定义的成功响应进行响应。在我们的验证中,我们只检查被测组件的状态,即我们的交易对象是否成功。
此存根的简单实现将不包含期望部分,因为我们不需要验证与存根的交互。但存根对象将被设置为响应预定义的值。
//stub class
class StubAccount implements Account {
public boolean deductAmount() {
//deductAmount returns true whenever it’s called
return true;
}
}
//exercise
transaction.execute((Account)
//verify
assertTrue(transaction.isSuccessful());
CI/CD(持续集成/持续交付)是一种旨在可靠、安全且更频繁地交付软件的过程。它自动化了将代码更改从开发转移到生产所需的步骤,包括构建、测试和部署。通过自动化这些步骤,我们可以获得更快的反馈循环,提高软件质量,并更频繁地交付变更。
由于单元测试是软件生命周期中的一个关键步骤,因此将其作为 CI/CD 流程的一部分非常常见。这为运行测试提供了快速一致的方式。单元测试也是 CI/CD 的完美候选者,因为它们已经作为代码自动化,这使得在 CI/CD 流程中执行它们变得更容易。
通过在 CI/CD 管道 中包含单元测试,我们可以在交付过程的早期检测代码中的错误和问题,从而节省时间和金钱。CI/CD 中的单元测试还有助于为开发人员提供更快的反馈,典型的 CI/CD 管道可以配置为在对代码进行的每次更改时自动运行,从而确保测试尽可能频繁和快速地执行。将单元测试集成到 CI/CD 有助于以最小的失败或意外问题的风险生产高质量和更可靠的软件。
软件交付过程的自动化通常使用 CI/CD 工具来实现。这些工具提供不同的组件、插件、运行时环境,帮助您创建和定制适合您特定需求的 CI/CD 工作流程。这些工具通常为用户提供简单的语法来定义工作流程的不同步骤。
Travis CI 是一个提供这些功能甚至更多功能的工具。它提供基于云的 CI/CD 解决方案,您可以将其用作一项服务,而无需自行设置和配置。如果您想在自己的基础设施上安装和使用它,还可以使用本地企业版。它支持各种编程语言,并允许将不同的工具集成到您的 CI/CD 管道中,以进行代码质量检查、机密管理和基础设施部署等操作。它提供简单的 YAML 语法来配置工作流程中的步骤和阶段。
集成测试是一种软件测试类型,它组合软件的多个组件或模块,并验证它们可以无问题地协同工作。它测试不同组件之间的交互,并确保它们之间预期流程的执行。
在验证每个组件都能单独正常工作后,通常会进行组件之间的交互测试。因此,集成测试通常在单元测试之后运行。组件之间可能出现的问题包括文件系统或数据库访问问题、不一致的数据交换格式或不同的执行逻辑。
集成测试的一个简单示例是具有登录页面和用户主页的网站。它们中的每一个都经过了单独测试,登录页面正确接收了凭据,主页显示了用户偏好。集成测试将验证登录页面和主页是否协同工作,即当用户执行登录时,他会被引导到他的个人主页。
集成测试可能需要设置一个专门的环境来执行测试。因为它使用真实组件,所以需要根据测试场景将这些组件一起部署并正确准备。
实现集成测试有不同的方法。最常见的两种方法是“大爆炸式”集成测试和增量集成测试。
在大爆炸方法中,软件的所有模块都组合在一起,并同时进行测试。它要求所有组件都已开发并准备就绪,并且也已成功完成单元测试。这种方法更适合组件依赖性较少的简单小型软件系统。它更容易设置,并且由于所有组件都同时集成,因此需要很少的规划。这种方法的缺点是,更难调试和隔离问题,并且我们必须等到所有模块都开发完成后才能运行测试。它也可能难以在大规模复杂系统中使用,因为这些系统包含大量集成。
增量方法包括仅将功能上密切相关的模块子集分组在一起并对其进行测试,然后逐渐包含更多模块和组,直到所有模块都被集成和测试。子集可以包含两个或多个模块,具体取决于测试计划。这种方法允许更轻松地隔离故障,因为它从少量集成开始,并逐渐增加。它还使早期检测错误成为可能,因为它不需要所有模块都同时准备就绪,因此,只要开发好几个模块,就可以立即对其进行测试。这种方法的缺点是,它需要详细的规划,并且通常包括大量测试。
增量方法可以进一步细分为两种类型,自下而上的方法和自上而下的方法。
自下而上的方法首先测试较低级别的模块,然后逐渐移至较高级别的模块,而自上而下的方法首先测试较高级别的模块,然后移至较低级别的模块。
较低级别的模块是指执行系统基本任务的软件中更简单和更基本的组件。它们通常位于系统层次结构的底部。例如,数据库连接模块或输入验证模块。较高级别的模块是指更复杂、更大的模块,它们执行多个功能,并且可以依赖一个或多个较低级别的模块。
在 CI/CD 管道中包含集成测试是一种常见的做法,它允许在发布到生产环境之前识别集成问题和错误。此过程可能需要将组件自动部署到某种测试/登台环境中,以便将要测试的组件部署到该环境中。
在某些情况下,我们还可以将测试环境的配置和配置自动化,作为 CI/CD 管道的一部分。例如,我们可以在管道中创建一个特定阶段,以使用适当的配置(如网络连接、环境变量、Web 服务器或数据库实例)启动一些基础设施。然后,我们创建另一个阶段,根据测试计划将软件的必需组件部署到此环境。最后,我们执行一组自动化的集成测试并获取结果。
通过尽可能多地自动化集成测试过程并将它包含在我们的 CI/CD 管道中,我们可以实现更快、更频繁地交付软件变更,而不会影响系统的质量或可靠性。由于集成测试在每次管道触发时都会频繁执行,因此我们可以尽早检测问题并快速反馈循环。
与其将单元测试和集成测试视为替代方案,不如将它们视为互补方案。在本文中,我们已经看到了它们在工作方式和在软件测试中扮演的角色方面的不同之处。它们不应该相互替代,因为它们解决了软件中不同的验证标准。
更全面的测试计划应包括这两种类型的测试,同时正确区分每种测试的功能,并将其精确地限定到适合的目标。我们应该知道在软件发布周期中将每种类型的测试放在哪里,以及应该从每种测试中获得什么结果。
单元测试应用于验证单个组件的行为是否符合开发人员的预期。它们的设计应尽可能快地执行,并提供有关代码各个部分的精确反馈。单元测试在成本和时间方面也更便宜,因为它们不需要任何特定的环境配置或设置。我们应该始终优先在软件中尽早执行单元测试。
另一方面,集成测试往往更慢、成本更高。因为它涉及与可能减慢测试速度的多个组件进行交互,并且还需要设置具有特定配置的专用环境。但这并不意味着集成测试不好,它们仍然需要提供单元测试无法执行的另一种类型的验证。但是,将它们与可以立即提供每个代码更改反馈的更快单元测试分开很重要。想象一下,您必须配置特定的环境或等待将一组组件一起部署以测试您在代码库中引入的一行代码,这不是最佳选择,对吧?
总之,单元测试最适合提供有关代码各个部分的快速反馈,应该在过程的尽早阶段执行。而集成测试适用于涉及软件多个组件之间交互的更复杂的测试场景。它们通常更慢、成本更高,因此在单元测试之后,最好在软件发布的后期阶段包含它们。
软件测试是软件开发生命周期中的一个关键步骤。它有助于发布满足业务和客户需求的高质量软件。在不同类型的测试中,单元测试和集成测试是最常见的两种测试。单元测试侧重于验证软件的各个组件,并确保代码的特定部分按开发人员的预期工作。集成测试侧重于组合多个组件,并验证它们之间的交互,以确保它们按预期协同工作。单元测试和集成测试不是相互替代的,而是相互补充的,应该一起使用以获得更好的结果。