## **简介**
在现代软件开发流程中,测试驱动开发、持续交付、持续集成这些概念中都将测试作为开发流程的有机组成部分,要求我们在软件开发的一开始,就要设计好相关的测试方法,从而让代码更加易于扩展、迭代和维护。
说到测试,常见的测试主要包括单元测试和功能测试。
* 单元测试是一种通过编写测试代码来确认函数、类和方法是否以我们预期的方式来工作,单元测试会贯穿整个项目的开发周期。通过检查各个函数和方法的输入输出,就可以保证代码内部的逻辑已经正确执行,PHPUnit 就是最著名的单元测试框架。
* 功能测试是通过使用工具来生成自动化的测试用例,然后在真实的系统上运行,而不是单元测试中简单的验证单个模块的正确性。这些工具会使用有代表性的真实数据来模拟真实用户的行为从而验证系统的正确性,常见的测试工具有[Selenium](https://www.seleniumhq.org/),用于浏览器功能测试的[Laravel Dusk](https://xueyuanjun.com/post/19543.html)就是基于 Selenium 实现的。
我们先来介绍单元测试。
## **PHPUnit简介和安装配置**
在 PHP 语言中,最著名的单元测试框架就是 PHPUnit 了,下面我们将以 PHPUnit 为例,演示如何在 PHP 项目中进行单元测试。
PHPUnit 目前有很多支持的版本,并且随着 PHP 版本的不同,功能也不尽相同,在选择版本时要注意与系统 PHP 版本的兼容性:
| 主版本 | PHP版本兼容性 | 发布时间 | 支持期限 |
| --- | --- | --- | --- |
| PHPUnit 8 | PHP 7.2、7.3、7.4 | 2019年2月1日 | 2021年2月5日 |
| PHPUnit 7 | PHP 7.1、7.2、7.3 | 2018年2月2日 | 2020年2月7日 |
| PHPUnit 6 | PHP 7.0、7.1、7.2 | 2017年2月3日 | 2019年2月1日 |
| PHPUnit 5 | PHP 5.6、7.0、7.1 | 2015年10月2日 | 2018年2月2日 |
版本最新的是 PHPUnit 8,Laravel 5.8 目前底层默认使用的还是 PHPUnit 7,但你可以选择升级到 PHPUnit 8。关于各个版本的功能差异可以在[官方文档](https://phpunit.de/)上查看。
在 Laravel 项目中,PHPUnit 已经开箱支持了,如果是在其它项目中使用,建议通过 Composer 进行安装:
~~~
composer require --dev phpunit/phpunit ^7
//由于不会在线上环境进行测试,所以加上了`--dev`选项表示仅在本地安装
~~~
这里我们以自己新建的空项目为例,首先通过`composer init`命令初始化`composer.json`文件,然后通过上述命令安装 PHPUnit。
接下来,在项目目录下创建一个新的子目录来存放测试代码,仿照 Laravel 框架将其命名为`tests`,接着在`tests`目录下创建子目录`Unit`用于存放单元测试代码(Laravel的`tests`目录中包含的`Unit`和`Feature`子目录下存放的测试用例分别用于单元测试和功能测试,二者都是基于 PHPUnit 实现,对应的测试用例的根类都是`PHPUnit\Framework\TestCase`)。
然后在项目根目录下创建一个`phpunit.xml`文件用于编排和初始化 PHPUnit 的测试行为,PHPUnit 在执行测试之前会基于这个文件进行初始化设置,你可以将其看作是 PHPUnit 的配置文件,下面我们就从这个文件为入口,分析 Laravel 框架如何集成 PHPUnit 进行单元测试和功能测试。
Laravel 框架已经为我们做好了如下初始化设置:
~~~
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
bootstrap="vendor/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">./app</directory>
</whitelist>
</filter>
<php>
<server name="APP_ENV" value="testing"/>
<server name="BCRYPT_ROUNDS" value="4"/>
<server name="CACHE_DRIVER" value="array"/>
<server name="MAIL_DRIVER" value="array"/>
<server name="QUEUE_CONNECTION" value="sync"/>
<server name="SESSION_DRIVER" value="array"/>
</php>
</phpunit>
~~~
该文件的第一行是 XML 文件的版本和编码描述信息,从第二行开始的`<phpunit>`元素则正式开始配置 PHPUnit 的核心功能,在该元素里面还嵌套定义了其它子元素,用于配置测试套件、过滤器、PHP 变量等其它信息。
### **通用配置**
`phpunit`元素上的属性属 通用配置,很多都可以在执行`phpunit`命令时通过命令行参数的形式传入,但是如果参数太多,且每次传入参数都是一样的,显然配置到`phpunit.xml`中更方便,也更加易于维护,可通过`phpunit --help`在命令行查看:
* `backupGlobals`属性对应命令行参数里的`--globals-backup`,用于在每个测试中备份和恢复 PHP 超全局变量`$GLOBALS`,这里设置为`false`表示不做相应的备份和恢复操作;
* `backupStaticAttributes`属性对应命令行参数里的`static-backup`,用于在每个测试中备份和恢复静态属性,这里设置为`false`表示不做相应的备份和恢复操作;
* `bootstrap`属性对应命令行参数里面的`--bootstrap <file>`,用于指定测试运行前需要引入的文件,这里配置为`vendor/autoload.php`表示会引入 Composer 自动加载和管理的所有依赖,以便在测试文件中使用;
* `colors`属性对应命令行参数里的`--colors=<flag>`,用于指示在输出中是否用颜色进行标识;
* `processIsolation`属性对应命令行参数里的`--process-isolation`,用于表示是否在隔离的 PHP 进程中执行测试;
* `stopOnFailure`属性对应命令行参数里的`--stop-on-failure`,用于表示测试出错或失败时是否退出脚本执行,配置为`false`表示不退出;
接下来是一些不能通过命令行参数指定的属性:
* `convertErrorsToExceptions`属性用于定义是否将 PHP ERROR 级别错误转化为异常,默认会转化为异常的错误类型包括:`E_WARNING`、`E_NOTICE`、`E_USER_ERROR`、`E_USER_WARNING`、`E_USER_NOTICE`、`E_STRICT`、`E_RECOVERABLE_ERROR`、`E_DEPRECATED`、`E_USER_DEPRECATED`,这里将该属性设置为`true`表示启用该功能。
* `convertNoticesToExceptions`属性用于定义是否将 PHP NOTICE 级别错误转化为异常,设置为`true`表示会将`E_NOTICE`、`E_USER_NOTICE`、`E_STRICT`三种级别错误转化为异常。
* `convertWarningsToExceptions`属性用于定义是否将 PHP WARNING 级别错误转化为异常,设置为`true`表示会将`E_WARNING`或`E_USER_WARNING`级别错误转化为异常。
当然,这里只包含了 PHPUnit 所支持的`phpunit`配置的一部分属性,更多配置请参考[官方文档](https://phpunit.readthedocs.io/zh_CN/latest/configuration.html#phpunit)及[PHPUnit 命令行参数配置](https://phpunit.readthedocs.io/en/8.0/textui.html#textui-clioptions)。
### **测试套件**
它们定义在子元素`<testsuites>`中,你可以像 Laravel 框架这样通过`<testsuites>`配置单个/多个`<testsuite>`,这取决于项目的复杂度或者你的需求。
Laravel 框架默认通过`<testsuites>`定义了两个`<testsuite>`,分别是用于单元测试的`Unit`和用于功能的测试的`Feature`,在它们各自的测试套件中,通过`directory`子元素指定对应测试文件所在的目录,并通过`suffix`属性指定测试文件的文件名后缀,这样,当运行`phpunit`命令时,PHPUnit 会从指定目录下匹配指定后缀的测试文件进行测试。
在运行`phpunit`命令时,我们可以通过相应测试套件的名称匹配要执行的测试用例:
~~~
./vendor/bin/phpunit --testsuite=Unit
~~~
更多测试套件的配置选项可以参考[官方文档](https://phpunit.readthedocs.io/zh_CN/latest/configuration.html#appendixes-configuration-testsuites)。
### **过滤器**
Laravel 框架还通过`<filter>`元素配置了过滤器,在该元素中我们可以通过`whitelist`子元素指定用于配置代码覆盖率报告分析所使用的白名单,代码覆盖率是代码测试中一个很重要的概念,我们的测试代码要尽可能覆盖到 100% 的业务代码,这样的测试才有意义,而 Laravel 应用代码都位于项目根目录下的`app`目录中,并且我们只测试 PHP 代码,所以在`<whitelist>`中通过`directory`子元素做了相应的配置。
这样,我们在运行`phpunit`时加上`--coverage-html .`参数,就可以在根目录下生成 HTML 格式的测试覆盖率报告文档了:
![](https://img.kancloud.cn/a3/dc/a3dcfcdbce73e42fb8f3bcc52e84c07e_2860x1070.jpg)
### **PHP变量**
Laravel 框架还通过`<php>`元素为我们初始化了一些 PHPUnit 测试环境下的 PHP 常量,上例中配置相当于以下PHP代码:
~~~
$_SERVER['APP_ENV'] = 'testing';
$_SERVER['BCRYPT_ROUNDS'] = '4';
$_SERVER['CACHE_DRIVER'] = 'array';
$_SERVER['MAIL_DRIVER'] = 'array';
$_SERVER['QUEUE_CONNECTION'] = 'sync';
$_SERVER['SESSION_DRIVER'] = 'array';
~~~
通过上述配置我们可以得知,在 Laravel 测试环境下,`APP_ENV`的值是`testing`,因此,我们可以在根目录下创建一个`.env.testing`文件作为测试环境下的环境配置文件,运行`phpunit`时实际执行的是控制台应用的 Kernel 来启动应用,这样,系统就会通过`.env.testing`读取环境配置。
缓存、邮件、会话驱动都是通过数组模拟,因而不会持久化到硬盘,此外队列驱动是`sync`,表示会同步执行推送到队列的任务。
除此之外,还可以初始化 PHP 请求、常量、INI 设置、Cookie、超全局变量等信息,更多使用明细请参考[官方文档](https://phpunit.readthedocs.io/zh_CN/latest/configuration.html#php-ini)。
## **编写第一个PHPUnit测试用例**
在编写测试用例之前,我们需要了解关于编写单元测试的一些常见约定:
* 测试文件名需要以`Test`作为后缀,比如如果要测试`First.php`,则对应的测试文件名为`FirstTest.php`;
* 测试方法名需要以`test`作为前缀,比如如果要测试的方法名为`getuser`,则对应的测试方法名为`testGetuser`,此外,你还可以通过`@test`注解来声明一个测试方法;
* 所有的测试方法可见性必须是`public`;
* 所有的测试类都继承自`PHPUnit\Framework\TestCase`。
### **基于PHPUnit编写单元测试**
了解了这些约定之后,就可以编写第一个单元测试用例了——邮件测试用例。
首先我们来编写一个应用类`Email`:如果当前是空项目,在项目根目录下创建一个`app`目录,并将`Email.php`保存在该目录下,然后编写`Email`类代码如下:
~~~
<?php
namespace App;
final class Email
{
private $email;
private function __construct(string $email)
{
$this->isValidEmail($email);
$this->email = $email;
}
public static function fromString(string $email): self
{
return new self($email);
}
public function __toString(): string
{
return $this->email;
}
private function isValidEmail(string $email): void
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException(
sprintf(
'"%s" is not a valid email address',
$email
));
}
}
}
~~~
然后,我们在`composer.json`中配置`autoload`选项通过 PSR-4 规则加载`app`目录下的类文件:
~~~
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
~~~
以便能够通过命名空间加载到`app`目录下的类。配置完成后,运行`composer dump-auto`命令重新生成自动加载文件以便`Email`类可以被正常加载到。
接下来,我们在`tests/Unit`目录下创建一个新的测试用例`EmailTest.php`,用来测试刚刚编写的`Email`类:
~~~
<?php
namespace Unit\Test;
use PHPUnit\Framework\TestCase;
use App\Email;
class EmailTest extends TestCase
{
public function testCanBeCreatedFromValidEmailAddress(): void
{
$this->assertInstanceOf(
Email::class,
Email::fromString('user@example.com')
);
}
public function testCannotBeCreatedFromInvalidEmailAddress(): void
{
$this->expectException(\InvalidArgumentException::class);
Email::fromString('invalid');
}
public function testCanBeUsedAsString(): void
{
$this->assertEquals(
'user@example.com',
Email::fromString('user@example.com')
);
}
}
~~~
在上述代码中,第一个测试方法使用了基类提供的断言方法`assertInstanceOf`判断`Email::fromString`方法返回的是否是`Email`实例;第二个测试方法中使用了`expectException`判断传入无效的邮件地址是否抛出指定异常;第三个测试方法则使用`assertEquals`判断`Email::fromString`打印结果是否与给定字符串相等。
#### **对变量进行测试**
PHPUnit 底层提供了很多断言方法用于对变量进行测试,这些变量通常是业务代码类方法或函数的返回值,我们在`Unit\ExampleTest`中新增一个`testVariables`方法:
~~~
public function testVariables()
{
$bool = false;
$number = 100;
$arr = ['Laravel', 'PHP', '学院君'];
$obj = null;
// 断言变量值是否为假,与 assertTrue 相对
$this->assertFalse($bool);
// 断言给定变量值是否与期望值相等,与 assertNotEquals 相对
$this->assertEquals(100, $number);
// 断言数组中是否包含给定值,与 assertNotContains 相对
$this->assertContains('学院君', $arr);
// 断言数组长度是否与期望值相等,与 assertNotCount 相对
$this->assertCount(3, $arr);
// 断言数组是否不会空,与 assertEmpty 相对
$this->assertNotEmpty($arr);
// 断言变量是否为 null,与 assertNotNull 相对
$this->assertNull($obj);
}
~~~
相应的断言用途在注释中已经说明了,我们可以对各种类型的变量从各种维度进行断言,甚至还可以对文件、目录、正则表达式进行断言,并且很多断言都可以从正反两个方法进行,相关的调用都很简单,你可以在需要的时候查看官方文档选择相应的断言方法:[https://phpunit.readthedocs.io/zh\_CN/latest/assertions.html](https://phpunit.readthedocs.io/zh_CN/latest/assertions.html)。
#### **对输出进行测试**
通过 PHPUnit 提供的`expectOutputString`方法来对页面输出进行测试:
~~~
public function testOutput()
{
$this->expectOutputString('学院君');
$this->expectOutputRegex('/Laravel/i');
echo '学院君';
}
~~~
#### **对异常进行测试**
类似的,还可以通过`expectException`方法对异常进行测试,为了让测试用例更加符合真实场景,我们在`app`目录下新增一个`Services`子目录,然后在该子目录下创建一个`TestService`类并初始化代码如下:
~~~
<?php
namespace App\Services;
class TestService
{
public function invalidArgument()
{
throw new \InvalidArgumentException('无效的参数');
}
}
~~~
然后回到`Unit\ExampleTest`,编写一个新的测试用例如下:
~~~
public function testException()
{
$this->expectException(\InvalidArgumentException::class);
$service = new TestService();
$service->invalidArgument();
}
~~~
除此之外,还可以进一步对异常明细进行测试,比如通过`expectExceptionCode()`、`expectExceptionMessage()`和`expectExceptionMessageRegExp()`方法可以用于测试异常码、异常信息。
除了通过上述方法,还可以通过注解对异常进行测试,这种方式更加方便:
~~~
/**
* @expectedException \InvalidArgumentException
*/
public function testExceptionAnnotation()
{
$this->service->invalidArgument();
}
~~~
#### **测试的依赖关系**
有的时候,我们需要测试的两个用例之间可能有依赖关系,比如我们在`TestService`定义如下个方法:
~~~
protected $stack = [];
public function init()
{
$this->stack = ['学院君', 'Laravel学院', '单元测试'];
}
public function stackContains($value)
{
return in_array($value, $this->stack);
}
public function getStackSize()
{
return count($this->stack);
}
~~~
我们在测试`stackContains`方法时,往往要先调用`init`方法,但是在单元测试中,每个方法都有独立的测试用例,如果多次调用有可能会对数据造成污染,那我们能否在`init`方法测试用例的运行基础上运行`stackContains`方法的测试用例呢?这个时候,我们说这两个测试用例之间是具有依赖关系的,PHPUnit 中通过`@depends`注解对这种依赖关系进行了支持,我们可以在`Unit\ExampleTest`中编写测试用例如下:
~~~
public function testInitStack()
{
$this->service->init();
$this->assertEquals(3, $this->service->getStackSize());
return $this->service;
}
/**
* @depends testInitStack
* @param TestService $service
*/
public function testStackContains(TestService $service)
{
$contains = $service->stackContains('学院君');
$this->assertTrue($contains);
}
~~~
在`testStackContains`用例中,我们将`testInitStack`用例返回的`$service`实例传递进来,并在此基础上进行测试。
#### **数据提供器**
除了支持测试用例之间的依赖之外,PHPUnit 还可以通过`@dataProvider`注解为多个测试用例提供初始化数据:
~~~
public function initDataProvider()
{
return [
['学院君'],
['Laravel学院']
];
}
/**
* @depends testInitStack
* @dataProvider initDataProvider
*/
public function testIsStackContains()
{
$arguments = func_get_args();
$service = $arguments[1];
$value = $arguments[0];
$this->assertTrue($service->stackContains($value));
}
~~~
在这个测试用例中,我们通过`initDataProvider`方法作为数据提供器,数据供给器方法必须声明为`public`,其返回值要么是一个数组,其每个元素也是数组;要么是一个实现了`Iterator`接口的对象,在对它进行迭代时每步产生一个数组。每个数组都是测试数据集的一部分,将以它的内容作为参数来调用测试方法。
然后我们在需要用到这个数据提供器的测试用例上用`@dataProvider`注解进行声明,在这个示例中我们迭代数据提供器数组,将其中的数据作为参数传入`TestService`的`stackContains`方法以判断对应值在`stack`属性中是否存在。
### **Laravel基于PHPUnit实现功能测试**
Laravel 框架开箱为我们提供了一个功能测试用例示例`tests/Feature/ExampleTest.php`:
~~~
<?php
namespace Tests\Feature;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*
* @return void
*/
public function testBasicTest()
{
$response = $this->get('/');
$response->assertStatus(200);
}
}
~~~
功能测试类都位于`tests/Feature`目录下,和单元测试类一样,也继承自`Tests\TestCase`,从根源上都继承自`PHPUnit\Framework\TestCase`,只不过功能测试用到的很多方法都是 Laravel 自行封装实现的【**单元测试基本上使用的是PHPUnit框架提供的原生方法**】,这些实现都是通过独立的 Trait 来完成,在`Illuminate\Foundation\Testing\TestCase`中,可以看到这些 Trait 的引入:
~~~
use Concerns\InteractsWithContainer,
Concerns\MakesHttpRequests,
Concerns\InteractsWithAuthentication,
Concerns\InteractsWithConsole,
Concerns\InteractsWithDatabase,
Concerns\InteractsWithExceptionHandling,
Concerns\InteractsWithSession,
Concerns\MocksApplicationServices;
~~~
比如请求相关的测试方法都位于`Illuminate\Foundation\Testing\Concerns\MakesHttpRequests`中,认证相关的测试方法都位于`Illuminate\Foundation\Testing\Concerns\InteractsWithAuthentication`中,会话相关的测试方法都位于`Illuminate\Foundation\Testing\Concerns\InteractsWithSession`中,而响应相关的测试方法都位于`Illuminate\Foundation\Testing\TestResponse`中,该实例会在调用 HTTP 功能测试类中调用`$this->get`方法时返回(当然,还支持`post`、`put`、`delete`、`getJson`等类似方法,这些方法都定义在`Illuminate\Foundation\Testing\Concerns\MakesHttpRequests`中)。
#### **基本测试**
如 Laravel 提供的示例代码所示,我们可以从最简单的测试开始,测试响应的状态码,与之等价的,我们还可以通过`assertOk`方法断言响应状态码是否是 200:
~~~
public function testBasic()
{
$response = $this->get('/');
$response->assertOk(); // 返回状态码是否是 200
}
// `assertSuccessful`:断言响应状态码是否介于200-300之间;
// `assertNotFound`:断言响应状态码是否是 404;
// `assertForbidden`:断言响应状态码是否是 403;
~~~
此外,我们还可以通过`assertSee`或`assertSeeText`方法断言响应实体中是否包含给定字符串:
~~~
public function testSeeText()
{
$response = $this->get('/');
$response->assertSee('Laravel');
//区别是下者会将响应实体转化为纯文本进行判断
$response->assertSeeText('Laravel');
}
~~~
与之相对的,还有`assertDontSee`和`assertDontSeeText`方法,与上述判断相反,断言响应实体中不包含给定字符串。与之类似的,还有`assertSeeInOrder`以及`assertSeeTextInOrder`方法,用于断言给定字符串是否按照对应的顺序出现在响应实体中。
#### **测试重定向**
我们可以通过`assertRedirect`对重定向响应进行测试,断言重定向指向的 URL 是否与预期一致:
~~~
public function testRedirection()
{
$response = $this->get('/redirect');
$response->assertRedirect('https://xueyuanjun.com');
}
~~~
要测试这个重定向响应,我们需要确保在`routes/web.php`中包含如下路由定义:
~~~
Route::get('/redirect', function () {
return redirect('https://xueyuanjun.com');
});
~~~
此外,还有一个与之类似的方法`assertLocation`也可以用于断言重定向 URL,与`assertRedirect`不同之处在于,它不会对响应状态码和响应头进行判断,`assertRedirect`会先断言响应状态码是否在`[201, 301, 302, 303, 307, 308]`数组中并且响应头中包含`Location`字段。
#### **测试响应头**
如果你想要对响应头进行深入测试,可以通过`assertHeader`方法实现:
~~~
public function testHeader()
{
$response = $this->get('/header');
$response->assertHeader('X-Header-One', 'Laravel学院')
->assertHeader('X-Header-Two', 'HTTP 功能测试');
}
~~~
为了让上述测试用例通过,我们还要在`routes/web.php`中定义如下路由:
~~~
Route::get('/header', function (){
return response('测试响应头')
->header('X-Header-One', 'Laravel学院')
->header('X-Header-Two', 'HTTP 功能测试');
});
~~~
#### **测试 Cookie**
Laravel 功能测试为 Cookie 测试提供了多个相关断言方法。要测试响应中是否包含给定 Cookie 且与指定值匹配,可以通过`assertCookie`方法实现:
~~~
public function testCookie()
{
$response = $this->get('/cookie');
$response->assertCookie('UserName', '学院君');
}
~~~
相应的,我们在`routes/web.php`中新增如下路由:
~~~
Route::get('/cookie', function (){
return response('测试 Cookie')->cookie('UserName', '学院君');
});
// `assertCookieExpired`:断言给定 Cookie 是否过期;
// `assertCookieNotExpired`:断言给定 Cookie 没有过期;
// `assertCookieMissing`:断言给定 Cookie 不存在;
// `assertPlainCookie`:断言给定 Cookie 存在且与给定值匹配(不加密)。
~~~
#### **测试 Session**
为了测试 HTTP Session,我们先在`routes/web.php`中定义一个新的路由:
~~~
Route::get('/session', function (){
session(['SiteName' => 'Laravel学院']);
session(['UserName' => '学院君']);
return response('测试 Session');
});
~~~
然后为这个路由编写测试用例:
~~~
public function testSession()
{
$response = $this->get('/session');
$response->assertSessionHas('SiteName', 'Laravel学院')
->assertSessionHas('UserName')
->assertSessionMissing('AppName');
// 一次性指定包含的 Session
$response->assertSessionHasAll(['SiteName' => 'Laravel学院', 'UserName' => '学院君']);
}
~~~
我们可以通过`assertSessionHas`方法依次断言每个 Session 存储项,也可以通过`assertSessionHasAll`方法一次性断言多个 Session 存储项,在使用它们的时候,可以指定对应的 Session 值,也可以不指定,如果指定的话则必须与存储的 Session 之匹配才会测试通过。
另外,我们可以通过`assertSessionMissing`方法断言指定 Session 存储项不存在。
## **运行测试用例**
我们可以通过运行`phpunit`对刚刚编写的代码进行测试:
~~~
./vendor/bin/phpunit
./vendor/bin/phpunit tests/Unit/EmailTest.php //指定测试用例
~~~
通过编排文件`phpunit.xml`,PHPUnit 会去`tests/Unit`目录中查找测试用例进行测试,测试通过则显示绿色的高亮文本,测试不通过则显示红色的警告文本。