最近,围绕着新的和令人兴奋的php测试工具有很多的运动。虽然这是个好消息,但退一步讲,在跳入之前了解基本概念会有帮助。让我们首先同意,当我们谈论测试工具和方法论时,我们指的是自动化测试工具。
什么是自动化测试?
自动测试是指使用专门设计的软件工具,用一组特定的输入来运行一个应用程序,并根据一组已知的期望检查产生的输出。从本质上讲,它与常规(即,手动)测试没有很大的区别。它只是要好得多。
为什么自动化测试是一个好主意?
自动化测试有助于大大减少错误数量,提高开发中的软件质量。同样,当应用于已建立的应用程序时,它有助于防止回归错误的出现,这是指在向工作代码添加新功能/改进时重新引入固定的错误。
如果你从来没有使用过自动化测试工具,你可能会持怀疑态度。毕竟,如果测试工具是软件,你如何让它们不出现bug?这很公平。这里的诀窍是以这样一种方式来编写测试,即如果它们包含一个bug,那么发现和修复它将是很容易的。通常情况下,这意味着编写小的测试,并将它们组合成测试套件。
底线是,自动化测试一开始似乎是浪费时间,因为它在项目开始时消耗了许多资源,但长期的回报是绝对值得的。在这一点上相信我。
有哪些类型的自动化测试?
现在我们已经涵盖了基本概念,让我们再进一步。正如你可能已经猜到的(或在某处听到的),有几种类型的自动化测试。主要区别在于究竟测试什么。你可以把它看成是你希望镜头离代码有多近。在最极端的一端,你会发现单元测试,而在保持相关性的情况下,你能走的最远的地方是一些验收测试的味道。
另一种测试分类方法是根据你对被测系统的了解程度。在这个方案中,你会发现所谓的白盒与黑盒测试。在第一种情况下,你有机会接触和理解代码,而在后者,情况正好相反。
值得注意的是,不同类型的测试并不一定是相互排斥的。事实上,在大多数情况下,你在项目中添加的测试层数越多越好。
当然,如果你做得太过分,你会发现自己花在编写测试上的时间远远多于实际解决业务问题的代码......但这是另一篇文章的讨论。
在下面的章节中,我将向你展示如何在一个有点真实的项目中实现一些特定的工具。这些例子是在Ubuntu盒子上用php 8.0构建和测试的,我使用Google Chrome作为网络浏览器。如果你的设置与这些规格不完全一致,你可能需要对这些命令进行一些调整。
单元测试
让我们从最基本的层面开始:单元测试。在这种测试中,你试图确定一个特定的代码单元是否符合一系列的期望。
例如,如果你正在创建一个计算器类,你会期望在其中找到以下方法:
- 加
- 减法
- 乘法
- 除法
在add 方法的情况下,期望是相当明确的;它应该返回两个数字相加的结果。
因此,我们的类应该看起来像这样:
declare(strict_types=1);
class Calculator
{
public function add(int $a, int $b): int
{
return $a + $b;
}
}
你可以用很多工具来证明这个方法的正确工作。最流行的(到目前为止)是phpUnit。
要把它添加到你的项目中,你应该做的第一件事就是安装这个工具。最好的方法是把它作为项目的依赖项加入。假设你的项目是用composer构建的,你需要做的就是发布一个composer require --dev phpunit/phpunit ,这将在你的vendor/bin 目录下产生一个新文件:phpunit 。
所以,在进一步行动之前,请确保一切都已就绪。运行vendor/bin/phpunit --version 。如果一切顺利,你应该看到像这样的东西。
PHPUnit 9.5.10 by Sebastian Bergmann and contributors.
很好!你已经准备好测试你的代码了!
当然......如果你没有写任何测试,那就没什么好说的,对吗?
那么,你如何使用phpUnit编写你的第一个单元测试?
首先,在你的项目的根部创建一个新的目录,叫做tests 。
在其中,创建一个名为CalculatorTest.php 的文件,并在其中放入以下内容:
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class CalculatorTest extends TestCase
{
public function testAddAdds()
{
$sut = new Calculator();
$this->assertEquals(8, $sut->add(5, 3));
}
}
在运行测试之前,有几件事需要准备好:
- 在项目根部有一个 phpUnit 配置文件 (
phpunit.xml.dist)。 - 一个引导脚本,将自动加载带入。
- 一个自动加载的定义。
不要担心这部分;它听起来比实际情况要糟糕得多。
phpUnit配置只是一个捷径,以避免每次运行phpunit 命令时明确地输入选项。在我们的例子中,一个简单的就可以了,像这样:
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/3.7/phpunit.xsd"
backupGlobals="true"
backupStaticAttributes="false"
bootstrap="tests/bootstrap.php">
</phpunit>
这个文件中最重要的部分是定义bootstrap="tests/bootstrap.php" ,它将tests/bootstrap.php 作为我们测试套件的入口,因此需要创建这样的文件。
tests/bootstrap.php 的内容也不需要很复杂。这样做就可以了:
<?php
require_once __DIR__.'/../vendor/autoload.php';
最后,我们需要通知 composer 我们的类映射,以使自动加载能够成功。只需在你的composer.json 中添加以下内容:
"autoload": {
"psr-4": {
"" : "."
}
},
然后,运行composer dump-autoload ,生成文件vendor/autoload.php ,你就可以毫无意外地运行你的测试了。
在你的项目根部发布命令vendor/bin/phpunit tests ,你会看到如下内容:
PHPUnit 9.5.10 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 00:00.003, Memory: 4.00 MB
OK (1 test, 1 assertion)
这意味着断言(8 等于Calculator::add(5, 3) 的结果这一事实)在测试运行中得到了验证。
phpUnit有很多细微的差别。事实上,关于这个问题已经写了整整一本书,这篇文章的想法不是针对某个特定的工具,而是给你一个概述,所以你可以进一步阅读你认为有趣的工具。不过,有一个关于phpUnit的小插曲我觉得非常有趣,我希望它能让你产生好奇心。看看如果你使用./vendor/bin/phpunit tests --testdox 运行你的测试会发生什么:
PHPUnit 9.5.10 by Sebastian Bergmann and contributors.
Calculator
✔ Add adds
Time: 00:00.003, Memory: 4.00 MB
OK (1 test, 1 assertion)
不错,对吗?但是......这段文字是怎么来的?它是直接从测试方法的名称中提取的,所以...注意你的测试名称!
集成测试
我们旅程的下一步是集成测试。这些测试的目的是证明一些组件与其他组件的配合情况如何。
起初,这种类型的测试可能看起来是多余的。毕竟,如果每个单独的单元都完成了它的工作,为什么还需要更多的测试?嗯......让我给你一个形象的解释:

