测试驱动开发

你可能已经意识到,在开发应用程序的过程中,并没有什么独一无二的方法。但有一种方法在编写优秀的可测试代码时非常有用:测试驱动开发(TDD)。

这种方法包括在编写代码之前先编写单元测试。不过,这种方法并不是一次性写完所有测试,然后再编写类或方法,而是循序渐进。让我们举例说明。想象一下,你的 Sale 类还没有实现,我们唯一知道的是我们必须能够添加书籍。将 src/Domain/Sale.php 文件重命名为 src/Domain/Sale2.php,或者直接删除,这样应用程序就不会知道它了。

有必要啰嗦这么多吗?

在这个示例中,你会发现我们将执行过多的步骤来生成一段非常简单的代码。的确,对于这个示例来说,这些步骤太多了,但有时这样的步骤也是可以的。我们建议您先从简单的示例开始练习。最终,您会自然而然地掌握这些技巧。

TDD 的机制包括以下四个步骤:

  1. 为某些尚未实现的功能编写测试。

  2. 运行单元测试,它们应该会失败。如果没有失败,要么是你的测试错了,要么是你的代码已经实现了这个功能。

  3. 编写最少的代码,使测试通过。

  4. 再次运行单元测试。这一次,它们应该会通过。

我们没有 sale 域对象,所以首先要确保我们可以实例化销售对象,因为我们应该从小事做起,然后再做大事。在 tests/Domain/SaleTest.php 中编写以下单元测试,因为我们将编写所有现有测试,但使用 TDD 时,您可以删除该文件中的现有测试。

namespace Bookstore\Tests\Domain;

use Bookstore\Domain\Sale;
use PHPUnit_Framework_TestCase;

class SaleTest extends PHPUnit_Framework_TestCase {
    public function testCanCreate() {
        $sale = new Sale();
    }
}
php

运行测试,确保测试失败。要运行一个特定的测试,可以在运行 PHPUnit 时提及测试的文件,如下脚本所示:

image 2023 11 03 20 12 46 131

很好,它们都失败了。这说明 PHP 无法找到对象并将其实例化。现在让我们编写使测试通过所需的最少代码量。在这种情况下,创建类就足够了,可以通过下面几行代码来实现:

<?php

namespace Bookstore\Domain;

class Sale {
}
php

现在,运行测试以确保没有错误。

image 2023 11 03 20 15 14 357

这很简单,对吗?因此,我们需要做的就是重复这个过程,每次都添加更多的功能。让我们把重点放在销售所持有的图书上;创建时,图书列表应该是空的,如下所示:

public function testWhenCreatedBookListIsEmpty() {
    $sale = new Sale();
    $this->assertEmpty($sale->getBooks());
}
php

运行测试以确保它们失败——它们确实失败了。现在,在类中编写以下方法:

public function getBooks(): array {
    return [];
}
php

现在,如果你运行…​…​等等,什么?我们强迫 getBooks 方法始终返回一个空数组?这不是我们需要的实现,也不是我们应得的实现,那我们为什么要这么做呢?原因在于第 3 步的措辞:"编写最少的代码以使测试通过"。我们的测试套件应该足够广泛,足以检测到此类问题,而这就是我们确保它检测到问题的方法。这一次,我们会故意写出糟糕的代码,但下一次,我们可能会无意中引入一个错误,而我们的单元测试应该能够尽快发现它。运行测试,它们就会通过。

现在,我们来讨论下一个功能。当向列表中添加一本书时,我们应该看到这本书的金额为 1。测试过程如下:

public function testWhenAddingABookIGetOneBook() {
    $sale = new Sale();
    $sale->addBook(123);
    $this->assertSame(
        $sale->getBooks(),
        [123 => 1]
    );
}
php

这个测试非常有用。它不仅迫使我们实现 addBook 方法,还帮助我们修正了 getBooks 方法,因为它现在是硬编码的,总是返回一个空数组。由于 getBooks 方法现在期望两种不同的结果,我们不能再欺骗测试了。该类的新代码应如下所示:

class Sale {
    private $books = [];

    public function getBooks(): array {
        return $this->books;
    }

    public function addBook(int $bookId) {
        $this->books[123] = 1;
    }
}
php

我们可以编写一个新测试,允许您一次添加多本书,并将金额作为第二个参数发送。该测试类似于下面的内容:

