hampel/xenforo-test-framework

XenForo 单元测试框架

3.0.2 2024-08-10 22:28 UTC

This package is auto-updated.

Last update: 2024-09-10 22:44:49 UTC


README

Latest Version on Packagist Total Downloads Open Issues License

XenForo 单元测试框架

兼容性

测试框架是针对正在运行的 XenForo 版本特定的。鉴于这是一个开发工具,您只需根据您正在开发的 XenForo 版本,在您的插件中安装适当的测试框架版本。

升级

单元测试框架 v2.1

单元测试框架的 v2.1 版本中已更新了 TestCase.phpCreatesApplication.php 文件,您应在您的插件单元测试目录中编辑这些文件以合并这些更改。

特别是,在 TestCase.php 中有一个新的变量

protected $addonsToLoad = [];

... 以及一些新的 CreatesApplication.php 代码,这些代码应复制到您自己的文件版本中

$options['xf-addons'] = $this->addonsToLoad ?: [];

1. 简介

单元测试是一个过程,通过这个过程对您的软件的各个组件(单元)进行测试。目标是隔离一段代码并验证其正确性——确保它对给定的输入表现出预期的行为。

在软件开发中通常使用多个级别的测试,包括单元测试、集成测试、系统测试、验收测试等。单元测试通常是最底层的测试,目标是独立测试一小段代码。每个测试应独立运行,并且不应影响后续测试。理想情况下,单元测试应覆盖通过被测试代码的所有潜在代码路径,并验证无效输入会导致预期的失败。

单元测试通常使用设计来运行测试套件并报告每个测试成功或失败的工具执行。PHP 最受欢迎和最广泛使用的单元测试框架是 PHPUnit。学习如何在开发 PHP 应用程序时使用 PHPUnit 将使您的代码更健壮,帮助您在开发周期的早期阶段识别问题,并通常使您的代码更干净、更易于维护。

我发现单元测试最有用的事情——除了显而易见的调试过程之外——是在对代码库进行重大更改时,例如重构或升级到所使用的框架或库的新版本。在开始之前拥有一个全面的单元测试套件,可以让您快速识别不再按预期工作或不应工作的代码。

有关如何使用 PHPUnit 和单元测试的一般教程有很多——我不会在这里介绍这些。本教程的目的是解释单元测试 XenForo 插件的原理和过程。

在像 XenForo 这样的单体应用程序框架中进行测试可能会出现问题——尤其是考虑到我们不是在构建独立的应用程序,而是在扩展或添加现有应用程序的功能。

单元测试最重要的功能之一是将您的代码从周围框架中隔离出来,并用测试存根、模拟对象、伪造和测试工具替换其他代码——这允许您在测试目的下注入来自外部代码的预期行为或响应,并避免副作用。

幸运的是,XenForo v2 已经被设计成可以使我们更容易地隔离框架的各个部分并注入模拟对象。

2. XenForo 应用程序容器

在为XenForo v2进行设计时,XenForo开发团队所做的最具影响力的架构决策之一是实施应用容器模式。应用容器为我们提供了一个中央位置,从这里我们可以访问所有允许XenForo运行的子系统——无论是访问数据库、发送电子邮件、创建警报,还是做其他事情。

之所以这一点如此重要,是因为容器模式还允许我们简单地替换那些子系统,用我们自己的系统来模拟或模拟我们为测试目的想要看到的行为。

例如,我们不必真的发送电子邮件——我们可以用模拟对象替换邮件处理类,这些模拟对象假装做同样的事情,但实际上不会发送电子邮件。每次运行测试套件时都发送大量电子邮件将会非常烦人!

更重要的是——我们可以对那些模拟类设置期望,以确保特定的方法被调用,或许带有一定的参数——作为验证我们的代码是否按预期执行测试的一部分。

为了使XenForo插件的单元测试更容易,我开发了一个单元测试框架,它依赖于能够随意替换应用容器中的子系统,用模拟对象和应用程序存根——既可以设置期望,也可以避免测试的副作用。

3. XenForo应用架构

为了理解我们如何对插件执行单元测试,我们首先需要了解XenForo是如何执行的。

作为XenForo的用户或管理员,我们交互的两个可能的执行触发器。第一个当然是Web界面。当我们用浏览器访问XenForo网站时,Web服务器通过{forum_root}/index.php重定向我们的请求。

