自动化测试的类型

尽管单元测试可能是最广为人知的自动化测试类型,但还有更多值得探索的地方。在本节中,我们将介绍最常见(也是最重要)的测试类型。众所周知的测试概念是测试金字塔,如图所示:

image 2023 11 12 15 05 33 065
Figure 1. Figure 10.1: Testing pyramid

这一概念基本上显示了三种类型的测试,即端到端测试(简称 E2E 测试)、集成测试和单元测试。我们将在下文中解释每种测试类型及其在测试金字塔中的位置。

单元测试

顾名思义,单元测试 就是测试代码的小单元。最好的做法是只为对象的每个功能编写一个测试;否则,测试就会变得越来越大,难以理解和维护。这也是为什么通常会有很多测试的原因。根据项目规模的不同,有成百上千个单元测试是完全正常的,因此必须尽可能快地执行这些测试。通常情况下,每个单元测试的执行时间不应超过几微秒。

单元测试应在隔离状态下运行,这意味着在测试中,被测试对象不与任何其它外部服务(如数据库或 API)交互。这可以通过伪造外部依赖关系来实现,单元测试术语称之为 mocking。简单地说,我们用模拟对象(简称 "mock")替换外部对象,如测试对象中使用的服务或资源库。在单元测试运行期间,这些对象会模拟它们所替代的依赖关系的行为。这样就能确保测试不会因为数据库中的某些数据(测试所依赖的数据)发生变化而突然失败。

由于这类测试小巧、快速,而且不依赖外部依赖关系,因此为它们创建测试设置相对容易。它们非常有用,因为它们能在几秒钟内告诉你,你对代码的最后一次修改是否导致了任何问题。这就是为什么它们是测试金字塔的基础。

