测试

CodeIgniter 的设计目标是使测试框架和应用程序尽可能简单。它内置支持 PHPUnit,并且框架提供了一些方便的辅助方法,使测试应用程序的各个方面尽可能轻松。

系统设置

安装 PHPUnit

CodeIgniter 使用 PHPUnit 作为其所有测试的基础。有两种方法可以在系统中安装 PHPUnit。

Composer

推荐的方法是在您的项目中使用 Composer 安装它。虽然可以全局安装它,但我们不建议这样做,因为它可能会随着时间的推移而导致与系统上的其他项目产生兼容性问题。

确保您的系统上已安装 Composer。从项目根目录(包含应用程序和系统目录的目录)中,在命令行中输入以下内容

composer require --dev phpunit/phpunit

这将安装适合您当前 PHP 版本的正确版本。完成后,您可以通过输入以下命令运行此项目的所有测试

vendor/bin/phpunit

如果您使用的是 Windows,请使用以下命令

vendor\bin\phpunit

Phar

另一个选择是从 PHPUnit 网站下载 .phar 文件。这是一个独立文件,应放置在您的项目根目录中。

测试您的应用程序

PHPUnit 配置

该框架在项目根目录中有一个 phpunit.xml.dist 文件。它控制框架本身的单元测试。如果您提供自己的 phpunit.xml,它将覆盖此文件。

您的 phpunit.xml 应该排除 system 文件夹,以及任何 vendorThirdParty 文件夹,如果您正在对您的应用程序进行单元测试。

测试类

为了利用提供的额外工具,您的测试必须扩展 CIUnitTestCase。默认情况下,所有测试都应位于 tests/app 目录中。

要测试一个新的库 Foo,您需要在 tests/app/Libraries/FooTest.php 中创建一个新文件

<?php

namespace App\Libraries;

use CodeIgniter\Test\CIUnitTestCase;

class FooTest extends CIUnitTestCase
{
    public function testFooNotBar()
    {
        // ...
    }
}

要测试您的一个模型,您最终可能会在 tests/app/Models/OneOfMyModelsTest.php 中得到类似以下内容

<?php

namespace App\Models;

use CodeIgniter\Test\CIUnitTestCase;

class OneOfMyModelsTest 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 OneOfMyModelsTest extends CIUnitTestCase
{
    protected function setUp(): void
    {
        parent::setUp(); // Do not forget

        helper('text');
    }

    // ...
}

特性

增强测试的一种常见方法是使用特性来合并不同测试用例中的准备阶段。 CIUnitTestCase 将检测任何类特性并查找要运行的准备方法,这些方法以特性本身命名(即 setUp{NameOfTrait}()tearDown{NameOfTrait}())。

例如,如果您需要为某些测试用例添加身份验证,您可以创建一个身份验证特性,其中包含一个设置方法来伪造已登录的用户

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

确保已实际发出标头或 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 中作为单独的进程运行

assertHeaderNotEmitted($header, $ignoreCase = false)

确保未发出标头或 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 中作为单独的进程运行

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 秒。

访问受保护/私有属性

在测试时,您可以使用以下 setter 和 getter 方法来访问您正在测试的类中的受保护方法和私有方法以及属性。

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 = $this->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

use CodeIgniter\Test\CIUnitTestCase;
use Config\Services;

final class SomeTest extends CIUnitTestCase
{
    public function testSomething()
    {
        $curlrequest = $this->getMockBuilder('CodeIgniter\HTTP\CURLRequest')
            ->setMethods(['request'])
            ->getMock();
        Services::injectMock('curlrequest', $curlrequest);

        // Do normal testing here....
    }
}

第一个参数是您要替换的服务。名称必须与 Services 类中的函数名称完全匹配。第二个参数是要替换它的实例。

Services::reset()

从 Services 类中移除所有模拟类,使其恢复到原始状态。

您也可以使用 $this->resetServices() 方法,该方法由 CIUnitTestCase 提供。

注意

此方法重置 Services 的所有状态,并且 RouteCollection 将没有路由。如果您想使用您的路由进行加载,您需要调用 loadRoutes() 方法,例如 Services::routes()->loadRoutes()

Services::resetSingle(string $name)

通过名称移除单个服务的任何模拟和共享实例。

注意

CacheEmailSession 服务默认情况下是模拟的,以防止侵入性测试行为。要防止这些模拟,请从类属性中移除它们的方法回调:$setUpMethods = ['mockEmail', 'mockSession'];

模拟工厂实例