如果你看看这个文件,它很简单(注释是我的)

<?php

// start by ensuring we are running the minimum required version of PHP
$phpVersion = phpversion();
if (version_compare($phpVersion, '5.6.0', '<'))
{
    die("PHP 5.6.0 or newer is required. $phpVersion does not meet this requirement. Please ask your host to upgrade PHP.");
}

// save the current directory from our index.php file - we'll use that later as our forum root
$dir = __DIR__;

// this is where we load in the main XenForo framework, but we aren't executing it yet
require($dir . '/src/XF.php');

// now let's boot the framework - this is what sets up some key variables, environment settings, runs our autoloader
// and registers our error handlers and shutdown functions
XF::start($dir);

// check whether we've got an API call
if (\XF::requestUrlMatchesApi())
{
    \XF::runApp('XF\Api\App');
}
else // ... or a regular web call
{
    \XF::runApp('XF\Pub\App');
}

你会注意到API调用使用相同的入口点——通过HTTP调用{forum_root}/api也会通过相同的索引文件重定向。

无论如何,\XF::runApp()实际上是启动XenForo框架执行的地方。它首先创建一个新的应用容器,将其存储在静态变量中,以便我们可以在全局范围内访问它,从config.php加载设置,启动插件的自动加载器,然后最终开始处理请求的URL,以确定我们需要显示论坛列表、特定线程还是采取其他行动。

第二个入口点是CLI——命令行界面。在这种情况下,我们执行{forum_root}/cmd.php,它与index.php非常相似(再次,注释是我的)

<?php

// start by ensuring we are running the minimum required version of PHP
$phpVersion = phpversion();
if (version_compare($phpVersion, '5.6.0', '<'))
{
    die("PHP 5.6.0 or newer is required. $phpVersion does not meet this requirement. Please ask your host to upgrade PHP.");
}

// save the current directory from our cmd.php file - we'll use that later as our forum root
$dir = __DIR__;

// this is where we load in the main XenForo framework, but we aren't executing it yet
require ($dir . '/src/XF.php');

// now let's boot the framework - this is what sets up some key variables, environment settings, runs our autoloader
// and registers our error handlers and shutdown functions
XF::start($dir);

// this is the important bit. Rather than "running" our application - we instead instantiate a CLI runner (based on
// Symfony's Console Component) and have that work our what command we're asking for and executing that for us.
$runner = new \XF\Cli\Runner();
$runner->run();

$runner->run()命令设置控制台输入和输出类,然后设置并启动我们的应用程序框架,就像基于Web的入口点一样。

区别在于,它不是通过解释URL来确定我们要做什么,而是使用命令行参数。它不会生成HTML(或JSON、XML等)响应发送回Web浏览器或API客户端,而是等待CLI命令的输出并将其写入控制台。

不过,在这两种情况下,我们都实例化了包含所有可能需要的子系统的应用容器,准备供我们的应用程序调用。

4. PHPUnit架构

现在,重要的是要理解PHPUnit是一个控制台应用程序。它没有Web界面,也不与浏览器或Web服务器交互。实际上,它在功能上更接近XenForo的CLI界面。

XenForo CLI与运行PHPUnit之间的区别在于,我们的单元测试不是给XenForo一个要执行的命令,我们是为PHPUnit提供一系列测试脚本,它将依次执行这些测试脚本,然后将测试结果输出到控制台。

那么XenForo在这个框架中扮演什么角色呢?很简单——我们可以让PHPUnit为我们实例化XenForo应用程序框架,这样应用程序容器就已经准备好了,等待我们在代码执行时调用!

更重要的是,作为我们的测试的一部分,我们可以选择性地替换或模拟应用程序容器中的某些子系统,这样我们就可以对我们的代码做出的调用给出可预测的响应,而不会对其他测试产生副作用。

幕后有很多事情要做才能实现这一点——但我已经创建了一个Composer包,你可以将其包含在你的插件中,它为你做了所有繁重的工作。你只需要编写你的测试,并从我的包中调用各种辅助方法来根据需要替换子系统。

5. 模拟对象

PHPUnit的一个重要补充是模拟我们的代码将要与之交互的类。这既确保了我们的代码使用预期的参数调用预期的调用,又为我们的代码执行特定的代码路径提供了可预测的响应。

