测试

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 annotationRunInSeparateProcess 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 annotationRunInSeparateProcess 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)

根据名称移除单个服务的模拟实例和共享实例。

备注

CacheEmailSession 服务默认会被模拟,以防止干扰测试环境。若不希望模拟这些服务,需从类属性中移除相应回调:$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() 方法可固定当前时间。第二个参数是可选的区域设置。

测试结束后,务必通过无参调用重置当前时间。