隨着 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 代碼的可測試性。咱們還介紹了這些狀況是如何出如今應用程序中的,而後介紹瞭如何恰當地修復這些問題代碼來便於進行測試。咱們還了解了這些代碼的修改不只 可以提升代碼的可測試性,也可以廣泛改進代碼的質量,以及提升重構代碼的可重用性。