在单元测试中,模拟对象模拟了真实对象的行为。它们可以表现得像真实对象一样,包括传递给声明类型期望的函数调用,提供接口的具体实现,而不需要提供任何实现细节。

Mockery 是一个用于与PHPUnit一起进行单元测试的简单PHP模拟对象框架。Mockery被设计为一个可替换PHPUnit自带的模拟对象库的替代品。我们在单元测试框架中大量使用Mockery。

6. XenForo单元测试框架

请参阅本教程后面的安装说明,了解如何将单元测试框架集成到你的插件中。现在,我将简要概述安装后它是如何工作的。

此时,我应该承认Taylor Otwell和其他Laravel PHP框架的贡献者的工作——XenForo单元测试框架受到了为Laravel开发的测试框架的很大启发,并且一些反射类直接来自Illuminate\Support组件。

如果你是PHPUnit的新手,你应该离开这里去阅读一些教程。至少阅读文档:为PHPUnit编写测试

我们为PHPUnit编写的测试类通常继承自PHPUnit\Framework\TestCase。我的XenForo单元测试框架所做的就是在基础TestCase类和你的类之间提供一些额外的层。

Hampel\Testing\TestCase 扩展了 PHPUnit\Framework\TestCase 并为单元测试框架提供了大部分功能。这是通过Composer包提供的。

然后,你将两个文件复制到你的单元测试目录中 {addon_root}/tests/TestCase.php

<?php namespace Tests;

use Hampel\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;

    /**
     * @var string $rootDir path to your XenForo root directory, relative to the addon path
     *
     * Set $rootDir to '../../../..' if you use a vendor in your addon id (ie <Vendor/AddonId>)
     * Otherwise, set this to '../../..' for no vendor
     *
     * No trailing slash!
     */
    protected $rootDir = '../../../..';

    /**
     * @var array $addonsToLoad an array of XenForo addon ids to load
     *
     * Specifying an array of addon ids will cause only those addons to be loaded - useful for isolating your addon for
     * testing purposes
     *
     * Leave empty to load all addons
     */
    protected $addonsToLoad = [];

    /**
     * Helper function to load mock data from a file (eg json)
     * To use, create a "mock" folder relative to the tests folder, eg:
     * 'src/addons/MyVendor/MyAddon/tests/mock'
     *
     * @param $file
     *
     * @return false|string
     */
    protected function getMockData($file)
    {
        return file_get_contents(__DIR__ . '/mock/' . $file);
    }
}

如果你没有在插件安装路径中包含供应商文件夹,你可能需要编辑$rootDir变量。

这个类是单元测试将扩展的类。

另一个文件包含我们用于启动XenForo框架的代码 {addon_root}/tests/CreatesApplication.php

<?php namespace Tests;

trait CreatesApplication
{
    /**
     * Creates the application.
     *
     * @return \XF\App
     */
    public function createApplication()
    {
        require_once("{$this->rootDir}/src/XF.php");

        \XF::start($this->rootDir);

        $options['xf-addons'] = $this->addonsToLoad ?: [];

        return \XF::setupApp('Hampel\Testing\App', $options);
    }
}

正如我们从之前的关于XenForo入口点的讨论中可以看到的,我们遵循相同的模式——包括XF.php,启动框架,然后设置我们的应用程序容器。我们不需要做更多——我们只需要应用程序容器可供我们在测试中使用。

在基础TestClass的setUp函数期间将调用此特质中的代码,因此它将使XenForo应用程序框架对任何扩展我们的TestClass的类可用。

我们在这里公开它,而不是简单地将其包含在Composer包中,以防你需要以任何方式自定义XenForo启动过程——你可以这样做而不必更改供应商文件。

7. 交换 XenForo 子系统

XenForo 单元测试框架的核心功能由一些相对简单的辅助函数提供,这使得我们能够轻松地从应用容器中替换子系统。

了解这一点很有帮助:应用容器中的大多数子系统并不是完全实例化的类——相反,它们使用闭包来配置和实例化对象,该对象将在第一次被应用程序调用时提供子系统。这更有效率——因为我们不需要在需要使用它们之前执行或实例化任何代码。这也使得替换变得非常简单——我们只需简单地覆盖容器中的那些闭包,用我们自己的对象替换。

