测试
CodeIgniter 的设计初衷是简化框架与应用的测试流程。框架内置支持 PHPUnit,并提供了一系列便捷的辅助方法,确保应用的全方位测试过程轻松高效。
系统设置
安装 PHPUnit
CodeIgniter 的所有测试均基于 PHPUnit。在系统中安装 PHPUnit 共有两种方式。
Composer
推荐使用 Composer 在项目内安装。虽然支持全局安装,但不建议这样做,以免日后与其他项目产生兼容性冲突。
请确保系统中已安装 Composer。在项目根目录(包含 application 和 system 目录的路径)下,在命令行输入以下命令:
composer require --dev phpunit/phpunit
此时将安装与当前 PHP 版本匹配的 PHPUnit。安装完成后,运行以下命令即可执行项目的所有测试:
vendor/bin/phpunit
Windows 用户请使用以下命令:
vendor\bin\phpunit
Phar
另一种选择是从 PHPUnit 官网下载 .phar 文件。这是一个独立文件,放置在项目根目录下即可。
测试应用
PHPUnit 配置
项目根目录下的 phpunit.xml.dist 文件用于控制单元测试。如果自行配置了 phpunit.xml,则会覆盖该默认配置。
默认情况下,测试文件位于项目根目录的 tests 目录下。
测试类
若要使用框架提供的增强工具,测试类必须继承 CodeIgniter\Test\CIUnitTestCase。
测试文件的存放路径并无强制限制。建议提前制定存放规则,以便快速定位测试文件。
在本文档中,app 目录下的类对应的测试文件均存放在 tests/app 目录。例如,若要测试 app/Libraries/Foo.php,应在 tests/app/Libraries/FooTest.php 创建测试文件:
<?php
namespace App\Libraries;
use CodeIgniter\Test\CIUnitTestCase;
class FooTest extends CIUnitTestCase
{
public function testFooNotBar()
{
// ...
}
}
若要测试模型 app/Models/UserModel.php,其测试文件 tests/app/Models/UserModelTest.php 可能如下所示:
<?php
namespace App\Models;
use CodeIgniter\Test\CIUnitTestCase;
class UserModelTest extends CIUnitTestCase
{
public function testFooNotBar()
{
// ...
}
}
可根据测试风格或需求自由创建目录结构。为测试类设置命名空间时,由于 app 目录是 App 命名空间的根路径,所使用的类必须拥有相对于 App 的正确命名空间。
备注
虽然不强制要求为测试类设置命名空间,但这有助于确保类名不发生冲突。
测试数据库结果时,必须在类中使用 DatabaseTestTrait。
准备工作
大多数测试在运行前都需要进行准备。PHPUnit 的 TestCase 提供了四个方法用于环境准备与清理:
public static function setUpBeforeClass(): void
public static function tearDownAfterClass(): void
protected function setUp(): void
protected function tearDown(): void
静态方法 setUpBeforeClass() 和 tearDownAfterClass() 在整个测试用例执行前后运行;而受保护方法 setUp() 和 tearDown() 则在每个测试方法运行间隙执行。
如果实现这些特殊函数,务必同时调用父类方法,以免影响继承类中的环境准备:
<?php
namespace App\Models;
use CodeIgniter\Test\CIUnitTestCase;
final class UserModelTest extends CIUnitTestCase
{
protected function setUp(): void
{
parent::setUp(); // Do not forget
helper('text');
}
// ...
}
Trait
常用的一种增强测试方式是使用 Trait 来整合不同测试用例的准备工作。CIUnitTestCase 会自动检测类中的 Trait,并尝试运行与 Trait 同名的准备和清理方法(即 setUp{NameOfTrait}() 和 tearDown{NameOfTrait}())。
例如,若需为部分测试用例添加身份验证,可以创建一个验证 Trait,并利用其中的 setUp 方法模拟用户登录:
<?php
namespace App\Traits;
trait AuthTrait
{
protected function setUpAuthTrait()
{
$user = $this->createFakeUser();
$this->logInUser($user);
}
// ...
}
<?php
namespace Tests;
use App\Traits\AuthTrait;
use CodeIgniter\Test\CIUnitTestCase;
final class AuthenticationFeatureTest extends CIUnitTestCase
{
use AuthTrait;
// ...
}
额外断言
CIUnitTestCase 提供了以下实用的单元测试断言。
assertLogged($level, $expectedMessage)
确保预期的日志信息确实已记录:
assertLogContains($level, $logMessage)
确保日志记录中包含特定的消息片段。
<?php
$config = new \Config\Logger();
$logger = new \CodeIgniter\Log\Logger($config);
// check verbatim the log message
$logger->log('error', "That's no moon");
$this->assertLogged('error', "That's no moon");
// check that a portion of the message is found in the logs
$exception = new \RuntimeException('Hello world.');
$logger->log('error', $exception->getTraceAsString());
$this->assertLogContains('error', '{main}');
assertEventTriggered($eventName)
确保预期的事件确实已触发:
<?php
use CodeIgniter\Events\Events;
Events::on('foo', static function ($arg) use (&$result) {
$result = $arg;
});
Events::trigger('foo', 'bar');
$this->assertEventTriggered('foo');
assertHeaderEmitted($header, $ignoreCase = false)
确保确实发送了特定的 HTTP 标头或 Cookie:
<?php
$response->setCookie('foo', 'bar');
ob_start();
$this->response->send();
$output = ob_get_clean(); // in case you want to check the actual body
$this->assertHeaderEmitted('Set-Cookie: foo=bar');
备注
使用此断言的测试用例应作为独立进程运行 (使用 PHPUnit 中的 @runInSeparateProcess annotation 或 RunInSeparateProcess attribute)。
assertHeaderNotEmitted($header, $ignoreCase = false)
确保未发送特定的 HTTP 标头或 Cookie:
<?php
$response->setCookie('foo', 'bar');
ob_start();
$this->response->send();
$output = ob_get_clean(); // in case you want to check the actual body
$this->assertHeaderNotEmitted('Set-Cookie: banana');
备注
包含此断言的测试用例应在 PHPUnit 中作为独立进程运行 (使用 @runInSeparateProcess annotation 或 RunInSeparateProcess attribute)。
assertCloseEnough($expected, $actual, $message = '', $tolerance = 1)
用于长时间执行的测试,验证预期时间与实际时间的绝对差值在指定误差范围内:
<?php
use CodeIgniter\Debug\Timer;
$timer = new Timer();
$timer->start('longjohn', strtotime('-11 minutes'));
$this->assertCloseEnough(11 * 60, $timer->getElapsedTime('longjohn'));
上述测试允许实际时间为 660 或 661 秒。
assertCloseEnoughString($expected, $actual, $message = '', $tolerance = 1)
用于长时间执行的测试,验证格式化为字符串的时间,其预期值与实际值的绝对差值在指定误差范围内:
<?php
use CodeIgniter\Debug\Timer;
$timer = new Timer();
$timer->start('longjohn', strtotime('-11 minutes'));
$this->assertCloseEnoughString(11 * 60, $timer->getElapsedTime('longjohn'));
上述测试允许实际时间为 660 或 661 秒。
访问私有或受保护的属性
测试时,可使用以下 Getter 和 Setter 方法访问目标类中的受保护或私有成员。
getPrivateMethodInvoker($instance, $method)
用于从类外部调用私有方法。返回一个可调用的函数。第一个参数是测试类的实例,第二个参数是待调用的方法名。
<?php
use App\Libraries\Foo;
// Create an instance of the class to test
$obj = new Foo();
// Get the invoker for the 'privateMethod' method.
$method = self::getPrivateMethodInvoker($obj, 'privateMethod');
// Test the results
$this->assertEquals('bar', $method('param1', 'param2'));
getPrivateProperty($instance, $property)
从类实例中获取私有或受保护属性的值。第一个参数是测试类的实例,第二个参数是属性名。
<?php
use App\Libraries\Foo;
// Create an instance of the class to test
$obj = new Foo();
// Test the value
$this->assertEquals('bar', $this->getPrivateProperty($obj, 'baz'));
setPrivateProperty($instance, $property, $value)
为类实例中的受保护属性设置值。第一个参数是测试类的实例,第二个参数是属性名,第三个参数是待设置的值:
<?php
use App\Libraries\Foo;
// Create an instance of the class to test
$obj = new Foo();
// Set the value
$this->setPrivateProperty($obj, 'baz', 'oops!');
// Do normal testing...
模拟服务
测试(尤其是控制器或集成测试)时,常需模拟 app/Config/Services.php 中定义的某个服务,以便在模拟服务响应的同时,将测试范围锁定在目标代码上。Services 类提供了以下简化模拟的方法。
Services::injectMock()
用于定义 Services 类返回的具体实例。可通过此方法设置服务属性以模拟特定行为,或用模拟类替换原有服务。
<?php
namespace Tests;
use CodeIgniter\HTTP\CURLRequest;
use CodeIgniter\Test\CIUnitTestCase;
use Config\Services;
final class SomeTest extends CIUnitTestCase
{
public function testSomething()
{
$curlrequest = $this->getMockBuilder(CURLRequest::class)
->onlyMethods(['request'])
->getMock();
Services::injectMock('curlrequest', $curlrequest);
// Do normal testing here....
}
}
第一个参数是待替换的服务名,必须与 Services 类中的函数名完全一致。第二个参数是替换后的实例。
Services::reset()
移除所有模拟类,使 Services 类恢复初始状态。
也可使用 CIUnitTestCase 提供的 $this->resetServices() 方法。
备注
此方法会重置 Services 的所有状态,导致 RouteCollection 中没有任何路由。如需加载路由,需手动调用 loadRoutes() 方法,例如 Services::routes()->loadRoutes()。
Services::resetSingle(string $name)
根据名称移除单个服务的模拟实例和共享实例。
备注
Cache、Email 和 Session 服务默认会被模拟,以防止干扰测试环境。若不希望模拟这些服务,需从类属性中移除相应回调:$setUpMethods = ['mockEmail', 'mockSession'];。
模拟工厂实例
与服务类似,测试期间可能需要为 Factories 提供预配置的类实例。其静态方法 Factories::injectMock() 和 Factories::reset() 与 Services 的用法相同,但在首位多了一个组件名参数:
<?php
namespace Tests;
use App\Models\UserModel;
use CodeIgniter\Config\Factories;
use CodeIgniter\Test\CIUnitTestCase;
use Tests\Support\Mock\MockUserModel;
final class SomeTest extends CIUnitTestCase
{
protected function setUp(): void
{
parent::setUp();
$model = new MockUserModel();
Factories::injectMock('models', UserModel::class, $model);
}
}
备注
所有组件工厂在每次测试间默认都会重置。若需保留实例,请修改测试用例的 $setUpMethods。
测试与时间
测试与时间相关的代码通常具有挑战性。但使用 Time 类时,可在测试期间随意固定或修改当前时间。
以下是固定当前时间的测试示例:
<?php
namespace Tests;
use CodeIgniter\I18n\Time;
use CodeIgniter\Test\CIUnitTestCase;
final class TimeDependentCodeTest extends CIUnitTestCase
{
protected function tearDown(): void
{
parent::tearDown();
// Reset the current time.
Time::setTestNow();
}
public function testFixTime(): void
{
// Fix the current time to "2023-11-25 12:00:00".
Time::setTestNow('2023-11-25 12:00:00');
// This assertion always passes.
$this->assertSame('2023-11-25 12:00:00', (string) Time::now());
}
}
使用 Time::setTestNow() 方法可固定当前时间。第二个参数是可选的区域设置。
测试结束后,务必通过无参调用重置当前时间。