在計(jì)算機(jī)編程中,單元測(cè)試(英語(yǔ):Unit Testing)又稱(chēng)為模塊測(cè)試, 是針對(duì)程序模塊(軟件設(shè)計(jì)的最小單位)來(lái)進(jìn)行正確性檢驗(yàn)的測(cè)試工作开缎。程序單元是應(yīng)用的最小可測(cè)試部件裸卫。在過(guò)程化編程中哗蜈,一個(gè)單元就是單個(gè)程序、函數(shù)涝焙、過(guò)程等卑笨;對(duì)于面向?qū)ο缶幊蹋钚卧褪欠椒刈玻ɑ?lèi)(超類(lèi))赤兴、抽象類(lèi)、或者派生類(lèi)(子類(lèi))中的方法隧哮。
本文主要是根據(jù)PHPUnit(The PHP Testing Framework)文檔結(jié)合實(shí)例簡(jiǎn)單介紹一下 PHP 單元測(cè)試中 mock 和數(shù)據(jù)庫(kù)測(cè)試桶良。如果你是初次接觸單測(cè)的話建議先看一下 PHPUnit 文檔中的入門(mén)章節(jié)。
通常來(lái)說(shuō)是開(kāi)發(fā)程序和單測(cè)是同步進(jìn)行的近迁,項(xiàng)目提測(cè)的時(shí)候核心模塊都需要包括單測(cè)(報(bào)告)艺普,但這個(gè)要求在不同的公司、部門(mén)、項(xiàng)目組要求不一樣歧譬。雖然單測(cè)會(huì)占用一定的開(kāi)發(fā)時(shí)間但總的來(lái)說(shuō)單測(cè)是利遠(yuǎn)大于弊岸浑,最大的好處是自己或者別人后續(xù)更新模塊功能時(shí)不用擔(dān)心對(duì)原有功能造成了影響而不知情。
下面是一個(gè)具體的例子瑰步,在 web 開(kāi)發(fā)中這樣一個(gè)場(chǎng)景可能很常見(jiàn):PHP 提供一個(gè)帳號(hào)注冊(cè)的接口供前端調(diào)用矢洲,接口先檢驗(yàn)一下此用戶(hù)名是否已經(jīng)存在,不存在的話插入數(shù)據(jù)庫(kù)缩焦,返回注冊(cè)成功读虏。接口代碼是這樣的:
<?php
require_once "lib/Join.php";
require_once "lib/Db.php";
use web\lib\Db;
use web\lib\Join;
_POST['username'];
_POST['password'];
$db = new Db('user');
db->connect());
echo json_encode(username, $password));
主要調(diào)用了 Join 類(lèi)的 signIn 方法。我們來(lái)看看 Join 類(lèi)是啥樣:
<?php
namespace web\lib;
require_once "Db.php";
class Join
{
private $db;
function __construct(Db $db)
{
$this->db = $db;
}
public function signIn($userName, $password)
{
if ($this->db->exists('user', ['username' => $userName])) {
return [
'code' => 1,
'msg' => "user has exists",
];
}
else {
$this->db->insert('user', [
'username' => $userName,
'password' => $password,
]);
return [
'code' => 0,
'msg' => "success",
];
}
}
}
|
邏輯很簡(jiǎn)單袁滥,先調(diào)用 Db 類(lèi)的 exists 方法判斷用戶(hù)名是否存在盖桥,不存在的話使用 insert 方法插入數(shù)據(jù)。Join 類(lèi)是這次業(yè)務(wù)新加的题翻,比較重要揩徊,需要單測(cè)來(lái)保障質(zhì)量,但這里用到了個(gè) Db 類(lèi)嵌赠,這個(gè)庫(kù)是以前就有的(坑)塑荒,可能會(huì)影響本模塊單測(cè)的正確性,而且 Db 類(lèi)需要連接數(shù)據(jù)庫(kù)姜挺,比較麻煩齿税,這種場(chǎng)景就需要 mock 了。本文說(shuō)的 mock 是廣義上的炊豪,包括 Stubs(樁件)和仿件對(duì)象(Mock Object)凌箕。
將對(duì)象替換為(可選地)返回配置好的返回值的測(cè)試替身的實(shí)踐方法稱(chēng)為上樁(stubbing)×镌冢可以用樁件(stub)來(lái)“替換掉被測(cè)系統(tǒng)所依賴(lài)的實(shí)際組件陌知,這樣測(cè)試就有了對(duì)被測(cè)系統(tǒng)的間接輸入的控制點(diǎn)他托。這使得測(cè)試能強(qiáng)制安排被測(cè)系統(tǒng)的執(zhí)行路徑掖肋,否則被測(cè)系統(tǒng)可能無(wú)法執(zhí)行”。
將對(duì)象替換為能驗(yàn)證預(yù)期行為(例如斷言某個(gè)方法必會(huì)被調(diào)用)的測(cè)試替身的實(shí)踐方法稱(chēng)為模仿(mocking)赏参。
我們這里應(yīng)用的是打樁的概念志笼。signIn 方法有兩個(gè)分支:用戶(hù)名存在和不存在。所以我們需要讓 Db 類(lèi)的 exists 方法在輸入某個(gè)(些)用戶(hù)名的時(shí)候返回 true把篓。主要使用 PHPUnit_Framework_TestCase
類(lèi)提供的 getMockBuilder()
方法來(lái)建立一個(gè)樁件對(duì)象:
// 為Db類(lèi)創(chuàng)建樁件
this->getMockBuilder('web\lib\Db')
->getMock();
代碼看上去很像是實(shí)例化了一個(gè)類(lèi)纫溃,其實(shí)原理也和這個(gè)差不多,PHPUnit 通過(guò)反射機(jī)制獲取到類(lèi)及其方法的信息韧掩,然后使用內(nèi)置模板生成一個(gè)新類(lèi)紊浩。我們需要 mock 掉 insert
和 exists
方法:
this->getMockBuilder('web\lib\Db')
->disableOriginalConstructor()
->setMethods(['insert', 'exists'])
->getMock();
這里使用了樁件生成器的 setMethods()
方法來(lái)設(shè)置哪些方法被上樁,以下是生成器提供的方法列表:
-
setMethods(array $methods)
可以在仿件生成器對(duì)象上調(diào)用,來(lái)指定哪些方法將被替換為可配置的測(cè)試替身坊谁。其他方法的行為不會(huì)有所改變费彼。如果調(diào)用setMethods(null)
,那么沒(méi)有方法會(huì)被替換口芍。 -
setConstructorArgs(array $args)
可用于向原版類(lèi)的構(gòu)造函數(shù)(默認(rèn)情況下不會(huì)被替換為偽實(shí)現(xiàn))提供參數(shù)數(shù)組箍铲。 -
setMockClassName($name)
可用于指定生成的測(cè)試替身類(lèi)的類(lèi)名。 -
disableOriginalConstructor()
參數(shù)可用于禁用對(duì)原版類(lèi)的構(gòu)造方法的調(diào)用鬓椭。 -
disableOriginalClone()
可用于禁用對(duì)原版類(lèi)的克隆方法的調(diào)用颠猴。 -
disableAutoload()
可用于在測(cè)試替身類(lèi)的生成期間禁用__autoload()
。
然后分別設(shè)置兩個(gè)方法的參數(shù)和返回值小染。這里 insert
操作比較簡(jiǎn)單翘瓮,可以用 willReturn($value)
返回簡(jiǎn)單值:
$db->method('insert')
->willReturn(true);
上面的例子中,使用了 willReturn($value)
返回簡(jiǎn)單值裤翩。這個(gè)簡(jiǎn)短的語(yǔ)法相當(dāng)于 will($this->returnValue($value))
春畔。而在這個(gè)長(zhǎng)點(diǎn)的語(yǔ)法中,可以使用變量岛都,從而實(shí)現(xiàn)更復(fù)雜的上樁行為律姨。我們這里的需求是需要根據(jù)預(yù)定義的參數(shù)清單來(lái)返回不同的值,顯然這是一個(gè)映射(map)臼疫,PHPUnit 提供現(xiàn)成的 returnValueMap()
方法來(lái)做這個(gè)事情:
// mock method multiple calls with different arguments
$map = [
['user', ['username' => 'yaozhen'], true],
[$this->anything(), $this->anything(), false],
];
$db->method('exists')
->will($this->returnValueMap($map));
完整的單測(cè)代碼:
<?php
namespace web\test;
require_once "../lib/Join.php";
use web\lib\Join;
use PHPUnit_Framework_TestCase;
class JoinTest extends PHPUnit_Framework_TestCase
{
public function testSignIn()
{
// class mock
$db = $this->getMockBuilder('web\lib\Db')
->disableOriginalConstructor()
->setMethods(['insert', 'exists'])
->getMock();
// function mock
$db->method('insert')
->willReturn(true);
// mock method multiple calls with different arguments
$map = [
['user', ['username' => 'yaozhen'], true],
[$this->anything(), $this->anything(), false],
];
$db->method('exists')
->will($this->returnValueMap($map));
$join = new Join($db);
$this->assertEquals(['code' => 1, 'msg' => 'user has exists'], $join->signIn('yaozhen', 'pwd'));
$this->assertEquals(['code' => 0, 'msg' => 'success'], $join->signIn('zhangsan', 'pwd'));
}
}
|
這樣就可以很好的測(cè)試 signIn
接口而不用擔(dān)心被依賴(lài)的第三方接口/類(lèi)庫(kù)影響择份。當(dāng)然這只是個(gè)很小的例子,實(shí)際操作比這個(gè)復(fù)雜烫堤,但基本原理類(lèi)似荣赶,結(jié)合實(shí)際、對(duì)照文檔一般的問(wèn)題就能解決了鸽斟,若不能簡(jiǎn)單的解決則說(shuō)明代碼不夠優(yōu)雅拔创,可測(cè)性低,上面的例子也可以看出 db 實(shí)例是通過(guò)依賴(lài)注入的方式當(dāng)做參數(shù)傳入到 Join
類(lèi)中富蓄,這樣即降低了代碼間的耦合也提高了可測(cè)性剩燥。
不過(guò)需要注意的是 final
、private
和 static
方法無(wú)法(其實(shí)可以立倍,但 PHPUnit 不支持)對(duì)其進(jìn)行上樁(stub)或模仿(mock)灭红,關(guān)于這一點(diǎn)是個(gè)經(jīng)常被問(wèn)到的問(wèn)題。不支持 private 方法是因?yàn)檎G闆r下你不需要這樣做口注,因?yàn)槟愣紵o(wú)法調(diào)用私有方法变擒,參考:http://stackoverflow.com/questions/5937845/mock-private-method-with-phpunit。不支持 static 方法是因?yàn)?a target="_blank" rel="nofollow">靜態(tài)方法死于可測(cè)性:靜態(tài)方法調(diào)用很靈活寝志,如果一個(gè)靜態(tài)調(diào)用另一個(gè)靜態(tài)方法就沒(méi)辦法控制被調(diào)方法的依賴(lài)娇斑。其實(shí)在早期版本(<3.9)中是支持靜態(tài)方法的 mock 的策添,但后來(lái)作者經(jīng)過(guò)考慮移除了這一功能(staticExpects 方法),也不提供其它方法支持這一功能:
當(dāng)然這些限制都是可以突破的毫缆,但不建議這樣做舰攒,還是好好改改你的代碼吧,提高可測(cè)性悔醋,這也是單測(cè)帶來(lái)的收益之一摩窃,讓你發(fā)現(xiàn)代碼中不足之處。
上面我們通過(guò)單測(cè)保障了 Join 類(lèi)的代碼質(zhì)量芬骄,但 Db 類(lèi)老是有 bug猾愿,而且每次改動(dòng)還擔(dān)心影響之前的功能。現(xiàn)在考慮把 Db 類(lèi)也加上單測(cè)账阻,但數(shù)據(jù)庫(kù)測(cè)試如何做呢蒂秘?其實(shí)就和平常的測(cè)試一樣,連上數(shù)據(jù)庫(kù)淘太,然后運(yùn)行一下代碼姻僧,最后查看庫(kù)里的數(shù)據(jù)是否符合預(yù)期。PHPUnit 將這個(gè)過(guò)程抽象了出來(lái)蒲牧,提供了相應(yīng)的方法撇贺,下面是具體例子:
一般使用 PHPUnit 時(shí)都是繼承 PHPUnit_Framework_TestCase
方法,但這里是繼承 PHPUnit_Extensions_Database_TestCase
并還需實(shí)現(xiàn)兩個(gè)抽象方法冰抢,getConnection()
和 getDataSet()松嘶。
實(shí)現(xiàn) getConnection():為了讓清理與載入基境的功能正常運(yùn)作,PHPUnit 數(shù)據(jù)庫(kù)擴(kuò)展模塊需要用 PDO 庫(kù)來(lái)實(shí)現(xiàn)跨供應(yīng)商抽象訪問(wèn)數(shù)據(jù)庫(kù)連接挎扰。重要的是要注意到翠订,使用 PHPUnit 的數(shù)據(jù)庫(kù)擴(kuò)展模塊并不要求應(yīng)用程序本身基于 PDO,PDO 連接僅僅用于清理和建立基境遵倦。簡(jiǎn)單來(lái)說(shuō)就是連接上測(cè)試的數(shù)據(jù)庫(kù):
/**
connect Database
@return \PHPUnit_Extensions_Database_DB_DefaultDatabaseConnection
*/
public function getConnection()
{
$db = new PDO("mysql:host=127.0.0.1;dbname=user", "root", "");
return $this->createDefaultDBConnection($db, "user");
}
|
實(shí)現(xiàn) getDataSet():getDataSet()
方法定義了在每個(gè)測(cè)試執(zhí)行之前的數(shù)據(jù)庫(kù)初始狀態(tài)應(yīng)該是什么樣尽超。簡(jiǎn)單來(lái)說(shuō)就是每個(gè)單測(cè) case 運(yùn)行之前數(shù)據(jù)庫(kù)的基境。
什么是基境(fixture)梧躺?
基境(fixture)是對(duì)開(kāi)始執(zhí)行某個(gè)測(cè)試時(shí)應(yīng)用程序和數(shù)據(jù)庫(kù)所處初始狀態(tài)的描述似谁。
那么如何創(chuàng)建一個(gè)數(shù)據(jù)集(DataSet)呢,主要有 3 中方法:
- 基于文件的 DataSet 和 DataTable
- 基于查詢(xún)的 DataSet 和 DataTable
- 篩選與組合 DataSet 和 DataTable
這里說(shuō)一下最常用的基于文件的數(shù)據(jù)集燥狰,其它幾種方法的使用請(qǐng)查看文檔棘脐。
XML DataSet (XML 數(shù)據(jù)集):在根節(jié)點(diǎn) <dataset>
內(nèi),可以指定 <table>
龙致、<column>
、<row>
顷链、<value>
和 <null />
標(biāo)簽目代。例:
<dataset>
<table name="user">
<column>id</column>
<column>username</column>
<column>password</column>
<row>
<value>1</value>
<value>yaozhen</value>
<value>yaozhenpwd</value>
</row>
<row>
<value>2</value>
<value>yaozhen2</value>
<value>yaozhenpwd2</value>
</row>
</table>
</dataset>
/**
Set up fixture
@return \PHPUnit_Extensions_Database_DataSet_XmlDataSet
*/
public function getDataSet()
{
return $this->createXMLDataSet("user.xml");
}
|
注:PHPUnit 假設(shè)在測(cè)試運(yùn)行之前數(shù)據(jù)庫(kù)以及其中的所有表(table)、觸發(fā)器(trigger)、序列(Sequence)和視圖(view)都已經(jīng)創(chuàng)建好榛了。這意味著開(kāi)發(fā)者必須在運(yùn)行測(cè)試套件之前確保數(shù)據(jù)庫(kù)已經(jīng)正確建立在讶。每個(gè)測(cè)試方法運(yùn)行之前都會(huì)調(diào)用此方法清空數(shù)據(jù)庫(kù)中的數(shù)據(jù),然后導(dǎo)入設(shè)置好的數(shù)據(jù)集霜大。
基礎(chǔ)數(shù)據(jù)設(shè)置好后就可以進(jìn)行實(shí)際的操作了构哺,正常實(shí)例化 Db 類(lèi)然后調(diào)用 insert 方法插入數(shù)據(jù):
/**
- test insert one row
*/
public function testOneInsert()
{
$dbObj = new Db('user');
$dbObj->connect();
$dbObj->insert('user', [
'username' => 'yaozhen_new_insert',
'password' => 'yaozhenpwd',
]);
}
|
安裝單測(cè)的基本套路,現(xiàn)在就需要對(duì)結(jié)果進(jìn)行斷言了战坤,PHPUnit 提供了 3 類(lèi)數(shù)據(jù)斷言 API:
- 對(duì)表中數(shù)據(jù)行的數(shù)量作出斷言
- 對(duì)表的狀態(tài)作出斷言
- 對(duì)查詢(xún)的結(jié)果作出斷言
這里使用行數(shù)斷言和結(jié)果斷言:
/**
- test insert one row
*/
public function testOneInsert()
{
$dbObj = new Db('user');
$dbObj->connect();
$dbObj->insert('user', [
'username' => 'yaozhen_new_insert',
'password' => 'yaozhenpwd',
]);
// table rows num Assertions
$this->assertEquals(3, $this->getConnection()->getRowCount('user'), "Inserting failed");
// Asserting the State of Multiple Tables
$queryTable = $this->getConnection()->createQueryTable('user', "SELECT * FROM user");
$arrayDateSet = new My_DbUnit_ArrayDataSet(['user' => [
['id' => 1, 'username' => 'yaozhen', 'password' => 'yaozhenpwd'],
['id' => 2, 'username' => 'yaozhen2', 'password' => 'yaozhenpwd2'],
['id' => 3, 'username' => 'yaozhen_new_insert', 'password' => 'yaozhenpwd'],
],
]);
$expectedTable = $arrayDateSet->getTable("user");
$this->assertTablesEqual($expectedTable, $queryTable);
}
|
查看源碼很容易發(fā)現(xiàn) getRowCount()
方法在底層是通過(guò)執(zhí)行的 SELECT COUNT(*)
來(lái)計(jì)算行數(shù)曙强,createQueryTable()
方法底層也是執(zhí)行傳入的 SQL,可以看出這和我們平常手動(dòng)自測(cè)是一樣途茫。細(xì)心的讀者可能會(huì)發(fā)現(xiàn)數(shù)據(jù)結(jié)果斷言的時(shí)候使用了一個(gè) My_DbUnit_ArrayDataSet
類(lèi)碟嘴,這是一個(gè)自定義的數(shù)組 DataSet 類(lèi),方便產(chǎn)出自己期望的數(shù)據(jù)集囊卜,實(shí)現(xiàn)也非常簡(jiǎn)單:
<?php
namespace web\test;
use PHPUnit_Extensions_Database_DataSet_AbstractDataSet;
use PHPUnit_Extensions_Database_DataSet_DefaultTableMetaData;
use PHPUnit_Extensions_Database_DataSet_DefaultTable;
use PHPUnit_Extensions_Database_DataSet_DefaultTableIterator;
use InvalidArgumentException;
class My_DbUnit_ArrayDataSet extends PHPUnit_Extensions_Database_DataSet_AbstractDataSet
{
/**
* @var array
*/
protected $tables = array();
/**
* @param array $data
*/
public function __construct(array $data)
{
foreach ($data AS $tableName => $rows) {
$columns = array();
if (isset($rows[0])) {
$columns = array_keys($rows[0]);
}
$metaData = new PHPUnit_Extensions_Database_DataSet_DefaultTableMetaData($tableName, $columns);
$table = new PHPUnit_Extensions_Database_DataSet_DefaultTable($metaData);
foreach ($rows AS $row) {
$table->addRow($row);
}
$this->tables[$tableName] = $table;
}
}
protected function createIterator($reverse = FALSE)
{
return new PHPUnit_Extensions_Database_DataSet_DefaultTableIterator($this->tables, $reverse);
}
public function getTable($tableName)
{
if (!isset($this->tables[$tableName])) {
throw new InvalidArgumentException("$tableName is not a table in the current database.");
}
return $this->tables[$tableName];
}
}
|
數(shù)據(jù)庫(kù)測(cè)試基本的步驟也就是這些娜扇,其它幾個(gè)方法的單測(cè)很容易舉一反三:
<?php
namespace web\test;
require_once "../lib/Db.php";
require_once "My_DbUnit_ArrayDataSet.php";
use web\lib\Db;
use \PDO;
use PHPUnit_Extensions_Database_TestCase;
class DbTest extends PHPUnit_Extensions_Database_TestCase
{
/**
* connect Database
*
* @return \PHPUnit_Extensions_Database_DB_DefaultDatabaseConnection
*/
public function getConnection()
{
$db = new PDO("mysql:host=127.0.0.1;dbname=user", "root", "");
return $this->createDefaultDBConnection($db, "user");
}
/**
* Set up fixture
*
* @return \PHPUnit_Extensions_Database_DataSet_XmlDataSet
*/
public function getDataSet()
{
return $this->createXMLDataSet("user.xml");
}
public function testConnect()
{
$dbObj = new Db('user');
$this->assertNotFalse($dbObj->connect());
$dbObj = new Db('user', '127.0.0.1', 'root', '123456');
$dbObj->connect();
$this->assertFalse($dbObj->connect());
$this->assertEquals("Access denied for user 'root'@'localhost' (using password: YES)", $dbObj->getLastError());
}
/**
* test insert one row
*/
public function testOneInsert()
{
$dbObj = new Db('user');
$dbObj->connect();
$dbObj->insert('user', [
'username' => 'yaozhen_new_insert',
'password' => 'yaozhenpwd',
]);
// table rows num Assertions
$this->assertEquals(3, $this->getConnection()->getRowCount('user'), "Inserting failed");
// Asserting the State of Multiple Tables
$queryTable = $this->getConnection()->createQueryTable('user', "SELECT * FROM user");
$arrayDateSet = new My_DbUnit_ArrayDataSet(['user' => [
['id' => 1, 'username' => 'yaozhen', 'password' => 'yaozhenpwd'],
['id' => 2, 'username' => 'yaozhen2', 'password' => 'yaozhenpwd2'],
['id' => 3, 'username' => 'yaozhen_new_insert', 'password' => 'yaozhenpwd'],
],
]);
$expectedTable = $arrayDateSet->getTable("user");
$this->assertTablesEqual($expectedTable, $queryTable);
}
/**
* test isnert mutlti row
*
* @depends testOneInsert
*/
public function testMultiInsert()
{
$dbObj = new Db('user');
$dbObj->connect();
$dbObj->insert('user', [
[
'username' => true,
'password' => 'yaozhenpwd',
],
[
'username' => 'yaozhen_new_insert2',
'password' => null,
],
[
'username' => 'yaozhen_new_insert3',
'password' => 123,
],
]);
// table rows num Assertions
$this->assertEquals(5, $this->getConnection()->getRowCount('user'), "Inserting failed");
}
/**
* test exists func
*/
public function testExists()
{
$dbObj = new Db('user');
$dbObj->connect();
$result = $dbObj->exists('user', [
'username' => 'yaozhen',
'password' => 'yaozhenpwd',
]);
$this->assertTrue($result);
$result = $dbObj->exists('user', [
'username' => true,
'password' => null,
]);
$this->assertFalse($result);
}
}
|
至此,你已經(jīng)學(xué)會(huì)了基本的 Mock 和數(shù)據(jù)庫(kù)測(cè)試栅组。當(dāng)然雀瓢,實(shí)際中比這個(gè)更復(fù)雜,但萬(wàn)變不離其宗玉掸,掌握了基本套路致燥,其它的看看文檔也很快就能搞定了。
最后排截,本文中的代碼因?yàn)閷?shí)際生產(chǎn)環(huán)境 PHP 版本(=5.4)原因嫌蚤,使用的是 PHPunit 4.8 版本,如果你使用的是 5.X 版本那可能有點(diǎn)不一樣断傲,還需你對(duì)照手冊(cè)來(lái)參考脱吱。