swap() 简单地用我们提供的实例替换特定容器键中的代码。

因此,如果我们编写了一个替换某些功能的测试框架,我们只需用我们的类替换核心类即可。我们就在 fakesMail() 辅助函数中这样做——我们用我们的类替换了 mailer.transportmailer.queue 容器键,我们的类记录了应用程序发送的邮件,并允许我们对该日志进行断言——但永远不会实际发送邮件。

    protected function fakesMail()
    {
        $this->swap('mailer.transport', function (Container $c) {
            return new Transport(
                \Swift_DependencyContainer::getInstance()->lookup('transport.eventdispatcher')
            );
        });

        $this->swap('mailer.queue', function(Container $c)
        {
            return new Queue($c['db']);
        });
    }

在上面的代码中,我们实例化的 Transport 类实际上是我构建的一个自定义类,它实现了 \Swift_Transport 接口,因此接受与正常 Swift 传输类相同的调用,但只是将它们存储在数组中而不是发送。

mock() 将此进一步,允许我们用我们声明的断言的模拟对象替换闭包函数,用于测试目的。

我们提供一个抽象类作为模拟对象的基础,并可选地提供对那个模拟的期望。

例如,如果我们的代码查询 XF\Http\Request 类以检索访问者的 IP 地址——我们无法从 PHPUnit 测试中测试此操作,因为没有 HTTP 请求执行控制台命令!然而,我们可以简单地模拟我们的请求——它在应用容器的 'request' 键中存储,所以我们可能会这样做

$this->mock('request', XF\Http\Request::class, function ($mock) {
   $mock->expects()->getIp(true)->once()->andReturns('10.0.0.1');
});

因此,我们指示 XenForo 在查询请求对象时使用我们的模拟对象,并告诉 PHPUnit 我们预计我们的代码将调用 XF\Http\Request::getIp(true); 一次,此时我们的模拟对象将返回 IP 地址 10.0.0.1

我们有模拟许多关键子系统的辅助函数

  • mockDatabase
  • mockRepository
  • mockFinder
  • mockEntity
  • mockFactory
  • mockService
  • mockRequest
  • mockFs

我们还提供了记录与子系统交互并允许我们在之后查询的模拟系统

  • fakesErrors
  • fakesJobs
  • fakesLogger
  • fakesMail
  • fakesSimpleCache
  • fakesRegistry
  • fakesHttp

最后,我们有针对特定目的的一些特殊辅助函数

  • assertBbCode 允许您测试某些 BbCode 的预期输出,这对于测试自定义代码很有用。
  • expectPhrase 模拟短语渲染过程,并允许我们为短语返回任意字符串。
  • setOption & setOptions 允许我们直接设置我们想要为我们的选项设置的值,这样我们就不需要模拟选项存储库。在每个测试执行后恢复选项——保持我们的无副作用的目标。
  • setTestTime 允许我们将应用程序执行时间(《\XF::$time》)设置为一个已知的特定时间(可选地使用 Carbon 库),这样我们就可以测试依赖于时间间隔或比较的函数。
  • swapFs 允许我们将文件系统从 local 交换到 memory,这样我们就可以对文件系统进行非持久性更改并避免副作用
  • isolateAddon 允许我们强制 XenForo 只加载我们的插件类扩展和代码事件监听器,从而避免与其他已安装在开发服务器上的插件可能发生的冲突或意外的代码路径。

8. 安装框架

单元测试框架是一个 Composer 包,名为 hampel/xenforo-test-framework - 在使用它之前,您需要在您的开发服务器上安装 Composer。我们使用 require-dev 指令只在我们的开发环境中加载测试框架。我们将在后续展示在构建过程中从我们的插件中移除测试代码所需的命令 - 我们不需要也不希望将单元测试部署到我们的生产服务器。

您可以在以下位置查看该包的源代码: XenForo 测试框架

如果您需要更多关于在 XenForo 插件中使用 Composer 包的指导,请参考我的教程: 在 XenForo 2.1+ 插件中使用 Composer 包教程

我将假设您的 XenForo 论坛根目录位于 /srv/www/xenforo,并且您的插件(让我们称之为 "Vendorly/Addonista")已安装于 /srv/www/xenforo/src/addons/Vendorly/Addonista