如果你是测试新手,从 PHPUnit 开始是明智的,因为它是 PHP 世界的行业标准。如果你开始一个新项目,很可能会用到 PHPUnit。其它测试框架也有其独特的优势,如 Pest (https://pestphp.com)。一旦你掌握了使用 PHPUnit 进行单元测试的概念,我们鼓励你也试试它们。

单元测试的一个缺点是它们之间不能相互影响。这甚至可能导致所有测试都通过,而应用程序却被破坏,原因就在于类之间的交互没有得到正确测试。

为了说明这个问题,我们创建了一个基本的演示应用程序。让我们来看看其中最重要的部分。

演示应用程序源代码

你可以在本书的 GitHub 存储库中找到完整的源代码: https://github.com/PacktPublishing/Clean-Code-in-PHP

首先,我们创建一个名为 MyApp 的基本类,如下所示:

<?php

class MyApp
{
    public function __construct(
        private myRepository $myRepository
    )
    {
    }

    public function run(): string
    {
        $dataArray = $this->myRepository->getData();

        return $dataArray['value_1'] . $dataArray['value_2'];
    }
}

MyRepository 方法通过构造函数注入。唯一的方法 run 使用存储库获取数据并进行连接。值得注意的是,MyClass 希望 MyRepository 返回一个特定的数组结构。虽然我们不建议这样做,但在 "in the wild" 还是会经常发现这种情况。因此,它完全可以作为一个示范。

MyRepository 是这样的:

<?php

class MyRepository
{
    public function getData(): array
    {
        return [
            'value_1' => 'some data...',
            'value_2' => 'and some more data'
        ];
    }
}

在现实生活中,MyRepository 会从外部数据源(如数据库)获取数据。在我们的示例中,它返回的是一个硬编码数组。如果 MyClassrun 方法被执行,它将返回 some data…​and some more data 字符串。

当然,我们还为前面的类添加了测试(使用 PHPUnit)。为简洁起见,我们将在下面的代码片段中只显示测试用例,而不显示整个测试类:

public function testRun(): void
{
    // Arrange
    $repositoryMock = $this->createMock(MyRepository::class);
    $repositoryMock
        ->expects($this->once())
        ->method('getData')
        ->willReturn([
            'value_1' => 'a',
            'value_2' => 'b'
        ]);

    // Act
    $appTest = new MyApp($repositoryMock);
    $result = $appTest->run();

    // Assert
    $this->assertEquals('ab', $result);
}

public function testGetDataReturnsAnArray(): void
{
    // Arrange
    $repositoryTest = new MyRepository();

    // Act
    $result = $repositoryTest->getData();

    // Assert
    $this->assertIsArray($result);
    $this->assertCount(2, $result);
}
安排-执行-断言 (AAA) 模式

你可能已经注意到,我们在两个测试用例中都添加了三行注释: Arrange(安排)、Act(执行)和 Assert(断言)。我们这样做是为了演示最常用的单元测试编写模式:AAA 模式。即使你从未编写过单元测试,这也有助于你理解单元测试是如何工作的。

首先,准备测试对象和所需的先决条件,如模拟对象(Arrange)。其次,执行被测对象的实际工作(Act)。最后,确保测试结果符合我们的预期(Assert)。如果其中一个断言未得到满足,则整个测试失败。

这里有两件事值得注意,如下所示:

  1. testRun() 中,我们创建了一个 $repositoryMock 模拟,而不是使用实际的 MyRepository 方法。这是因为我们假设 MyRepository 通常会从外部数据源获取数据,而我们不想编写具有外部依赖性的单元测试。

  2. testGetDataReturnsAnArray() 并没有很好地测试版本库。我们只是检查结果是否是一个数组,是否有两个条目,但没有检查返回的是哪个数组的键。

现在,想象一下,不管出于什么原因,有一位开发人员认为 value_1value_2 数组键值太长,于是将它们重命名为 val1val2。如果我们现在执行应用程序,它当然会崩溃,如图所示:

$ php index.php
PHP Warning: Undefined array key "value_1" in
/home/curtis/cleancode/chapter10/unit_tests_fail/src/MyApp.php on line 18
PHP Warning: Undefined array key "value_2" in
/home/curtis/cleancode/chapter10/unit_tests_fail/src/MyApp.php on line 18

但是,如果你执行测试,它们仍然会通过,如下所示:

$ vendor/bin/phpunit tests
PHPUnit 9.5.20 #StandWithUkraine

.. 2 / 2 (100%)

Time: 00:00.008, Memory: 6.00 MB
OK (2 tests, 4 assertions)

这说明,单元测试固然重要,但并不一定意味着我们不会再引入错误,因为单元测试可能是错误的,或者测试的是错误的东西,就像我们的例子一样。

通常情况下,与外部系统交互的资源库等对象根本不会被测试,因为这需要更复杂的测试设置—​例如,使用带有假数据的额外测试数据库。如果我们只用一个模拟对象替换这样的对象,测试就能正常进行。如果后来原始对象发生了重大变化,而模拟对象没有更新以反映这些变化,我们就会陷入刚才描述的情况。

为了解决这个问题,我们需要一种方法来额外测试我们的类,而不需要用 mock 替换依赖关系。为此,我们将在下一节介绍测试金字塔的第二种测试类型—​集成测试。

集成测试

我们要了解的第二种测试类型是 集成测试。与单元测试不同的是,单元测试不应该使用任何外部依赖关系,而集成测试则恰恰相反:我们希望按照代码正常运行的方式进行测试,而不需要用模拟来替代任何东西。

你可能已经见过使用测试数据库或外部 API 的单元测试套件。从技术上讲,这些测试不再是单元测试,而是集成测试(或功能测试)。理论上,我们也可以使用 PHPUnit 来进行这些测试,或者使用特定的测试工具来替我们完成大量的基础工作。

下面的代码片段展示了一个集成测试的例子:

public function productIsSaved(Tester $tester)
{
    $product = new Product();
    $product->setId(123);
    $product->setName('USB Coffee Maker');
    $product->save();

    $this->tester->seeInDatabase(
        'products',
        ['id' => 123, 'name' => 'USB Coffee Maker' ]
    );
}

前面的函数展示了如果我们使用 Codeception 测试工具,集成测试会是什么样子(更多信息请参见下一个信息框)。一个名为 $tester 的对象被传入测试中,它是一个帮助对象,提供了我们需要执行的功能—​例如,数据库检查。在执行 $product 测试对象的保存方法后,我们将使用该帮助对象来验证我们所期望的数据是否已实际写入数据库。

Codeception

Codeception (https://codeception.com) 将单元测试、集成测试甚至 E2E 测试等多种测试类型集于一身。在引擎盖下,它基于现有的工具,如 PHPUnit。它为所有主要框架提供模块,因此能很好地集成到大多数 PHP 项目中。

使用集成测试会使测试设置变得更加复杂,因为我们必须确保我们使用的外部依赖关系始终处于可靠状态。例如,如果需要依赖数据库中的某个用户,就必须确保它始终拥有相同的数据,如测试的用户标识符(ID);否则,测试就会失败。这通常需要在每次测试运行前创建一个全新的测试数据库,以确保没有以前测试运行的遗留数据干扰我们的测试。此外,我们还需要运行数据库迁移,以确保测试数据库模式是最新的。最后,我们必须用测试数据填充数据库,这就是所谓的 "播种"。

这种测试类型的主要缺点是执行速度。数据库事务处理速度较慢(与使用模拟对象相比),而且每次测试运行时我们都需要准备测试数据库。集成测试也更容易崩溃,或者变得不稳定(不稳定),因为与其它依赖关系的交互很快就会变得非常复杂:如果上一个测试以下一个测试意想不到的方式更改了数据库,那么尽管代码没有更改,测试运行也会失败。

例如,测试套件中添加了一个新测试,用于检查一个类是否以某种方式更新了数据集。由于这是一个集成测试,它将使用测试数据库并更改特定的数据集。不过,在执行完该测试后,已更改的数据仍将保留在测试数据库中。如果在这个新测试之后运行的另一个测试依赖于之前的数据,就会失败。尽管集成测试会增加测试设置的复杂性,但它能确保被测试对象在应用程序上下文中的集成运行良好。因此,集成测试应成为测试策略中不可或缺的一部分,是测试金字塔的第二层。

在测试存储库、模型或控制器时,你会发现很多集成测试。但是,它们无法测试 PHP 与浏览器之间的交互。由于我们使用 PHP 主要是为了构建网络应用程序,因此我们不应忘记这一点。幸运的是,测试金字塔中的最后一种测试类型正好可以解决这个问题。

端到端测试

对于这种测试类型,我们将暂时离开 PHP 领域。通过 E2E 测试,我们要确保从服务器到客户端(例如浏览器)再返回服务器的整个流程正常运行。基本上,我们要模拟用户坐在电脑前点击我们的应用程序。

为此,我们首先需要一个可重现的测试环境。与集成测试一样,我们必须确保要测试的应用程序始终处于相同的状态。这意味着我们需要确保每次测试运行时都有相同的数据集(例如,博客文章或商店中的文章)。

其次,我们需要实现用户与测试环境交互的自动化。这就是有趣的地方:我们不仅需要应用程序,还需要本地网络服务器和浏览器来运行应用程序并模拟用户交互。网络服务器会增加测试设置的复杂性,但通常不会妨碍测试。在用户交互方面,我们需要使用所谓的无头浏览器。这种浏览器无需打开浏览器窗口即可与服务器交互。这是一个非常有用的功能,因为我们可以在命令行上使用它,而无需安装带有图形用户界面(GUI)的完整操作系统,如 Ubuntu Desktop 或 Windows。这为我们节省了大量的安装时间,并有助于我们避免进一步增加复杂性。

在撰写本文时,谷歌 Chrome 浏览器是首选,因为它不仅是目前使用最广泛的浏览器引擎,而且还提供无头模式,换句话说,它可以像无头浏览器一样运行。借助 Cypress 等现代框架,我们可以毫不费力地实现用户与应用程序的自动交互。把它想象成一个脚本,告诉浏览器打开哪个统一资源定位器(URL),点击哪个按钮,等等。下面的示例展示了一个简化的 Cypress 测试:

describe('Application Login', function () {
    it('successfully logs in', function () {
        cy.visit('http://localhost:8000/login')

        cy.get('#username').type('test@test.com')

        cy.get('#password').type('supersecret')

        cy.get('#submit').click()

        cy.url().should('contain','http://localhost:8000/home')
    })
})
Cypress

Cypress 测试框架 (https://www.cypress.io/) 可以让编写 E2E 测试变得非常简单,因为它会为你处理无头浏览器的设置和通信。是的,测试需要用 JavaScript 编写,但这并不妨碍你一试。

cy 对象代表测试人员,它执行某些步骤。在前面的代码示例中,它首先打开一个虚构应用程序的登录页面,在登录表单中填写 #username#password 字段,然后点击 #submit 按钮提交。最后一步是检查登录是否成功,以及测试人员是否被转到主屏幕。所有这些操作都在后台运行的真实浏览器中执行。利用这项技术,我们可以编写测试套件,让测试人员像人一样点击应用程序。它们不仅测试 PHP 代码,还测试前端代码—​例如,JavaScript 错误很快就会破坏测试。即使自己无法修复错误,也可以通知团队中的前端工程师出现了问题。

与 Selenium 等旧技术相比,现代框架让编写测试变得更容易。事实上,如今编写测试非常容易,即使不是开发人员但有扎实技术基础的人,如质量保证(QA)工程师,也能轻松编写自己的测试套件。这种方法减轻了团队的压力,因为开发人员只需编写较少的测试,而质量保证人员则可以根据自己的需要设置测试,无需等待开发人员。

当然,E2E 测试也有一些缺点,这就是为什么它们只是测试金字塔中的第三层测试:测试环境更复杂,需要更多的工作来设置,尤其是在使用数据库或任何外部 API 的情况下。这种测试类型的速度也是最慢的,因为除了前面几种测试类型的测试设置外,它还涉及到浏览器。最后,这些测试很容易中断,因为测试框架通常使用超文本标记语言(HTML)的 id 和类属性,甚至是文档对象模型(DOM)选择器来浏览 DOM 并找到要交互的元素。因此,DOM 上的一个微小变化就会迅速破坏整个测试套件。

页面对象

如果你对创建可维护的 E2E 测试感兴趣,就应该了解一下页面对象的概念 ( https://www.martinfowler.com/bliki/PageObject.html )。

实践中的测试金字塔

通过单元测试、集成测试和 E2E 测试,你现在知道了三种最重要的测试类型及其优缺点。我们建议的方法是以单元测试作为重要基础,再进行相当数量的集成测试,最后进行一些 E2E 测试,这是一个很好的起点。

不过,你不必一直严格遵循这种方法,因为每个项目都不尽相同:例如,如果你想开始测试一个完全未经测试的应用程序,那么引入单元测试就需要进行大量的重构工作,以使类具有可测试性。这种重构很可能会在一开始带来更多的错误,而不是解决更多的问题。

在这种情况下,从良好的 E2E 测试覆盖开始会更快、更安全。一旦应用程序的主要部分可以自动测试,就可以安全地开始重构并引入单元和/或集成测试。如果应用程序因必要的重构而崩溃,你的 E2E 测试将为你提供保障。

在本章的最后,我们将列出更多的测试类型,如果你有兴趣,可以进行评估。目前,我们在本章中介绍的三种测试类型是最重要的,应该足以让你入门。

但有一个重要问题我们还没有真正涉及:你到底需要测试多少代码?我们将在下一节讨论这个问题。