public function testSpecifyAmountBooks() {
    $sale = new Sale();
    $sale->addBook(123, 5);

    $this->assertSame(
        $sale->getBooks(),
        [123 => 5]
    );
}
php

现在,测试没有通过,所以我们需要修复它们。让我们重构 addBook 以便它可以接受第二个参数作为 amount

public function addBook(int $bookId, int $amount = 1) {
    $this->books[123] = $amount;
}
php

我们想添加的下一个功能是同一本书多次调用该方法,并记录添加的书籍总数。测试过程如下:

public function testAddMultipleTimesSameBook() {
    $sale = new Sale();
    $sale->addBook(123, 5);
    $sale->addBook(123);
    $sale->addBook(123, 5);

    $this->assertSame(
        $sale->getBooks(),
        [123 => 11]
    );
}
php

该测试将失败,因为当前执行不会添加所有金额,而是保留最后一个。让我们执行以下代码来解决这个问题:

public function addBook(int $bookId, int $amount = 1) {
    if (!isset($this->books[123])) {
        $this->books[123] = 0;
    }
    $this->books[123] += $amount;
}
php

好了,我们就快成功了。我们还应该添加最后一项测试,即添加多本不同图书的能力。测试内容如下:

public function testAddDifferentBooks() {
    $sale = new Sale();
    $sale->addBook(123, 5);
    $sale->addBook(456, 2);
    $sale->addBook(789, 5);

    $this->assertSame(
        $sale->getBooks(),
        [123 => 5, 456 => 2, 789 => 5]
    );
}
php

由于我们的实现中硬编码了图书 ID,因此该测试失败。如果我们不这样做,测试就已经通过了。那我们就来解决这个问题;运行下面的代码:

public function addBook(int $bookId, int $amount = 1) {
    if (!isset($this->books[$bookId])) {
        $this->books[$bookId] = 0;
    }
    $this->books[$bookId] += $amount;
}
php

我们完成了!看起来眼熟吗?除了其他属性外,这和我们第一次实现时写的代码是一样的。现在,您可以将销售域对象替换为之前的对象,这样您就拥有了所需的所有功能。

理论与实践

如前所述,这是一个相当冗长的过程,很少有经验丰富的开发人员会自始至终遵循这一过程,但大多数开发人员都鼓励人们遵循这一过程。为什么会这样呢?如果先编写所有代码,最后才进行单元测试,就会出现两个问题:

  • 首先,在很多情况下,开发人员懒得跳过测试,他们告诉自己代码已经可以正常工作了,所以没必要再写测试。要知道,测试的目的之一是确保未来的更改不会破坏当前的功能,所以这不是一个合理的理由。

  • 其次,在代码之后编写的测试通常是测试代码而不是功能。想象一下,你有一个方法,最初的目的是执行一个动作。在编写完方法后,由于错误或糟糕的设计,我们不会完美地执行该操作;相反,我们要么做得太多,要么留下一些边缘情况未处理。在编写代码后进行测试时,我们将测试在方法中看到的内容,而不是最初的功能!

如果你强迫自己先写测试,然后再写代码,你就能确保始终有测试,而且测试的是代码要做的事情,从而使代码达到预期的性能,并得到全面的覆盖。此外,通过小间隔地进行测试,您可以快速获得反馈,而不必等待数小时才能知道您编写的所有测试和代码是否有意义。尽管这个想法很简单,也很有道理,但许多新手开发人员却发现很难实现。

经验丰富的开发人员已经编写了数年代码,因此他们已经将所有这些内化于心。这就是为什么他们中的一些人喜欢先写几个测试,然后再开始写代码,或者反其道而行之,即先写代码,然后再测试,因为这样他们的工作效率更高。不过,如果说他们有什么共同点的话,那就是他们的应用程序总是充满了测试。

总结

在本章中,您了解了使用单元测试测试代码的重要性。现在你知道了如何在应用程序中配置 PHPUnit,这样不仅可以运行测试,还能获得良好的反馈。您已经很好地了解了如何正确编写单元测试,现在,您可以更安全地在应用程序中引入更改。

在下一章中,我们将学习一些现有的框架,你可以使用它们来代替每次启动应用程序时编写自己的框架。这样,您不仅可以省时省力,其他开发人员也可以加入您的行列,轻松理解您的代码。