如果您已经有一个 composer.json 文件,我将假设您知道自己在做什么,并将指导您将下面的 require-devautoload-dev 指令添加到您的文件中。否则,如果您的包尚未使用 Composer,您只需在插件的根目录(/srv/www/xenforo/src/addons/Vendorly/Addonista/composer.json)中创建一个 composer.json 文件即可。

{
    "require-dev": {
        "hampel/xenforo-test-framework": "^2.1",
        "nesbot/carbon": "^3.0"
    },
    "autoload-dev": {
        "psr-4": {
            "Tests\\": "tests/"
        }
    }
}

注意,nesbot/carbon 是可选的,但非常有用。

切换到您的插件根目录,然后运行 composer update 以安装框架。我们会自动为您安装 PHPUnit 和 Mockery。

$ cd /srv/www/xenforo/src/addons/Vendorly/Addonista/
$ composer update

接下来,查看 {addon_root}/vendor/hampel/xenforo-test-framework/ 目录 - 将整个 tests 目录复制到您的插件根目录。

$ cd /srv/www/xenforo/src/addons/Vendorly/Addonista/
$ cp vendor/hampel/xenforo-test-framework/tests .

在测试目录中,您会找到以下目录和文件

  • /tests/Feature 这是用于未来功能测试的占位符
  • /tests/Unit 这是放置所有单元测试的位置
  • /tests/Unit/ExampleTest.php 这是一个简单的示例测试 - 编辑或复制它作为您自己的测试类的起点
  • /tests/CreatesApplication.php 这是一个启动我们的 XenForo 测试框架的特质。如果您需要调整启动方式,可以修改此文件 - 但在大多数情况下,您应该保持不变
  • /tests/TestCase.php 这是一个我们的基类测试类(Tests\TestCase),如果要在测试中使用 XenForo 应用程序框架,所有单元测试类都应该从它继承

第三步是将 phpunit.xml 文件从 {addon_root}/vendor/hampel/xenforo-test-framework/phpunit.xml 复制到您的插件根目录

$ cd /srv/www/xenforo/src/addons/Vendorly/Addonista/
$ cp vendor/hampel/xenforo-test-framework/phpunit.xml .

此文件包含 PHPUnit 的配置和指令 - 重要的一点,查看 testsuite 配置选项 - 它们告诉 PHPUnit 哪里可以找到我们的单元测试。

<?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>
</phpunit>

最后,更新您的 build.json 文件,以便在构建我们的插件发布版本时清理单元测试代码。假设您只使用 Composer 进行单元测试

{
    "exec": [
        "rm -v _build/upload/src/addons/{addon_id}/composer.json",
        "rm -v _build/upload/src/addons/{addon_id}/composer.lock",
        "rm -v _build/upload/src/addons/{addon_id}/phpunit.xml",
        "rm -v -r _build/upload/src/addons/{addon_id}/tests",
        "rm -v -r _build/upload/src/addons/{addon_id}/vendor"
    ]
}

(您可以直接使用字符串 {addon_id} - 您不需要在 build.json 文件中硬编码您的插件 ID !!)

... 这些指令将删除与我们的单元测试相关的所有文件,包括 composer 文件和 vendor 目录。

如果您还在您的插件的其他非开发部分使用 Composer,那么您不希望删除所有内容 - 而是在您的 build.json 中添加以下内容

{
    "exec": [
        "rm -v _build/upload/src/addons/{addon_id}/phpunit.xml",
        "rm -v -r _build/upload/src/addons/{addon_id}/tests"
    ]
}

... 并且在您的构建过程中运行 composer install 时,确保指定 --no-dev 选项。

例如,我的一些插件同时使用 Composer 进行开发和非开发用途,它们的 build.json 文件如下

{
    "exec": [
        "composer install --working-dir=_build/upload/src/addons/{addon_id}/ --no-dev --optimize-autoloader",
        "composer install --no-dev --optimize-autoloader",
        "rm -v _build/upload/src/addons/{addon_id}/phpunit.xml",
        "rm -v -r _build/upload/src/addons/{addon_id}/tests"
    ]
}

9. 配置框架

您可能需要配置两个选项,都在 TestCase.php 文件中,该文件位于 tests 目录下。

根目录路径