你绝对不希望发现自己处于这种情况。
这可能是一个惊喜,但尽管它的名字,phpUnit也可以用来编写这种测试。
在我们的例子中,让我们假设除了我们的小计算器之外还有另一个组件,也许是一个数字集合,它能够通过向我们的计算器输入数字来计算其成员的总数。
它看起来会是这样的:
<?php
declare(strict_types=1);
class NumberCollection
{
private array $numbers;
private Calculator $calculator;
public function __construct(array $numbers, Calculator $calculator)
{
$this->numbers = $numbers;
$this->calculator = $calculator;
}
public function sum() : int
{
$acum = 0;
foreach ($this->numbers as $number) {
$acum = $this->calculator->add($acum, $number);
}
return $acum;
}
}
在这个例子中,你可以看到计算器是如何被注入到NumberCollection中的。虽然我们可以把代码写进去,让构造函数创建计算器实例,但这将使我们的测试,特别是单元测试,更难写,还有其他问题。
事实上,为了有一个真正坚实的结构,我们应该使用CalculatorInterface 作为构造函数的参数,但我们将把这个问题留待另一个讨论。
我将把这个类的单元测试作为家庭作业留给你,直接进入集成测试。在这样的测试中,我想确定的是这两个类是否一起工作,并最终产生我想要的结果。
我将如何做到这一点呢?嗯......与我到目前为止所做的没有什么不同。这就是测试的样子:
<?php
use PHPUnit\Framework\TestCase;
class NumberCollectionTest extends TestCase
{
public function testSum()
{
$numbersList = [6, 5, 6, 9];
$numberCollection = new NumberCollection($numbersList, new Calculator());
$this->assertEquals(array_sum($numbersList), $numberCollection->sum(), 'Sum doesn\'t match');
}
}
然后,通过运行vendor/bin/phpunit tests/NumberCollectionTest.php ,我得到以下结果:
PHPUnit 9.5.10 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 00:00.003, Memory: 4.00 MB
OK (1 test, 1 assertion)
这和单元测试的区别在于,在后者中,我会用一个 mock而不是实际的计算器类,因为我想测试的只是NumberCollection,从而假设计算器按预期工作。
验收测试
你可以使用的另一类测试是验收测试。这些测试是要由非技术人员进行的,这意味着用户只是简单地按下按钮并对结果微笑(或哭泣或大叫,你永远不知道)。
这种测试显然不会有很好的可重复性,更没有效率,对吗?那么,这种测试的目的是模拟真实的用户会做什么。
在PHP应用程序的情况下,我们有可能在谈论一个Web应用程序。因此,为了测试它,一个Web浏览器肯定会派上用场。
有很多工具可以用于这个目的,但我特别喜欢的一个是CodeCeption。我最喜欢的是,它是一个统一的工具,可以用来进行几种类型的测试,验收是其中之一。
让我们先把它引入我们的项目,好吗?
首先运行composer require "codeception/codeception" --dev 。这将下载并安装所有需要的库到你的vendor 目录中。
接下来,用php vendor/bin/codecept bootstrap 命令初始化 CodeCeption 的环境,它应该产生以下输出:
❯ php vendor/bin/codecept bootstrap
Bootstrapping Codeception
File codeception.yml created <- global configuration
Adding codeception/module-phpbrowser for PhpBrowser to composer.json
Adding codeception/module-asserts for Asserts to composer.json
2 new packages added to require-dev
? composer.json updated. Do you want to run "composer update"? (y/n)
回答y 并等待它完成所有辅助包的下载。
现在,要真正利用CodeCeption有很多事情要做。目前,让我们把注意力集中在验收测试上。要做到这一点,我们需要一个可以通过浏览器进行测试的应用程序。
让我们回到我们的小计算器,给它添加一个网页用户界面。
在你的项目根部创建一个web 目录,把下面的代码放在一个index.php 文件中。
<?php
require_once '../vendor/autoload.php';
session_start();
if ('post' === strtolower($_SERVER['REQUEST_METHOD'])) {
$_SESSION['numbers'][] = (int)$_POST['newNumber'];
}
$numbers = $_SESSION['numbers'] ?? [];
$numbersCollection = new NumberCollection($numbers, new Calculator());
?>
<html>
<body>
<p>Numbers entered: <b><?php echo implode(', ', $numbers); ?></b></p>
<p>Sum: <b><?php echo $numbersCollection->sum();?></b></p>
<hr/>
<form method="post">
<label for="newNumber">Enter a number between 1 and 100:</label>
<input type="number" min="1" max="100" name="newNumber" id="newNumber"/>
<input type="submit" value="Add it!"/>
</form>
</body>
</html>
现在,通过运行php -S localhost:8000 -t web ,运行内置的网络浏览器,然后,你已经有了一个漂亮的网络用户界面,在http://localhost:8000 ,它看起来应该和下面类似:

