随着 PHP 从一种简单的脚本语言转变为一种成熟的编程语言,一个典型的 PHP 应用程序的代码库的复杂性也随之增大。为了控制对这些应用程序的支持和维护,咱们可使用各类测试工具来自动化该流程。其中一种是单元测试,它容许您直接测试所编写代码的正确性。然而,一般遗留代码库是不适合进行这种测试的。本文将介绍对包含常见问题的 PHP 代码的重构策略,以便简化使用流行的单元测试工具进行测试的过程,同时减小改进代码库的依赖性。php
回顾 PHP 15 年的发展历程,咱们发现它已经从一个简单的用来替代当时流行的 CGI 脚本的动态脚本语言变成一种成熟的现代编程语言。 随着代码库的增加,手动测试已经变成不可能完成的任务,不管是大是小,全部代码的变化都会对整个应用程序产生影响。这些影响可能小到只是影响某个页面的加 载或表单保存,也多是产生难以检测的问题,或者产生只在特定条件下才会出现的错误。甚至,它可能会使之前修复的问题从新出如今应用程序中。为此开发了许 多测试工具来解决这些问题。数据库
其中一种流行的方法是所谓的功能或验收测试,它会经过应用程序的典型用户交互来测试这个应用程序。这是一种 很适合测试应用程序中各个进程的方法,可是测试过程可能很是慢,并且通常没法测试底层的类和方法是否按要求正常工做。这时,咱们须要使用另外一种测试方法, 那就是单元测试。单元测试的目标是测试应用程序底层代码的功能,保证它们执行后产生正确的结果。一般,这些 “不断增大” 的 Web 应用程序会慢慢出现愈来愈多长此以往难以测试的遗留代码,这使开发团队很难保证应用程序测试的覆盖率。这一般被称为 “不可测试代码”。如今让咱们看看如何识别应用程序中的不可测试代码,以及修复这些代码的方法。编程
关于代码库不可测试性的问题域一般在编写代码时是不明显的。当编写 PHP 应用程序代码时,人们倾向于按照 Web 请求的流程来编写代码,这一般就是在应用程序设计时采用一种更加流程化的方法。急于完成项目或快速修复应用程序均可能促使开发人员 “走捷径”,以便快速完成编码。之前,编写不当或者混乱的代码可能会加剧应用程序中的不可测试性问题,由于开发人员一般会进行风险最小的修复,即便它可能产生后续的支持问题。这些问题域都是没法经过通常的单元测试发现的。编程语言
全局变量在 PHP 应用程序中很方便。它们容许您在应用程序中初始化一些变量或对象,而后在应用程序的其余位置使用。然而,这种灵活性是有代价的,过分使用全局变量是不可测试代码的一个通病。咱们能够在 清单 1中看到这种状况。模块化
<?php function formatNumber($number) { global $decimal_precision, $decimal_separator, $thousands_separator; if ( !isset($decimal_precision) ) $decimal_precision = 2; if ( !isset($decimal_separator) ) $decimal_separator = '.'; if ( !isset($thousands_separator) ) $thousands_separator = ','; return number_format($number, $decimal_precision, $decimal_separator, $thousands_separator); }
这些全局变量带来了两个不一样的问题。第一个问题是您须要在测试中考虑全部这些全局变量,保证给它们设置了函数可接受的有效值。第二个问题更为严重, 那就是您没法修改后续测试的状态并使它们的结果无效,您须要保证将全局状态重置为测试运行以前的状态。PHPUnit 有一些工具能够帮您备份全局变量并在测试运行后恢复它们的值,这些工具可以帮助解决这个问题。然而,更好的方法是使测试类可以直接给方法传入这些全局变量的值。清单 2显示了采用这种方法的一个例子。函数
<?php function formatNumber($number, $decimal_precision = null, $decimal_separator = null, $thousands_separator = null) { if ( is_null($decimal_precision) ) global $decimal_precision; if ( is_null($decimal_separator) ) global $decimal_separator; if ( is_null($thousands_separator) ) global $thousands_separator; if ( !isset($decimal_precision) ) $decimal_precision = 2; if ( !isset($decimal_separator) ) $decimal_separator = '.'; if ( !isset($thousands_separator) ) $thousands_separator = ','; return number_format($number, $decimal_precision, $decimal_separator, $thousands_separator); }
这样作不只使代码变得更具可测试性,并且也使它不依赖于方法的全局变量。这使得咱们可以对代码进行重构,再也不使用全局变量。工具
单一实例指的是旨在让应用程序中一次只存在一个实例的类。它们是应用程序中用于全局对象的一种常见模式,如数据库链接和配置设置。它们一般被认为是应用程序的禁忌, 由于许多开发人员认为建立一个老是可用的对象用处不大,所以他们并不太注意这一点。这个问题主要源于单一实例的过分使用,由于它会形成大量不可扩展的所谓 god objects 的出现。可是从测试的角度看,最大的问题是它们一般是不可更改的。清单 3就是这样一个例子。单元测试
<?php class Singleton { private static $instance; protected function __construct() { } private final function __clone() {} public static function getInstance() { if ( !isset(self::$instance) ) { self::$instance = new Singleton; } return self::$instance; } }
您能够看到,当单一实例首次实例化以后,每次调用 getInstance()
方法实际上返回的都是同一个对象,它不会建立新的对象,若是咱们对这个对象进行修改,那么就可能形成很严重的问题。最简单的解决方案就是给对象增长一个 reset 方法。清单 4 显示的就是这样一个例子。测试
<?php class Singleton { private static $instance; protected function __construct() { } private final function __clone() {} public static function getInstance() { if ( !isset(self::$instance) ) { self::$instance = new Singleton; } return self::$instance; } public static function reset() { self::$instance = null; } }
如今,咱们能够在每次测试以前调用 reset 方法,保证咱们在每次测试过程当中都会先执行 singleton 对象的初始化代码。总之,在应用程序中增长这个方法是颇有用的,由于咱们如今能够轻松地修改单一实例。this
进行单元测试的一个良好作法是只测试须要测试的代码,避免建立没必要要的对象和变量。您建立的每个对象和变量都须要在测试以后删除。这对于文件和数据库表等 麻烦的项目来讲成为一个问题,由于在这些状况下,若是您须要修改状态,那么您必须更当心地在测试完成以后进行一些清理操做。坚持这一规则的最大障碍在于对 象自己的构造函数,它执行的全部操做都是与测试无关的。清单 5 就是这样一个例子。
<?php class MyClass { protected $results; public function __construct() { $dbconn = new DatabaseConnection('localhost','user','password'); $this->results = $dbconn->query('select name from mytable'); } public function getFirstResult() { return $this->results[0]; } }
在这里,为了测试对象的 fdfdfd
方法,咱们最终须要创建一个数据库链接,给表添加一些记录,而后在测试以后清除全部这些资源。若是测试 fdfdfd
彻底不须要这些东西,那么这个过程可能太过于复杂。所以,咱们要修改 清单 6所示的构造函数。
<?php class MyClass { protected $results; public function __construct($init = true) { if ( $init ) $this->init(); } public function init() { $dbconn = new DatabaseConnection('localhost','user','password'); $this->results = $dbconn->query('select name from mytable'); } public function getFirstResult() { return $this->results[0]; } }
咱们重构了构造函数中大量的代码,将它们移到一个 init()
方法中,这个方法默认状况下仍然会被构造函数调用,以免破坏现有代码的逻辑。然而,如今咱们在测试过程当中只可以传递一个布尔值 false 给构造函数,以免调用 init()
方法和全部没必要要的初始化逻辑。类的这种重构也会改进代码,由于咱们将初始化逻辑从对象的构造函数分离出来了。
正如咱们在前一节介绍的,形成测试困难的大量类设计问题都集中在初始化各类不须要测试的对象上。在前面,咱们知道繁重的初始化逻 辑可能会给测试的编写形成很大的负担(特别是当测试彻底不须要这些对象时),可是若是咱们在测试的类方法中直接建立这些对象,又可能形成另外一个问题。清单 7显示的就是可能形成这个问题的示例代码。
<?php class MyUserClass { public function getUserList() { $dbconn = new DatabaseConnection('localhost','user','password'); $results = $dbconn->query('select name from user'); sort($results); return $results; } }
假设咱们正在测试上面的 getUserList
方法,可是咱们的测试关注点是保证返回的 用户清单是按字母顺序正确排序的。在这种状况下,咱们的问题不在因而否可以从数据库获取这些记录,由于咱们想要测试的是咱们是否可以对返回的记录进行排 序。问题是,因为咱们是在这个方法中直接实例化一个数据库链接对象,因此咱们须要执行全部这些繁琐的操做才可以完成方法的测试。所以,咱们要对方法进行修 改,使这个对象能够在中间插入,如 清单 8所示。
<?php class MyUserClass { public function getUserList($dbconn = null) { if ( !isset($dbconn) || !( $dbconn instanceOf DatabaseConnection ) ) { $dbconn = new DatabaseConnection('localhost','user','password'); } $results = $dbconn->query('select name from user'); sort($results); return $results; } }
如今您能够直接传入一个对象,它与预期数据库链接对象相兼容,而后直接使用这个对象,而非建立一个新对象。您也能够传 入一个模拟对象,也就是咱们在一些调用方法中,用硬编码的方式直接返回咱们想要的值。在这里,咱们能够模拟数据库链接对象的查询方法,这样咱们就只须要返 回结果,而不须要真正地去查询数据库。进行这样的重构也可以改进这个方法,由于它容许您的应用程序在须要时插入不一样的数据库链接,而不是只绑定一个指定的 默认数据库链接。
显然,编写更具可测试性的代码确定可以简化 PHP 应用程序的单元测试(正如您在本文展现的例子中所看到的),可是在这个过程当中,它也可以改进应用程序的设计、模块化和稳定性。咱们都曾经看到过 “spaghetti” 代码,它们在 PHP 应用程序的一个主要流程中充斥了大量的业务和表现逻辑,这毫无疑问会给那些使用这个应用程序的人形成严重的支持问题。在使代码变得更具可测试性的过程当中, 咱们对前面一些有问题的代码进行了重构;这些代码不只设计上有问题,功能上也有问题。经过使这些函数和类的用途更普遍,以及经过删除硬编码的依赖性,咱们 使之更容易被应用程序其余部分重用,咱们提升了代码的可重用性。此外,咱们还将编写不当的代码替换成更优质的代码,从而简化未来对代码库的支持。
在本文中,经过 PHP 应用程序中一些典型的不可测试代码示例,咱们了解了如何改进 PHP 代码的可测试性。咱们还介绍了这些状况是如何出如今应用程序中的,而后介绍了如何恰当地修复这些问题代码来便于进行测试。咱们还了解了这些代码的修改不只 可以提升代码的可测试性,也可以广泛改进代码的质量,以及提升重构代码的可重用性。