$rootDir 变量指定了相对于插件目录的论坛根目录路径。

protected $rootDir = '../../../..';

通常,插件将使用 Vendor/AddonId 结构,这使得插件目录为 <forum-root>/src/addons/Vendor/AddonId - 因此我们需要回退4级才能到达论坛根目录。

但是,如果您在插件ID中没有使用Vendor,您需要调整此变量。如果您的插件目录是: <forum-root>/src/addons/AddonId,那么您需要将其更改为: $rootDir = '../../..'

插件隔离

另一个选项是插件隔离系统 - 允许我们指示XenForo框架仅加载测试所需的插件,从而最小化来自我们开发论坛中安装的其他插件可能产生的副作用或意外的交互。

简单地列出您希望在单元测试运行时加载的每个插件的完整插件ID,作为一个数组。

例如

protected $addonsToLoad = ['Vendor/AddonId', 'XFMG'];

通常,您只需列出您正在开发的插件的插件ID,从而防止在单元测试执行时加载其他所有插件。请注意,这也限制了Composer自动加载,这是一种检查您是否正确指定了composer.json中的所有要求的好方法,并且没有从其他插件中选取包。

将数组留空将按正常方式加载所有插件。

11. 运行单元测试

Composer在 {addon_root}/vendor/bin/phpunit 安装了PHPUnit可执行文件。要运行我们的测试,我们在控制台中转到插件根目录,然后简单地执行PHPUnit。phpunit.xml 文件(也应位于插件根目录)告诉PHPUnit在哪里找到我们的测试。

$ cd /srv/www/xenforo/src/addons/Vendorly/Addonista/
$ ./vendor/bin/phpunit
PHPUnit 8.4.1 by Sebastian Bergmann and contributors.

..................                                                18 / 18 (100%)

Time: 544 ms, Memory: 32.00 MB

OK (18 tests, 70 assertions)

您也可以设置一个bash别名,使其更容易运行。以下是我的设置

alias u="./vendor/bin/phpunit"

... 因此,我只需要将目录切换到我的插件根目录,然后只需运行 u 命令来执行我的单元测试。

15. 框架文档

查看文件DOCS.md

17. 限制

有许多事情我们无法有效地测试,或者测试起来很麻烦

控制器

控制器表面上可能看起来很简单,但要让它们运行,需要大量的脚手架。会话数据、路由数据、请求数据、验证器 - 在我们能够有效地对控制器进行单元测试之前,所有这些都需要配置。

如果我们有一个库,它使得针对我们的XenForo测试系统进行简单HTTP请求并测试响应变得容易,我们就可以使用功能测试 - 但我们还没有那个库。

数据库查询

虽然我们可以模拟数据库适配器或实体和查找器,但对于任何比简单查询更复杂的代码来说,单元测试代码很快就变得麻烦了。

实体保存

虽然我们可以模拟实体,但我们无法阻止它与数据库交互,因为基类Entity上的save()方法被标记为final - 这意味着我们的模拟实际上无法通过重写它来阻止该方法执行。

基本上,您无法对调用实体上的save()的代码进行单元测试 - 运行您的单元测试会导致数据库更新的副作用。

使用time()而不是\XF::$time的函数

我们无法像使用\XF::$time变量那样用任意已知的值重写PHP函数time()的返回值。因此,我们无法预先知道它将返回什么值,这可能会使某些测试变得复杂。

使用Carbon::now()作为time()的替代方案将解决这个问题 - 因为Carbon库有在测试中设置任意返回值的能力。然而,我们无法控制外部库和XenForo核心

  • 如果它们使用的函数依赖于time()并且我们需要调用它们,而不能模拟整个类,那么在某些情况下进行测试可能会有困难。

UI更改和模板修改

我们无法验证某些代码会导致UI发生变化,例如视图或模板的修改。这更像是功能测试级别的操作,而不是单元测试。

数据库中的数据

任何依赖于在特定时间点存在于数据库中的某些数据的代码都存在问题,因为该数据可能由外部来源更改 - 从而在未来的运行中破坏我们的单元测试。

这包括任何想要创建并保存实体到数据库以便稍后对其进行操作的代码 - 除非我们有在每个测试执行后清理该数据并恢复数据库到测试前状态的方法,否则我们将产生副作用。