与 Services 类似,您可能会发现自己需要在测试期间提供一个预配置的类实例,该实例将与 Factories 一起使用。使用与 **Services** 相同的 Factories::injectMock()Factories::reset() 静态方法,但它们需要一个额外的先行参数来表示组件名称。

<?php

namespace Tests;

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', 'App\Models\UserModel', $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() 方法修复当前时间。 您可以选择将区域设置作为第二个参数指定。

不要忘记在测试后通过不带参数调用它来重置当前时间。

测试 CLI 输出

StreamFilterTrait

版本 4.3.0 中的新增功能。

StreamFilterTrait 提供了这些辅助方法的替代方案。

您可能需要测试难以测试的东西。 有时,捕获流(如 PHP 自己的 STDOUT 或 STDERR)可能会有所帮助。 StreamFilterTrait 可以帮助您捕获来自您选择的流的输出。

方法概述

  • StreamFilterTrait::getStreamFilterBuffer() 获取缓冲区中捕获的数据。

  • StreamFilterTrait::resetStreamFilterBuffer() 重置捕获的数据。

一个在您的测试用例中演示此功能的示例

<?php

use CodeIgniter\CLI\CLI;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\StreamFilterTrait;

final class SomeTest extends CIUnitTestCase
{
    use StreamFilterTrait;

    public function testSomeOutput(): void
    {
        CLI::write('first.');

        $this->assertSame("\nfirst.\n", $this->getStreamFilterBuffer());

        $this->resetStreamFilterBuffer();

        CLI::write('second.');

        $this->assertSame("second.\n", $this->getStreamFilterBuffer());
    }
}

StreamFilterTrait 具有一个自动调用的配置器。 请参阅 测试特征

如果您在测试中覆盖了 setUp()tearDown() 方法,则必须分别调用 parent::setUp()parent::tearDown() 方法来配置 StreamFilterTrait

CITestStreamFilter

CITestStreamFilter 用于手动/单次使用。

如果您只需要在一个测试中捕获流,那么您可以手动将过滤器添加到流中,而不是使用 StreamFilterTrait 特征。

方法概述

  • CITestStreamFilter::registration() 过滤器注册。

  • CITestStreamFilter::addOutputFilter() 将过滤器添加到输出流。

  • CITestStreamFilter::addErrorFilter() 将过滤器添加到错误流。

  • CITestStreamFilter::removeOutputFilter() 从输出流中移除过滤器。

  • CITestStreamFilter::removeErrorFilter() 从错误流中移除过滤器。

<?php

use CodeIgniter\CLI\CLI;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\Filters\CITestStreamFilter;

final class SomeTest extends CIUnitTestCase
{
    public function testSomeOutput(): void
    {
        CITestStreamFilter::registration();
        CITestStreamFilter::addOutputFilter();

        CLI::write('first.');

        CITestStreamFilter::removeOutputFilter();
    }
}

测试 CLI 输入

PhpStreamWrapper

版本 4.3.0 中的新增功能。

PhpStreamWrapper 提供了一种方法来编写测试,用于需要用户输入的方法,例如 CLI::prompt()CLI::wait()CLI::input()

注意

PhpStreamWrapper 是一个流包装器类。如果您不了解 PHP 的流包装器,请参阅 PHP 手册中的 streamWrapper 类

方法概述

  • PhpStreamWrapper::register()PhpStreamWrapper 注册到 php 协议。

  • PhpStreamWrapper::restore() 将 php 协议包装器恢复回 PHP 内置包装器。

  • PhpStreamWrapper::setContent() 设置输入数据。

重要

PhpStreamWrapper 仅用于测试 php://stdin。但是,当您注册它时,它会处理所有 php 协议 流,例如 php://stdoutphp://stderrphp://memory。因此,强烈建议仅在需要时注册/取消注册 PhpStreamWrapper。否则,它会在注册时干扰其他内置的 php 流。

一个在您的测试用例中演示此功能的示例

<?php

use CodeIgniter\CLI\CLI;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\PhpStreamWrapper;

final class SomeTest extends CIUnitTestCase
{
    public function testPrompt(): void
    {
        // Register the PhpStreamWrapper.
        PhpStreamWrapper::register();

        // Set the user input to 'red'. It will be provided as `php://stdin` output.
        $expected = 'red';
        PhpStreamWrapper::setContent($expected);

        $output = CLI::prompt('What is your favorite color?');

        $this->assertSame($expected, $output);

        // Restore php protocol wrapper.
        PhpStreamWrapper::restore();
    }
}