现在我们已经有了所有的东西,让我们回到我们最初的目标:建立一个验收测试。
要做到这一点,我们需要对默认配置进行一些调整。
打开文件tests/acceptance.suite.yml ,并将其编辑成这样。
# Codeception Test Suite Configuration
#
# Suite for acceptance tests.
# Perform tests in browser using the WebDriver or PhpBrowser.
# If you need both WebDriver and PHPBrowser tests - create a separate suite.
actor: AcceptanceTester
modules:
enabled:
- WebDriver:
url: http://localhost:8000
browser: chrome
- \Helper\Acceptance
step_decorators: ~
在这里,我们要求CodeCeption在一个实际的Web浏览器中运行我们的测试,所以我们需要一些辅助工具的支持:
- CodeCeption的WebDriver模块
- ChromeDriver
要安装WebDriver模块,只需运行composer require codeception/module-webdriver --dev 。
要安装ChromeDriver,首先要进入帮助->关于Chrome,检查你的Chrome版本。一旦你知道你所安装的确切版本号,请到这里下载与你的安装相匹配的版本。
准备好后,运行./chromedriver --url-base=/wd/hub --white-list-ip 127.0.0.1 ,启动Chrome驱动服务器。
我知道,我知道......仅仅运行几个测试似乎太费劲了,对吗?嗯,可能是这样,但请记住,这只是你要做的一次,然后它将使你的应用程序更容易测试,因此也更可靠。
现在是时候看一些实际的php代码了,不是吗?让我们直接开始吧!
使用这个命令来创建你的第一个基于CodeCeption的验收测试。vendor/bin/codecept g:cest acceptance First,然后打开文件tests/acceptance/FirstCest.php ,发现以下内容:
<?php
class FirstCest
{
public function _before(AcceptanceTester $I)
{
}
// tests
public function tryToTest(AcceptanceTester $I)
{
}
}
它看起来不大,不是吗?忍耐一下吧,神奇的事情就要开始了。
在这种情况下,我们可能想测试的一件事是用户输入一个数字并得到预期结果的能力,所以让我们来写这个确切的测试。
编辑FirstCest 类的方法tryToTest ,使其看起来像这样:
public function tryToTest(AcceptanceTester $I)
{
$I->amOnPage('index.php');
$I->amGoingTo('put a new number into the collection');
$I->see('Numbers entered:');
$I->see('Sum:');
$newNumber = rand(1, 100);
$I->fillField('newNumber', $newNumber);
$I->click('Add it!');
$I->wait(2);
$I->see('Numbers entered: '.$newNumber);
$I->see('Sum: '.$newNumber);
}
然后,运行vendor/bin/codecept run acceptance 。
继续;我在这里等着。
完成了吗?很好。
我想现在你会明白为什么我这么喜欢CodeCeption。如果没有,回头看看你刚才写的测试。注意它是多么清晰和容易写。它当然比它的裸露的phpUnit对应物要干净和优雅得多,对吗?
事实上,在幕后,CodeCeption使用phpUnit来实际运行测试,但它将体验提升到一个全新的水平。
测试方法论
在软件测试领域,对于如何编写测试,以及何时编写测试,有很多方法。
让我们快速浏览一下两个最流行的方法。
PHP中的TDD
简称TDD,代表测试驱动开发。这里的意思是在写实际代码之前先写测试。听起来很奇怪,不是吗?你怎么会知道在写代码之前要测试什么?这正是这个想法。这是关于编写通过测试所需的最小代码。
如果测试足够好,通过测试本身就应该证明代码符合其功能要求,而且没有多余的代码。
在工具方面,除了我们已经讨论过的,其实没有什么可以补充的。通常情况下,phpUnit是这类测试的首选工具;唯一改变的是执行顺序。
PHP中的BDD
BDD当然是一种思考软件测试的不同方式。BDD的想法与TDD有些相似,它基于以下的循环
- 测试
- 编写一些运行代码
- 调整
- 返回到1
然而,测试的编写方式是完全不同的。事实上,测试应该用一种既能被开发人员又能被业务人员理解的语言来写(即例子)。有一种专门为此目的而设计的语言;它被称为小黄瓜。
让我们看一个快速的例子,看看这在我们的计算器应用中意味着什么:
Feature: Numbers collection
In order to calculate the sum of a series of numbers
As a user
I need to be able to input numbers
Rules:
- Numbers should be integers between 1 and 100
Scenario: Input the first number
Given the number series is empty
When I enter 2
Then The number series should contain only 2
And The sum should be 2
Scenario: Input the second number
Given the number series contains 5
When I enter 10
Then The number series should contain 5 and 10
And The sum should be 15
为了对这个定义做一些事情,我们需要把Behat带进来。像往常一样,我们将依靠Composer来帮助完成这项任务。
发布命令composer require --dev behat/behat 。然后,我们需要用命令vendor/bin/behat --init 来初始化测试套件。这个命令将创建Behat运行所需的基本结构。其中最重要的部分是创建features 目录,我们的特征描述将存放在这里。
所以,很自然地,下一步就是把我们写的小黄瓜文本保存到一个.feature 文件。在我们的例子中,让我们把它称为number_collection.feature 。
好了,我们准备动手了。运行vendor/bin/behat --append-snippets ,你会看到Behat如何解释你的功能,识别出两个场景和八个步骤。
由于这是你第一次在这个项目上运行Behat,所以前面有相当多的工作要做。毕竟,文本定义看起来很好,但当涉及到让计算机对照现实检查时,恐怕人工智能还没有发展到那一步。我们将不得不通过填补空白来帮助它。
最终,你应该得到一个features/bootstrap/FeatureContext.php ,看起来像这样的文件:
<?php
use Behat\Behat\Context\Context;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;
/**
* Defines application features from the specific context.
*/
class FeatureContext implements Context
{
/**
* Initializes context.
*
* Every scenario gets its own context instance.
* You can also pass arbitrary arguments to the
* context constructor through behat.yml.
*/
public function __construct()
{
}
/**
* @Given the number series is empty
*/
public function theNumberSeriesIsEmpty()
{
throw new PendingException();
}
/**
* @When I enter :arg1
*/
public function iEnter($arg1)
{
throw new PendingException();
}
/**
* @Then The number series should contain only :arg1
*/
public function theNumberSeriesShouldContainOnly($arg1)
{
throw new PendingException();
}
/**
* @Then The sum should be :arg1
*/
public function theSumShouldBe($arg1)
{
throw new PendingException();
}
/**
* @Given the number series contains :arg1
*/
public function theNumberSeriesContains($arg1)
{
throw new PendingException();
}
/**
* @Then The number series should contain :arg1 and :arg2
*/
public function theNumberSeriesShouldContainAnd($arg1, $arg2)
{
throw new PendingException();
}
}
花点时间看一下这个文件。
你应该注意到,在你用Gherkin创建的文本定义和Behat创建的方法名称之间有一个清晰的映射关系。当然,这并不是巧合。此外,看看方法名上面的注释。那里所发生的是Gherkin定义和使其可执行的代码之间精确映射的声明。
看起来不错,不是吗?
只是有一个小问题。这段代码本身并没有什么实际作用。这里缺少的是对测试执行的上下文的设置。基本上,你必须在@Given 注释所确定的方法中初始化以后测试所需的对象,在那些用@When 注释的方法中对它们所做的改变,以及最后,验证@Then 注释所表达的期望所需的断言。
让我们看一下完整的例子,以了解清楚:
<?php
declare(strict_types=1);
use Behat\Behat\Context\Context;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;
use NumberCollection;
use PHPUnit\Framework\Assert;
/**
* Defines application features from the specific context.
*/
class FeatureContext implements Context
{
private NumberCollection $numberCollection;
/**
* Initializes context.
*
* Every scenario gets its own context instance.
* You can also pass arbitrary arguments to the
* context constructor through behat.yml.
*/
public function __construct()
{
$this->numberCollection = new NumberCollection([], new Calculator());
}
/**
* @Given the number series is empty
*/
public function theNumberSeriesIsEmpty()
{
}
/**
* @When I enter :arg1
*/
public function iEnter(int $arg1)
{
$this->numberCollection->append($arg1);
}
/**
* @Then The number series should contain only :arg1
*/
public function theNumberSeriesShouldContainOnly(int $arg1)
{
$numbers = $this->numberCollection->getNumbers();
Assert::assertContains($arg1, $numbers);
Assert::assertCount(1, $numbers);
}
/**
* @Then The sum should be :arg1
*/
public function theSumShouldBe(int $arg1)
{
Assert::assertEquals($arg1, $this->numberCollection->sum());
}
/**
* @Given the number series contains :arg1
*/
public function theNumberSeriesContains(int $arg1)
{
$this->numberCollection->append($arg1);
}
/**
* @Then The number series should contain :arg1 and :arg2
*/
public function theNumberSeriesShouldContainAnd(int $arg1, int $arg2)
{
Assert::assertContains($arg1, $this->numberCollection->getNumbers());
Assert::assertContains($arg2, $this->numberCollection->getNumbers());
}
}
不要被这里使用phpUnit断言的事实所迷惑,任何其他的断言库都可以很好地工作。
Behat很好,但它不是唯一的一个。事实上,CodeCeption也支持Gherkin。
其他一些有趣的基于PHP的测试工具
在本节中,我将快速提到一些我还没有机会亲自试用但看起来很有前途的工具。
如果你想检查的话,你可以从GitHub上获得完整的例子。
总结
正如你所看到的,在PHP测试领域有很多事情在进行。此外,还有很多人在努力挑战软件质量的极限。
如果你还没有使用这些奇妙的工具,现在是时候开始了。此外,当你在使用时,如果你也能拿起一个静态分析工具,那就更好了。
专业的PHP开发人员的时代已经到来,不要被落下。