解决这个问题的一个理想方式是构建一个新的数据库适配器,该适配器使用SQLite等系统,它提供了一个可以快速填充和销毁的内存数据库,以便在每次测试运行时使用。不幸的是,这不会是一个简单的练习 - XenForo中内置了许多MySQL特定的函数。然后还有如何有效地快速填充新创建的数据库,使其包含所有必要的XenForo实例数据以便进行测试的问题。

静态类

如果我们不能替换一个类为我们的实例,因为它依赖于静态变量或函数,那么测试将会更加困难或不可能。这是一个单元测试的一般限制,而不是XenForo特有的问题。

13. 编写可测试的代码和其他单元测试技巧

人们在尝试测试他们的代码时面临的主要问题之一是他们陷入了试图调整现有代码结构的困境。当你试图测试未考虑测试的代码时,你最终会进行不必要的跳跃。

需要一些经验才能知道什么是不易测试的 - 以及如何有效地更改代码以使测试更简单。

以下提示并非旨在全面或具有约束力 - 事实上,一些开发者可能不同意我这里的一些建议,这是完全可以接受的。至少我希望这能让你思考你的测试以及你如何改进你的实践,也许还能引发一些有用的讨论。

知道你要测试什么

首先要记住的是,我们不是在测试XenForo框架 - 我们会假设它工作正常,尤其是考虑到我们无法随意更改它。

同样,我们通过Composer或其他方式拉入的任何外部库都应该假定其正常工作,并且有自己的单元测试。

我们希望专注于我们自己的代码,并通过模拟我们代码使用的类来最小化我们将使用的代码路径数量。

知道你为什么要测试它

编写无法失败或总是返回特定值的代码的测试是没有意义的。除非它可能导致级联错误或意外的失败,否则为什么要测试它呢?

我们希望知道我们的逻辑是正确的,并且对于一组特定的输入,我们会得到预期的输出。

我们希望知道如果外部的东西发生变化,我们的代码行为是一致的,并且符合预期。

不要留下副作用

如果你的测试代码以这种方式改变系统,以至于后续执行单元测试会返回不同的结果,那么你就有了副作用。不惜一切代价避免这种情况。

单元测试需要可重复,甚至可能需要自动化。你需要有信心,每次单元测试都会在完全相同的环境中运行,而无需你手动干预。这就是我们为什么要模拟那些如果允许它们执行可能会产生副作用的系统,例如:数据库更新;发送电子邮件;文件系统更新;外部API调用。

不要尝试编写功能或集成测试

我们正在进行单元测试。这不同于功能或集成测试。

如果你正在调用外部系统,例如API,你应该模拟响应(Guzzle提供了帮助你进行API调用的函数)。

不要引起数据库更新。不要发送电子邮件。不要写入文件系统。我们应该以可重复和一致的方式隔离测试我们的代码。

功能测试和集成测试也很重要——但现在是时候专注于单元测试了。

保持你的控制器薄

如果你发现自己想知道为什么不能对控制器进行单元测试——这可能是一个很好的迹象,表明你做错了。

控制器只是协调器——它们根据请求的URL由路由引擎调用,负责验证请求,根据该请求执行正确的逻辑,然后返回响应。

你无法测试控制器中的逻辑。逻辑和算法应包含在存储库或服务中。子容器也是存放相关逻辑的有用位置。

这同样适用于控制台命令和作业——尽可能保持简单,并将逻辑放在存储库、服务或子容器中。

避免静态函数

好吧,但我的cron任务必须是一个静态函数怎么办——我该如何测试它?

看看XenForo核心如何构建内置的cron任务

<?php

namespace XF\Cron;

/**
 * Cron entry for cleaning up bans.
 */
class Ban
{
	/**
	 * Deletes expired bans.
	 */
	public static function deleteExpiredBans()
	{
		\XF::app()->repository('XF:Banning')->deleteExpiredUserBans();
	}
}

... 函数的实际静态部分非常短且简单——它获取一个存储库并执行那里的逻辑!你会发现大多数内置的cron任务都使用存储库或服务来完成所有工作。

避免模拟数据库

如果可能的话,避免模拟数据库——它会很快变得笨拙且难以管理。使用存储库与数据库交互的代码,然后你可以独立测试存储库,并在测试其他代码时模拟该存储库。

