测试驱动开发
你可能已经意识到,在开发应用程序的过程中,并没有什么独一无二的方法。但有一种方法在编写优秀的可测试代码时非常有用:测试驱动开发(TDD)。
这种方法包括在编写代码之前先编写单元测试。不过,这种方法并不是一次性写完所有测试,然后再编写类或方法,而是循序渐进。让我们举例说明。想象一下,你的 Sale
类还没有实现,我们唯一知道的是我们必须能够添加书籍。将 src/Domain/Sale.php
文件重命名为 src/Domain/Sale2.php
,或者直接删除,这样应用程序就不会知道它了。
有必要啰嗦这么多吗?
在这个示例中,你会发现我们将执行过多的步骤来生成一段非常简单的代码。的确,对于这个示例来说,这些步骤太多了,但有时这样的步骤也是可以的。我们建议您先从简单的示例开始练习。最终,您会自然而然地掌握这些技巧。 |
TDD 的机制包括以下四个步骤:
-
为某些尚未实现的功能编写测试。
-
运行单元测试,它们应该会失败。如果没有失败,要么是你的测试错了,要么是你的代码已经实现了这个功能。
-
编写最少的代码,使测试通过。
-
再次运行单元测试。这一次,它们应该会通过。
我们没有 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 时提及测试的文件,如下脚本所示:

很好,它们都失败了。这说明 PHP 无法找到对象并将其实例化。现在让我们编写使测试通过所需的最少代码量。在这种情况下,创建类就足够了,可以通过下面几行代码来实现:
<?php
namespace Bookstore\Domain;
class Sale {
}
php
现在,运行测试以确保没有错误。

这很简单,对吗?因此,我们需要做的就是重复这个过程,每次都添加更多的功能。让我们把重点放在销售所持有的图书上;创建时,图书列表应该是空的,如下所示:
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
我们完成了!看起来眼熟吗?除了其他属性外,这和我们第一次实现时写的代码是一样的。现在,您可以将销售域对象替换为之前的对象,这样您就拥有了所需的所有功能。
理论与实践
如前所述,这是一个相当冗长的过程,很少有经验丰富的开发人员会自始至终遵循这一过程,但大多数开发人员都鼓励人们遵循这一过程。为什么会这样呢?如果先编写所有代码,最后才进行单元测试,就会出现两个问题:
-
首先,在很多情况下,开发人员懒得跳过测试,他们告诉自己代码已经可以正常工作了,所以没必要再写测试。要知道,测试的目的之一是确保未来的更改不会破坏当前的功能,所以这不是一个合理的理由。
-
其次,在代码之后编写的测试通常是测试代码而不是功能。想象一下,你有一个方法,最初的目的是执行一个动作。在编写完方法后,由于错误或糟糕的设计,我们不会完美地执行该操作;相反,我们要么做得太多,要么留下一些边缘情况未处理。在编写代码后进行测试时,我们将测试在方法中看到的内容,而不是最初的功能!
如果你强迫自己先写测试,然后再写代码,你就能确保始终有测试,而且测试的是代码要做的事情,从而使代码达到预期的性能,并得到全面的覆盖。此外,通过小间隔地进行测试,您可以快速获得反馈,而不必等待数小时才能知道您编写的所有测试和代码是否有意义。尽管这个想法很简单,也很有道理,但许多新手开发人员却发现很难实现。
经验丰富的开发人员已经编写了数年代码,因此他们已经将所有这些内化于心。这就是为什么他们中的一些人喜欢先写几个测试,然后再开始写代码,或者反其道而行之,即先写代码,然后再测试,因为这样他们的工作效率更高。不过,如果说他们有什么共同点的话,那就是他们的应用程序总是充满了测试。