迈向可测试模块
当您为像 Joomla 这样的复杂现有系统编写扩展时,您能做什么来开始自动化单元测试?这并不总是容易,但通过一些简单的更改,您可以开始。一旦开始,请记住第一规则:“打破所有依赖!”通过使用 PHPUnit 内置的一些特殊功能,以及一些这些简单的技术和模式,您可以进入可测试性的领域。
(虽然这涵盖了众多测试基础知识,但这不是“测试 101”文章。它不仅假设您对 PHP 有实际的工作知识,而且还假设您对面向对象编程、自动化单元测试以及与 PHPUnit 的熟悉有一定的了解。它进一步假设您已经将 PHPUnit 安装在当前可执行路径或已在您的 IDE 中配置。我在本文中使用命令行来运行测试。配置您特定的 IDE 以正确运行测试留由您自行完成。)
自动化单元测试 是开发者工具箱中最有用的工具之一。它有助于您集中精力完成任务,它会在您不小心破坏了之前工作的东西时向您显示,缩小了可能隐藏错误的代码范围,甚至会在您完成编码时通知您。
但我们如何将这样的工具引入 Joomla?单元测试假设您正在测试与系统其余部分完全隔离的代码单元,因此测试中暴露的所有缺陷仅属于该代码单元。
在 Joomla 中,我们传统上通过安装模块、点击一些链接并观察发生了什么来测试模块。或者,对于真正的勇者,让 Selenium 点击链接。这会将数千行额外的代码带入测试过程,每行都带来了其自身的潜在缺陷。这也使得测试所需的时间比应有的时间更长,因为那数千行代码必须执行,这样您才能看到真正重要的那十行或十五行代码的执行结果。
我想建议一种创建模块的新方法,该方法利用了自动化单元测试带来的所有优势,并且可以在几秒钟内完全测试一个模块。
我在这个示例中使用的是随机图片模块。部分原因是因为它是一个简单的模块,这样技术可以更加清晰地展示,另一部分原因是我想要为它添加一些功能,所以无论如何我都会使其可测试。
目前,mod_random_image模块的起始部分与其他大多数模块类似,在模块入口点文件中有一大块程序代码。
// no direct access defined('_JEXEC') or die; // Include the syndicate functions only once require_once dirname(__FILE__).'/helper.php'; $link = $params->get('link'); $folder = modRandomImageHelper::getFolder($params); $images = modRandomImageHelper::getImages($params, $folder); if (!count($images)) { echo JText::_('MOD_RANDOM_IMAGE_NO_IMAGES'); return; } $image = modRandomImageHelper::getRandomImage($params, $images); $moduleclass_sfx = htmlspecialchars($params->get('moduleclass_sfx')); require JModuleHelper::getLayoutPath('mod_random_image', $params->get('layout', 'default'));
这显然是不可测试的,必须做出改变。
当我们面对如此难以测试的代码时,我们有两种选择:我们可以接受现状,或者我们可以进行尽可能少的修改使其可测试。由于我们不需要对代码进行大规模修改才能使其可测试,我们将选择后者。
游戏目标
单元测试是基于对象的概念构建的。实际上,该模块本身就是一个对象,类定义在"helper.php"文件中。(将类命名为"helper"而不是模块似乎有些奇怪且反语义,但我们承诺只做绝对必要的最少修改,所以我们将保持这种方式。)
由于模块被定义为对象,为什么不采取下一步逻辑并像对待对象一样对待它,而不是像对待命名空间函数库一样?这意味着我们可以将入口点文件中的所有程序代码移入模块对象的函数中。在模块入口点文件中,我们只需创建对象并请求其输出。
我们需要什么来创建对象?$params注册存储模块选项,因此创建的对象需要了解它。这似乎是这个模块在开始时需要了解的所有内容,所以我们将params注册作为类属性添加,并创建类构造函数。
/** * @var params the params registry for the module */ protected $params; /** * * Constructor. * * @param JRegistry $params * * @return modRandomImageHelper */ public function __construct( $params ) { $this->params = $params; }
这是一个简单的构造函数;它所做的只是将params注册本地存储,以便类方法可以访问它。params注册包含模块需要的所有选项,可以在以后使用时提取出来。在此阶段,如果我们愿意,我们可以为params注册中的每个单独的数据项(宽度、高度、文件夹等)创建一个类属性并将它们存储起来。但我们正在努力尽可能少地修改现有代码,以最大限度地减少可能的副作用。将此模块置于测试之下是当前目标;重构将在以后进行。
模块入口点文件中的大部分程序代码都与生成模块输出有关。由于我们现在希望对象来完成这项工作,我们需要将这部分代码移入一个类方法来生成输出。
/** * * createOutput. * * This method outputs through the selected template the results of the * module. * * @return none * */ public function createOutput() { $link = $this->params->get('link'); $moduleclass_sfx = htmlspecialchars($this->params->get('moduleclass_sfx')); $folder = static::getFolder($params); $images = static::getImages($params, $folder); if (!count($images)) { echo JText::_('MOD_RANDOM_IMAGE_NO_IMAGES'); } else { $image = $this->getRandomImage($images); require JModuleHelper::getLayoutPath('mod_random_image', $params->get('layout', 'default')); } }
如果你看看这段代码,你会发现它与原始入口点文件中的代码几乎一样。modRandomImageHelper::的引用已被更改为简单地static::,但这就是全部。它们以相同的方式很好地工作(几乎以相同的方式),但改变的原因将在以后出现;现在我们只是这样做。
我应该注意的是,我可以通过将这个类作为JModuleHelper的子类并将它替换为"$this->getLayoutPath"来使JModuleHelper静态调用无害(实际上我在此方法的早期草稿中就是这样做的)但这意味着我必须引入足够的Joomla来正确定义JModuleHelper,然后处理继承方法中固化的静态依赖的雪崩。在尝试将遗留系统置于测试之下时,最好一口一口地吃。但这确实是一个在以后,在更多依赖清理之后要考虑的重构。
// no direct access defined('_JEXEC') or die; // Include the syndicate functions only once require_once dirname(__FILE__).'/helper.php'; $randomImage = new modRandomImageHelper($params); $randomImage->createOutput();
您可以看到它仍然包含过程式代码;这是无法避免的,因为Joomla要求我们在这里有这些代码。(也许Joomla的未来版本可以简单地查找对象的名称并调用它,但这会影响不仅仅是这个模块,所以这是一个需要稍后做出的决定。)但是,这个过程式代码更容易测试(入口点代码实际上将包含在我们的最终测试文件中)。而且,确实如此,当我们对现有Joomla安装中的随机图像模块代码进行这些更改时,它仍然可以正常工作。在没有自动化测试来确保我们没有破坏东西的情况下,这是我们能获得的信心,因此现在我们可以开始设置自动化测试。
这些是考验我们灵魂的线路
设置测试环境的方法可能和开发者一样多。我会向您展示我习惯的方式,您可以根据自己的喜好选择并修改其余部分。重要的是不是您按照我的方式测试,而是您测试,这就是关键。而且,如果您对您的测试设置感到舒适,您更有可能进行测试。
首先,我创建一个“test”目录来保存我在测试中将要使用的所有文件。在其下,我创建一个“suite”目录,用于保存自动化测试文件,以及一个“fixtures”目录来保存我测试的所有支持文件。
PHPUnit使用一个“bootstrap”文件来创建测试运行的环境,因此我在“test”目录下直接创建一个名为“bootstrap.php”的文件,它包含
<?php define('_JEXEC', 1); define('JPATH_BASE', __DIR__ . '/fixtures'); define('JPATH_SITE', __DIR__ . '/fixtures');
我们现在确实需要的是_Joomla,但我们完成时将需要其他所有这些,所以现在就把它都放进去吧,趁我们还在这。
我们将把我们的自动化测试文件放在“suite”目录中。我们将其命名为“helperTest.php”(PHPUnit希望所有自动化测试文件都以“Test”结尾,而且,将您的测试文件名设置为要测试的文件名加上“Test”是一个好习惯)。它将包含
include_once __DIR__ . '/../../helper.php'; /** * modRandomImageHelperTest * * Proposed phpunit testing approach for the module Random Image */ class modRandomImageHelperTest extends PHPUnit_Framework_TestCase { /** * @var module the module under test */ protected $module; /** * setUp * * Creates the module under test and sets everything else up for the test. */ protected function setUp() { $this->module = new modRandomImageHelper(); } /** * testCreatedModule * * Tests the just-created module to ensure it's set up properly. */ public function testCreatedModule() { } }
这是我们将在接下来的文章中构建的基本测试框架。我们可以在此时尝试运行它,但它会因为错误而失败,因为我们没有传递一个参数注册表。
我们可以通过引入JRegistry对象来为它创建官方的Joomla参数注册表,但同样,这将引入JRegistry及其所有依赖项,而单元测试的第一条规则是“打破所有依赖”。那么我们应该怎么办呢?
模仿乌龟
查看代码,我们只在params对象上调用了一个方法,那就是get,这是一个简单到可以复制的足够方法。我们将创建一个“test double”,一个我们在测试中使用的对象,它将假装它是参数注册表,被测试的代码将无法分辨它不是一个真正的注册表。
我们可以这样做的一种方式是编写一个我们自己的特定于测试的对象,它只做测试需要它做的。所以,在fixtures目录中(记住,这是为我们的测试支持文件),我们将创建一个名为“mockParams.php”的文件,它将包含完成欺骗所需的最少代码(记住,代码越少等于bug越少)
<?php class mockParams { public $params; public function get($param, $default=null) { return isset($this->params[$param]) ? $this->params[$param] : $default; } }
简单直接。由于我们的模块只使用JRegistry的一个方法get(),被测试的代码将认为它在与“真正的”参数注册表通信。
这意味着我们需要在测试文件中包含类定义,并在测试设置中初始化我们的假参数
include_once __DIR__ . '/../../helper.php'; include_once __DIR__ . '/../fixtures/mockParams.php';
和
protected function setUp() { $this->params = new mockParams(); $this->params->params['width'] = null; $this->params->params['height'] = null; $this->params->params['link'] = '/'; $this->params->params['folder'] = 'images'; $this->params->params['type'] = 'jpg'; $this->params->params['moduleclass_sfx'] = null; $this->params->params['layout'] = null; $this->module = new modRandomImageHelper($this->params);
现在我们需要证明新模块被正确创建。通常,我们可以用一个简单的测试断言来做这件事。比如
$this->assertEquals($this->module->params, $this->params)
这一行告诉PHPUnit我们认为模块的params属性与为我们测试类创建的params属性是同一个对象。如果是这样,构造函数中的代码执行正确,否则不会。但是,这种方法有一个问题。在类定义文件中存在
/** * @var params the params array for the module */ protected $params;
已将params注册表指定为"受保护"。这意味着它不能在模块外部访问,所以即使代码运行正确,断言也会因错误而失败。
我们再次有两个选择。一是我们可以回到并更改"受保护"为"公共",这将使其对所有代码可见,包括测试。但这也会使任何找到它的代码都能修改它,使其成为未来可能的不愉快副作用的目标。尽管如此,我认为在这种情况下风险最小,我们可以安全地这样做。
但如果我们面临的风险不是最小的呢?如果你真的需要这个属性是受保护的,我们仅仅通过要求这一点就破坏了可测试性了吗?
不是的,因为PHPUnit包含一些可以用来测试私有和受保护属性的特殊方法
public function testCreatedModule() { $this->assertAttributeEquals($this->params, 'params', $this->module); }
测试断言“assertAttributeEquals”可以拉取私有或受保护属性的值(它不能修改它,只能读取它)并将其与你的预期值进行比较。在这个特定的情况下,我们告诉它将 $this->params 与对象 $this->module 的属性 'params' 进行比较,如果它们相等,则测试通过。确实如此
$ phpunit --bootstrap bootstrap.php suite PHPUnit 3.6.10 by Sebastian Bergmann. . Time: 0 seconds, Memory: 5.75Mb OK (1 test, 1 assertion)
这是从命令行运行的(注意添加了bootstrap参数,告诉PHPUnit在哪里找到bootstrap文件)。我通常从命令行运行PHPUnit。由于我在任何给定开发会话中多次运行它,所以我将其保持在单独的终端窗口中,只需按上箭头即可重复命令。我的系统会保存终端窗口的会话内容,所以我永远不需要重复输入命令。
如果你不那么幸运,你不需要重新输入bootstrap参数,可以创建一个配置文件来告诉PHPUnit在哪里找到bootstrap文件。PHPUnit在当前目录中查找此配置文件,因此它属于你运行PHPUnit的目录。
我们从运行它获得的响应可能需要一些解释,如果你之前从未从命令行运行过PHPUnit。
然后它跟着一行包含单个"."的行,这表示正在运行多少个测试(在这种情况下只有一个)以及发生了什么。"."表示一个通过测试。如果我们更改我们正在测试的内容,例如将assertAttributeEquals更改为assertAttributeNotEquals,测试将失败,响应将如下所示
$ phpunit --bootstrap bootstrap.php suite PHPUnit 3.6.10 by Sebastian Bergmann. F Time: 0 seconds, Memory: 5.75Mb There was 1 failure: 1) modRandomImageHelperTest::testCreatedModule Failed asserting that mockParams Object ( 'params' => Array ( 'width' => null 'height' => null 'link' => '/' 'folder' => 'images' 'type' => 'jpg' 'moduleclass_sfx' => null 'layout' => null ) ) is not equal to mockParams Object ( 'params' => Array ( 'width' => null 'height' => null 'link' => '/' 'folder' => 'images' 'type' => 'jpg' 'moduleclass_sfx' => null 'layout' => null ) ). FAILURES! Tests: 1, Assertions: 1, Failures: 1.
这伴随着测试文件中发生失败的行号,直接将您带到问题断言。
我们到了吗?
所以,我们已经更改了模块,使其看起来更容易测试,并且没有问题地创建了它。我们一切都搞定了,对吗?
唉,没有。我们才刚刚开始。看看代码,我们将要测试的下一个方法是getRandomImage。我们选择它,因为我们看到它没有对我们需要打破的其他代码的依赖,所以它将是下一个最容易处理的方法(而且我非常喜欢简单,尤其是在适应新软件时)。
那么,我们需要改变什么?首先我们看到它被指定为静态方法。静态方法是对可测试性的死亡(想想看——你通过用测试代码替换外部调用来打破依赖关系,你不能用静态调用来做这件事),所以我们现在就将其删除,将其更改为公共方法。前两行引用了params注册表作为参数。由于它是一个类属性,我们不需要它作为参数,所以现在方法开始
public function getRandomImage($images) { $width = $this->params->get('width'); $height = $this->params->get('height');
看起来这就是我们需要对它做的所有更改。那么让我们为它编写测试
/** * testGetRandomImage * */ public function testGetRandomImage() { $this->params->params['width'] = null; $this->params->params['height'] = null; $expectedWidths = array(100,100); $expectedHeights = array(61,73); $myImages = array( (object)array("name" => 'EQ.jpg', "folder" => 'images', 'width' => 416, 'height' => 304), (object)array("name" => 'hobok.jpg', "folder" => 'images', 'width' => 450, 'height' => 277), ); $image = $this->module->getRandomImage($myImages); $this->assertContains( $image, $myImages); /* tests for object in array sent */ $this->assertContains( (int)$image->width, $expectedWidths, "Incorrect Width"); $this->assertContains( (int)$image->height, $expectedHeights, "Incorrect Height"); }
如果这听起来有点多,深呼吸,放松一下。让我们一步步来
我们首先要设置一些模块参数。由于这是在测试模块中的相同对象(测试模块中包含 $this->params,记得吗?),设置 $this->params 也会在对象中设置它们(这是我构建对象的方式的一个副作用,我们很快就会利用这一点)。我们还要为两个图像的预期宽度和高度设置一些数组。我们需要它们是数组,因为我们不知道系统会返回哪两个图像(它是随机的,记得吗?)所以我们为两者都做好准备。
哪两个图像?我很高兴你提出了这个问题。还记得 fixtures 目录吗?是时候添加一些支持文件了。在这种情况下,我在 fixtures 下方创建了一个名为 "images" 的目录,并在其中放置了两个图像(它们的名称和大小在上面的 $myImages 数组中显示)。
$myImages 数组包含 getImages 方法将返回的数据(名称和文件夹)以及两个其他数据点(实际高度和宽度),我想要它们随时可用,以便于我做测试中的数学计算。如果你不想要这些提醒,你可以去掉它们。
由于我们为高度和宽度发送了 null 的模块参数,getRandomImage 将假设宽度为 100(阅读代码),并计算一个与原始比例匹配的高度。
在这里我们使用 assertContains 进行测试断言,因为这将断言实际值是作为变量给定元素的元素包含。在这种情况下,我们正在断言返回的图像是原始数组中包含的图像之一,并且返回图像的高度和宽度包含在可能的正确结果数组中。
但这只测试了两个值都为 null 的情况。对于每个值都有三种可能的情况(null,小于实际,不小于实际——代码从不测试等于实际)。我们必须复制和粘贴此代码九次以测试所有九种组合吗?
不,幸运的是。PHPUnit 提供了一种方法来 多次运行相同的测试,使用不同的测试数据集。首先,我们需要创建一个方法,该方法返回我们将要更改的数据数组。在这种情况下,那是两个模块参数(高度和宽度),以及可能返回的两个预期宽度和高度数组。
/** * casesRandomImage * * Provides test cases for getRandomImage */ public function casesRandomImage() { return array( array( null, null, array(100,100), array(61,73) ), array( null, 200, array(100,100), array(61,73) ), array( null, 400, array(100,100), array(61,73) ), array( 400, null, array(400,400), array(246,292) ), array( 400, 200, array(273,324), array(200,200) ), array( 400, 400, array(400,400), array(246,292) ), array( 800, null, array(416,450), array(304,277) ), array( 800, 200, array(273,324), array(200,200) ), array( 800, 400, array(416,450), array(304,277) ), );
我们在这里有,覆盖了所有九种组合。但如何将测试用例连接到测试中呢?
* @dataProvider casesRandomImage */ public function testGetRandomImage($testWidth, $testHeight, $expectedWidths, $expectedHeights) { $this->params->params['width'] = $testWidth; $this->params->params['height'] = $testHeight; $myImages = array( (object)array("name" => 'EQ.jpg', "folder" => 'images', 'width' => 416, 'height' => 304), (object)array("name" => 'hobok.jpg', "folder" => 'images', 'width' => 450, 'height' => 277), ); $image = $this->module->getRandomImage($myImages);
首先,我们添加一条特殊的注释行,告诉 PHPUnit 包含 @dataProvider 的数据。然后我们为测试本身添加一个参数,用于每一行中的测试数据元素。由于我们有九行,这个测试将会运行九次,每次使用 casesRandomImage 数组中的一行。
$ phpunit --bootstrap bootstrap.php suite PHPUnit 3.6.10 by Sebastian Bergmann. .......... Time: 0 seconds, Memory: 5.75Mb OK (10 tests, 28 assertions)
我们看到有 10 个点(每个测试用例九行,加上原始测试),以及运行了 10 次测试和 28 个断言的摘要。(在九次运行中,每次都有三个断言,共 27 个,加上原始断言。)
这也是简单测试的结束。
如果道路向你而来,你就是在走上坡路
下一个要测试的方法应该是 getFolder(),因为它是告诉 getImages 在哪里查找图片的。但是当我们查看其内部时,我们发现它调用了 JURI 和 JString 的静态方法,所以除非我们想要将 Joomla 的部分代码引入我们的测试中,否则我们无法执行它。如果我们这样做,并且测试失败了,我们无法确定是我们在代码中的问题,还是 Joomla 的问题,这实际上违背了单元测试的初衷。记住,“打破所有依赖”。
因此,我们转向 getImages(),试图找到一种方法来打破它对 getFolder() 的依赖。一种方法当然是让它从参数注册表中接受文件夹值并继续执行。但这不是我们希望这种代码在“野外”运行的方式。另一种方法是简单地坚持任何调用 getImages() 的人必须先调用 getFolder(),并将结果值作为参数传递给它。
第二种方法是可接受的,我可能会选择这种方式,但为了这篇文章,我想展示另一种处理此问题的方法,这是 PHPUnit 提供的。通过将第二个静态方法混合在一起,我们可以利用 PHP 5.3 的 后期静态绑定 属性,在不修改代码的情况下,使一些静态方法调用可替换,以便进行测试。
首先,我们对 getImage() 进行以下更改
static function getImages($theFolder, $type) { $folder = static::getFolder($theFolder); $files = array(); $images = array(); $dir = JPATH_BASE . '/' . $folder;
是的,我们将其声明为静态方法。是的,我说静态方法是测试性的致命因素。Digitalis 也是一种毒药,但当你有特定的疾病时,一小剂量的它可能会救你的命。
由于方法是静态的,它无法访问本地参数注册表,因此我们将值作为参数传递给它。我们将使用我们之前使用的 static:: 关键字来调用 getFolder,以利用 PHP 的后期静态绑定,并将结果存储在本地变量中。我们不需要从 getImages() 内部获取类型,因为我们现在将其传递进来了。
这能帮助我们什么?让我们看看我们的测试文件中的新测试方法 testGetImages()
/** * testGetImages */ public function testGetImages() { $mockMe = $this->getMockClass('modRandomImageHelper', array('getFolder')); $mockMe::staticExpects($this->any()) ->method('getFolder') ->with($this->equalTo($this->folder)) ->will($this->returnValue($this->folder)); $images = $mockMe::getImages($this->folder, $this->params->params['type'] = 'jpg'); $this->assertEquals($images, array( (object)array("name" => 'EQ.jpg', "folder" => 'images'), (object)array("name" => 'hobok.jpg', "folder" => 'images'), ));
嗯?这是什么“mock”的东西?
Mock 对象 是为测试目的临时创建的测试替身,就像我们之前创建的 mockParams() 类一样。
在这种情况下,我们告诉 PHPUnit 创建一个类,我们可以用它来模拟我们的测试类 modRandomImageHelper,并且我们告诉它我们想要它模拟 getFolder() 方法(模拟一个方法是替换该方法,可以是它自己的方法或空方法)。
然后我们调用 mockMe,我们的“假”类,告诉它期望一个静态调用($this->any() 告诉它允许任意数量的这些调用——我们也可以指定应该允许的确切数量)到被模拟的方法 getFolder,并带有等于类方法文件夹属性的参数,并且它应该返回相同的值。
下一个调用触发了我们之前提到的后期静态绑定,并使所有这些成为可能。我们使用模拟类来对“真实”方法 getImages() 进行静态调用。由于我们告诉 PHPUnit 只要模拟 getFolder() 方法,这个调用将被转发到“真实”的 getImages() 方法,这是我们想要测试的代码。
这就是 魔法发生的地方(一切都取决于 PHP 处理“static::”而不是“className::”时的微小差异)。由于我们从 mockMe 类中静态地调用了 getImages(),所以“static::getFolder()”调用将引用 mockMe,而不是它编写在其中的类(如果我们使用了“modRandomImageHelper::”,它将绕过模拟直接到达“真实”类而不是模拟)。当我们从“真实”对象内部在“正常”情况下进行调用时,它将引用 modRandomImageHelper。
因此,我们已经成功地用自己的测试例程替换了 getFolder(),它返回测试类的“文件夹”属性。
现在我们添加文件夹类属性
/** * @var folder the test folder name */ protected $folder = 'images';
在测试类定义的顶部,并用以下内容初始化它:
$this->params->params['folder'] = $this->folder;
在setUp()方法中,替换params初始化中类似的行。
现在当我们运行测试代码时,getImages的调用将返回一个包含我们的fixtures/images目录中图片的数组,断言将通过。
$ phpunit --bootstrap bootstrap.php suite PHPUnit 3.6.10 by Sebastian Bergmann. ........... Time: 0 seconds, Memory: 6.50Mb OK (11 tests, 30 assertions)
等等!那个测试中只有一个断言。为什么断言的数量会增加两个?还记得这一行吗?
$mockMe::staticExpects($this->any()) ->method('getFolder') ->with($this->equalTo($this->folder)) ->will($this->returnValue($this->folder));
这同样是一个断言。在这个特定情况下,它与"assertTrue(true)"几乎相同,因为它无论被调用多少次都会通过。为了看到这个断言失败的情况,我们将"$this->any()"改为"$this->never()"
$ phpunit --bootstrap bootstrap.php suite PHPUnit 3.6.10 by Sebastian Bergmann. ..........F Time: 0 seconds, Memory: 6.75Mb There was 1 failure: 1) modRandomImageHelperTest::testGetImages modRandomImageHelper::getFolder('images') was not expected to be called. FAILURES! Tests: 11, Assertions: 28, Failures: 1.
在开发涉及模拟对象的测试时,我通常最初使用any(),以模拟对象同时防止这个断言触发并停止测试,直到验证了测试的其余部分的行为。然后我将其更改为正确的值。但这只是我在学习模拟工作原理时养成的习惯,您可以随时从一开始就输入正确的值。在这种情况下,该值应该是$this->once(),因为模拟的方法应该只被调用一次。
我们在这里使用的模拟静态方法调用的技术很不幸只能用于同一类中的静态调用,因此这个技术不能用来模拟JURI等其余部分的静态调用。
注意到目前为止测试运行得有多快——不到一秒。现在我们遇到了障碍;我们必须通过包括Joomla在我们的测试中来减缓测试和错误修复。
还是说我们会这样做呢?
全局隐藏,局部调用
这是真的。我们可以在这里停下来。我们已经将模块的大约2/3纳入测试。如果我们愿意,我们可以将剩下的部分留给集成测试(在完整的Joomla安装中进行测试),我们仍然会获得净收益。我们可以为报告的bug创建新的测试用例,并使用这些测试来查看问题是否在我们的代码中,或者是我们对Joomla的理解中。
或者我们可以继续前进,测试所有我们的代码。
每当一个方法使用全局变量时,测试就会变得困难,PHP静态方法调用只是全局变量的一个隐蔽诱惑版本。但是,在测试遗留代码中处理全局引用有一个标准模式:我们使它们不再全局。这被称为封装全局引用,我们可以通过收集全局引用到它们自己的对象并将它们注入到正在测试的对象中来实现。这样,对象间接调用它们,我们可以注入一个测试对象来"拦截"对静态方法的调用。
这样做不是无条件的利益。虽然它为我们提供了一个可以用来测试调用代码的接口,但它也增加了调用的一层,这意味着一个小但真实的表现损失。(对于大多数模块,我怀疑它不会增加超过几毫秒的总执行时间。)像所有其他事情一样,这也涉及成本/效益判断,因为我们确实在承受损失的同时获得了收益。
如果我们编写了多个模块,我们可以共享全局对象。当Joomla发生变化时,我们只需要检查我们的全局对象代码,以确保它仍然正确调用Joomla并返回正确的值,我们就知道所有我们的模块仍然会正常工作。而且,如果Joomla已经改变到不再工作的程度,我们只需要在我们的全局对象代码中做一次更改,所有我们的模块现在都将正常工作。
此外,通过将其用作Joomla的接口层,我们可能会发现我们可以通过替换Joomla特定的全局对象为新环境量身定制的对象,而不是在代码中进行大量更改,来在其它项目中重用我们的模块代码。这意味着我们不需要维护多个代码库,只需要维护多个全局对象。
这些都是一些潜在的好处。但在“成本”方面,除了性能影响之外,还有更多。这样做将使我们的编码过程变得稍微复杂一些。此外,我们还会面临风险,即我们的全局类名可能与有相同想法的某人的同名类冲突。
您需要自己解决这个方程。在随机图像的具体情况下,我认为获得的好处并不多(主要是因为它是一个核心模块,不太可能在其他地方使用),所以可能不会为这个特定的模块使用全局对象。
不过,我将为此篇文章构建一个全局对象,以便我可以演示如何构建它以及如何使用它来使您的模块更容易测试。 (我绝对不推荐使用与我所使用的相同名称的全局对象;这很可能引发冲突。)
首先,我们将遍历我们的代码,找到所有它对Joomla的静态调用。这些是我们无法模拟的调用,因此不能进行测试。我们将将这些调用分别放入全局类中的单独方法,并使用一个标识其功能的名称。
我们将创建一个名为“JoomlaGlobals.php”的文件,并将其放在包含其他模块代码的目录中
<?php /** * JoomlaGlobals * * This class abstracts all the calls into the Joomla API from the actual * module code. This means the module can be run completely isolated from * Joomla itself. */ class JoomlaGlobals { /** * getBaseURL * * Gets the domain (plus '/administrator' if on the admin site) of the site * URL * * return string */ public function getBaseURL() { return JURI::base(); } /** * strpos * * Calls a UTF-8 aware string search function * * @param needle string to be searched for * @param haystack string to search within * @param offset offset into haystack to start searching * * @return mixed Number of chars before first match, or false if not found */ public function strpos($needle, $haystack, $offset=false) { return JString::strpos($needle, $haystack, $offset); } }
这是由调用getFolder触发的静态调用。现在我们将全局类定义添加到入口点文件,并将全局对象传递给我们的模块对象
require_once dirname(__FILE__).'/JoomlaGlobals.php'; $randomImage = new modRandomImageHelper($params, new JoomlaGlobals);
现在我们需要让我们的模块对象了解全局对象
/** * @var cms the globals object for talking to the cms */ protected $cms;
和
public function __construct( $params, $cms ) { $this->params = $params; $this->cms = $cms; }
现在我们已经设置好了。现在我们可以将getImages改回成员函数,就像它应该的那样,甚至可以将getFolder也改为成员函数
public function getImages($theFolder, $type) { $folder = $this->getFolder($theFolder);
和
public function getFolder($theFolder) { $folder = $theFolder; $LiveSite = $this->cms->getBaseURL(); // if folder includes livesite info, remove if ($this->cms->strpos($folder, $LiveSite) === 0) { $folder = str_replace($LiveSite, '', $folder); } // if folder includes absolute path, remove if ($this->cms->strpos($folder, JPATH_SITE) === 0) { $folder= str_replace(JPATH_BASE, '', $folder); } $folder = str_replace('\\', DIRECTORY_SEPARATOR, $folder); $folder = str_replace('/', DIRECTORY_SEPARATOR, $folder); return $folder; }
静态调用JURI::base()已被替换为对全局对象的调用 $this->cms->getBaseURL(),同样,后续对JString的调用也是如此。在“正常”情况下,这些调用将表现得就像静态调用一样。
现在我们需要通过将其放入与类属性相同的部分来让测试了解全局对象
/** * @var mock_globals The mock object for the CMS globals */ protected $mock_globals;
是的,我们将在setUp()方法中模拟全局对象
$this->mock_globals = $this->getMock('JoomlaGlobals', array('getBaseUrl', 'strpos')); $this->module = new modRandomImageHelper($this->params, $this->mock_globals);
我们正在告诉PHPUnit模拟全局对象中对getBaseURL和strpos的调用。我们将在测试方法中稍后告诉它如何处理这些调用。
你知道我们之前放入的mockMe,晚期静态绑定,东西吗?当时我们说我们只是包括它来演示如何使用那种技术。由于getImages()不再是静态的,我们不能使用它,所以我们将
$mockMe = $this->getMockClass('modRandomImageHelper', array('getFolder')); $mockMe::staticExpects($this->any()) ->method('getFolder') ->with($this->equalTo($this->folder)) ->will($this->returnValue($this->folder)); $images = $mockMe::getImages($this->folder, $this->params->params['type'] = 'jpg');
替换为简单的
$images = $this->module->getImages($this->folder, $this->params->params['type'] = 'jpg');
这导致测试getImages()必然需要执行getFolder()。我将保持这种方式,因为这篇文章覆盖了获取现有模块进行测试,并且将其留给读者作为练习,如何重构测试文件或模块类(或两者),以使测试效果最佳。(有关更多信息,请参阅文章末尾。)
现在我们添加getFolder的测试代码
/** * testGetFolder */ public function testGetFolder() { $this->mock_globals->expects($this->once()) ->method('getBaseURL') ->will($this->returnValue('http://www.testingsite.com')); $this->mock_globals->expects($this->exactly(2)) ->method('strpos') ->will($this->returnValue($this->folder)); $actual = $this->module->getFolder($this->folder); $this->assertEquals($actual, $this->folder); }
我们首先告诉mock_globals我们期望getBaseURL()被调用一次,并且当被调用时返回"http://www.testingsite.com"。然后我们期望strpos()被调用两次,并且在每次被调用时都返回文件夹属性。
剩下的工作就是运行getFolder的测试。为了更多地练习代码,我们需要像之前那样设置一些测试用例,通过不同的路径来运行代码。再次强调,我将这个任务留给读者去练习。
$ phpunit --bootstrap bootstrap.php suite PHPUnit 3.6.10 by Sebastian Bergmann. ............ Time: 0 seconds, Memory: 6.75Mb OK (12 tests, 31 assertions)
剩下的工作就是测试输出。查看createOutput()函数,我看到有两个静态调用,因此我们将它们添加到全局对象中
/** * getTranslatedText * * Gets the text string in the current language * * @param string $string The string to translate. * @param mixed $jsSafe Boolean: Make the result javascript safe. * @param boolean $interpretBackSlashes To interpret backslashes (\\=\, \n=carriage return, \t=tabulation) * @param boolean $script To indicate that the string will be push in the javascript language store * * @return string The translated string or the key is $script is true */ public function getTranslatedText($string, $jsSafe = false, $interpretBackSlashes = true, $script = false) { return JText::_($string, $jsSafe, $interpretBackSlashes, $script); } /** * getLayoutPath * * Gets the text string in the current language * * @param string $layout The layout name to find. * * @return string The path to the layout file. */ public function getLayoutPath($layout) { return JModuleHelper::getLayoutPath('mod_random_image', $layout); }
现在我们将要模拟的方法添加到测试文件中的列表中
$this->mock_globals = $this->getMock('JoomlaGlobals', array('getBaseUrl', 'strpos', 'getTranslatedText', 'getLayoutPath', 'sendHTML') );
等等。那个第三个模拟方法“sendHTML”是什么?好吧,你抓住了我。既然我们正在修改这个,我就偷偷地加入了一些以后会用到的代码。在布局模板中有一个对JTEXT的静态调用,也需要模拟,我为此做好了准备。不,在“真实”的全局对象中没有名为“sendHTML”的方法,但这就是模拟的优点。你可以模拟你所需要的内容,而不受现有内容的限制。
为了使用这个模拟,我们需要创建一个默认输出模板的测试版本(我们也可以直接修改输出模板,但我想要展示输出模板在测试之前并不需要)。我们将在“ fixtures”目录中创建一个名为“tmpl”的目录,并在其中创建一个名为“default.php”的文件作为我们的测试输出模板。我们不需要这么做,但我们将使其与“正常”的默认输出模板完全相同,只是我们将用"$this->cms->sendHTML"替换JTEXT::_,以便将输出导向我们的模拟。
<?php // no direct access defined('_JEXEC') or die; ?> <div class="random-image<?php echo $moduleclass_sfx ?>"> <?php if ($link) : ?> <a href="/<?php echo $link; ?>"> <?php endif; ?> <?php echo $this->cms->sendHTML('image', $image->folder.'/'.$image->name, $image->name, array('width' => $image->width, 'height' => $image->height)); ?> <?php if ($link) : ?> </a> <?php endif; ?> </div>
现在我们编写createOutput测试
/** * testCreateOutput */ public function testCreateOutput() { $image = '$lt;img src="/images/test.jpg" alt="test.jpg">'; $this->mock_globals->expects($this->any()) ->method('getTranslatedText') ->will($this->returnValue('No Images')); $this->mock_globals->expects($this->once()) ->method('getLayoutPath') ->will($this->returnValue( JPATH_BASE . '/tmpl/default.php' )); $this->mock_globals->expects($this->once()) ->method('sendHTML') ->will($this->returnValue($image)); ob_start(); $this->module->createOutput('default'); $view_output = ob_get_contents(); ob_end_clean(); $this->assertStringStartsWith('<div class="random-image">', $view_output); $this->assertStringEndsWith("</div>\n", $view_output); $this->assertTrue(!!strpos($view_output, $image)); }
这展示了测试这种特定输出的简单方法。你在这里编写的测试将根据你想要的输出进行定制,你可以使它们尽可能的抽象或详细。只需记住,测试越详细,如果修改了模板,你可能就越需要修改它。
例如,如果我们修改默认模板以不使用div,我们就需要回到这个测试并更改它所寻找的内容。
现在,在这条路上,我们有
$ phpunit --bootstrap bootstrap.php suite PHPUnit 3.6.10 by Sebastian Bergmann. ............. Time: 0 seconds, Memory: 6.75Mb OK (13 tests, 35 assertions)
希望随着Joomla的不断发展,那些使测试困难并需要全局对象的静态调用将消失,你可以逐个从全局对象中删除这些调用,直到它完全消失。考虑这个对象为拐杖;当你的腿受伤时非常实用,但当病人康复后可以丢弃。
目前,我们有一个完全经过测试的模块,除了全局对象。全局对象应该单独设置以供测试,这样你就可以在每次Joomla更新时轻松地重新测试它,并且它应该在测试安装上与Joomla一起测试。毕竟,你现在测试的全局对象是检查Joomla是否发生了足够的更改以导致你的代码出现问题。假设你一开始就正确地测试了代码,你不需要重新测试代码。
由于模块是完全可测试的,我们现在可以添加功能和重构模块代码,我们有信心我们正在做的事情是合理的。
本文的代码可以在GitHub仓库中找到,它是公开的。如果你有改进建议,请克隆它并发送pull request,我会继续添加内容。当我写这样的文章时,我会故意留下一些内容作为读者的练习,并做一些次优(不是错误,只是不是最佳)的选择。我的意图不是误导,而是给你一些东西来检验你的知识,以证明你知道如何使用这些工具。而且,除了我故意做的选择外,我确信还有一些无意中的错误。正如链接所示,这里的信息来自许多不同的来源。最有用的是PHPUnit本身和Michael Feathers的《Working Effectively With Legacy Code》一书。我认为你不可能读了那本书而不成为更好的开发者。它在我的“必需品”列表中。
《Joomla社区杂志》上发布的一些文章代表作者对特定主题的个人观点或经验,可能并不与Joomla项目的官方立场一致。
通过接受,您将访问https://magazine.joomla.net.cn/之外第三方提供的服务
评论