测试存储库时,你可能必须模拟数据库——但你可以在程序逻辑的隔离中进行。

不要模拟一切

如果你需要模拟一切才能使代码工作,那么它可能具有过多的依赖关系。尝试重构代码,将关注点分割成多个类。

如果你正在使用不引起副作用的核心XenForo功能,那么让你的测试代码通过该功能是完全可以的,以确保你的代码按预期行为。模拟你需要更多控制或需要停止产生副作用的部分。

这尤其适用于核心框架中的实用函数——除非它们引起副作用,否则不需要模拟它们。

不要模拟XF\App

你做错了。使用我提供的XenForo测试框架的辅助函数。

不要模拟任何东西以尝试设置或获取选项

使用setOption()setOptions()代替。

不要模拟XF\Error

使用fakesErrors()代替。

不要模拟XF\Job\Manager

使用fakesJobs()代替。

不要模拟XF\Logger

使用fakesLogger()代替。

不要模拟XF\Mail\TransportXF\Mail\Queue

使用fakesMail()代替。

不要模拟XF\SimpleCache

使用fakesSimpleCache()代替。

不要模拟XF\DataRegistry

使用fakesRegistry()代替。

不要模拟XF\LanguageXF\Phrase

处理短语时,请使用 expectPhrase()

如果不进行断言,就不是真正的测试。

在测试代码中没有对预期情况进行断言,意味着忽视了发现意外错误的机遇。请广泛使用断言。

然而,要小心一个陷阱:单个测试函数中有很多断言会使得难以确定具体哪个失败了。PHPUnit 允许你向大多数断言函数添加自定义错误消息,以便使错误更具意义,帮助在大型的测试套件中隔离问题代码。

当我们总是向方法传递真值时,断言我们向方法传递了真值并没有太多意义。测试意外的值。

不要使用 print 或 dump

在积极开发时,检查变量内容以了解其内容可能很有用。确实,\XF::dump() 命令将在单元测试期间在控制台提供格式良好的输出——所以它非常有用。但完成工作后,不要留下它们!

对于你已经完成工作的代码,PHPUnit 的输出应该是干净的,只显示 PHPUnit 本身生成的错误或成功指示器。

使测试函数描述性

当你的单元测试失败时,PHPUnit 会告诉你正在执行的测试函数的名称。名为 testFoo() 的函数名不会给你太多关于出了什么错的线索。

尝试使用像 test_foo_throws_an_exception_when_passed_null()test_foo_returns_null_when_passed_a_banned_user() 这样的函数名。

这也有助于你使测试函数集中于单个代码路径或单个用例。

避免在单元测试中包含逻辑

如果你需要在单元测试中进行分支,这通常表明你的测试函数太大,或者你试图在一个函数中测试多个代码路径。将测试拆分成多个测试函数。

测试边界条件

不要只测试简单或预期的情况。代码在接收到意外输入时会出错。所以,向代码传递意外数据以确保它足够健壮,能够优雅地失败。

当然,你也可以过分强调这一点——当一个函数意外返回 null 时,这并不是什么大问题。当然,那会导致灾难性的失败——那是一个错误,它应该使系统失败,以便我们正确地将其识别为错误并修复它。

但当你期望一个函数返回 null 时会发生什么?尝试对 null 值执行操作是意外失败的最常见原因之一,许多系统调用在特定情况下会返回 null(或 false)。

如何测试私有函数?

你不需要。单元测试应该测试代码的公共接口。如果存在私有代码,它最终会在某个时刻通过公共方法被调用。测试那个公共方法。如果你的私有代码永远不会被公共方法调用,那么它为什么存在呢?

如果你发现自己希望能直接测试那个私有方法,那么请检查你的代码结构,看看是否可以将该方法封装到一个具有可测试公共接口的单独类中。

不要将测试代码视为不重要

你的测试代码与生产代码一样重要。由于粗心大意而允许的复制粘贴错误导致的测试代码意外结果将是一个巨大的挫折。更糟糕的是,如果由于测试代码中的错误而导致生产代码中的错误未被发现。

如果你幸运地作为团队的一员开发,并且可以进行代码同行评审,那么请利用这个机会也对你的测试代码进行同行评审。

确保将你的测试代码提交到源代码控制!