開發 Laravel 套件時的單元測試
在官方手上的有關開發 Laravel 4 套件的章節,內容其實寫得滿詳盡了。只是它缺少了有關單元測試的說明,以下我將介紹一些自己的做法和經驗。
前置作業
我們可以用以下指令來建立一個新的 Laravel 套件:
php artisan workbench --resource vendor/name
然後在專案目錄下的 workbench/vendor/name
路徑下找到我們的專案原始檔。
註: vendor
可以是公司名稱或開發者的名字,而 name
則是套件名稱。這裡雖然直接以這個名稱當範例,但實務上請不要這麼設定。
我們會看到目錄結構如下:
.
├── composer.json
├── phpunit.xml
├── public
├── src
│ ├── Vendor
│ │ └── Name
│ │ └── NameServiceProvider.php
│ ├── config
│ ├── controllers
│ ├── lang
│ ├── migrations
│ └── views
└── tests
接下來不特別說明的話,所有操作都是在上述的路徑裡。
Composer 設定
我們假設套件會用到資料庫,所以第一步是把 illuminate/database
這個套件加進 composer.json
的 require
區段設定內:
"require": {
...
"illuminate/database": "4.0.x"
}
接著是 PHPUnit ,要將它寫在 require-dev
區段設定中:
"require-dev": {
...
"phpunit/phpunit": ">=3.7.0"
}
然後執行 composer update --prefer-dist
,以安裝資料庫套件。
Model 類別基本結構
這邊假設我們的 model 名稱為 Ranger
,它只有 id
和 name
兩個屬性。請在 src/Vendor/Name
這個路徑下建立一個 Ranger.php
,然後輸入以下內容:
namespace Vendor\Name;
use Illuminate\Database\Eloquent\Model as Eloquent;
use Illuminate\Database\Schema\Blueprint as Table;
class Ranger extends Eloquent
{
public static $tableName = 'rangers'; // 自行加入的屬性
protected $guarded = array(); // 視需求更改
/**
* @return callable
*/
public static function getBlueprint() // 自行加入的方法
{
return function (Table $table) {
$table->increments('id');
$table->string('name', 100);
};
}
}
Laravel 的 Model 預設會自行找出類別名稱與資料表名稱的對應,然後將它設定在 $table
這個屬性中;不過為了稍後在做 migration 和單元測試時的需求,我自行加了一個 static 變數: $tableName
。
另一個 static 方法 getBlueprint
是回傳一個 callback
給 Schema Builder 使用,在後面我們會在製作 migration 與單元測試時會用到。
單元測試
基本上單元測試的設定檔, Laravel 已經幫我們產生好了。我們只需要建立 model 對應的測試即可。
在 tests
目錄下再建立一個子資料夾 NameTest
,其中 Name
就是對應到我們的套件名稱。
然後在 composer.json
的 psr-0
區段中加入:
"psr-0": {
...
"Vendor\\NameTest": "tests/"
}
這會讓 composer 的自動載入找到我們的測試類別。
接著在 NameTest
資料夾中再建立一個 PHP 檔案 RangerTest.php
,內容如下:
namespace Vendor\NameTest;
use Vendor\Name\Ranger;
use Illuminate\Database\Capsule\Manager as DB;
class RangerTest extends \PHPUnit_Framework_TestCase
{
}
這邊比較關鍵的部份的是把 Illuminate\Database\Capsule\Manager
載入後,取別名為 DB
方便後續操作。
另外因為套件並沒辦法使用到 Laravel 在 application 中的 Facade 機制,所以直接用 \PHPUnit_Framework_TestCase
類別,而不是 Laravel 內建的 TestCase
類別。
資料庫與資料表設定
在測試中,通常較為麻煩的是測試 Model 與資料庫之間的溝通。其實我們可以直接提供一個測試用的資料庫讓測試使用。
測試用的資料庫可以是 Laravel 所支援的任一類型關連式資料庫,這裡我們選用 sqlite 。
接著在 RangerTest
類別中加入以下程式碼:
...
protected static $db = null;
public static function setUpBeforeClass()
{
static::connectTestDb();
static::initTables();
}
protected static function connectTestDb()
{
static::$db = new DB();
static::$db->addConnection(array(
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
));
static::$db->setAsGlobal();
static::$db->bootEloquent();
}
protected static function initTables()
{
$conn = static::$db->getConnection();
$builder = $conn->getSchemaBuilder();
$builder->dropIfExists(Ranger::$tableName);
$builder->create(Ranger::$tableName, Ranger::getBlueprint());
}
在 PHPUnit 中提供了 setUpBeforeClass
這個方法,主要是在所有測試開始前要先做的動作;我們利用它來初始化資料庫連線及產生我們需要資料表。
註: setUp
是在每個 test case 啟動前做。
connectTestDb
方法是對資料庫連線,讓接下來的 Model 可以直接操作,而不需要處理連線問題。這裡我們可以直接使用 sqlite 配合 :memory:
來使用記憶體當做測試資料庫,或是在 MySQL 或其他與正式機相同規格的資料庫伺服器上建立測試用的資料庫。
initTables
用來建立資料表,由於我們在 model 的 getBlueprint
中會回傳 Schema 的資訊,所以就直接利用它來建資料表。
開始測試
現在我們可以撰寫測試案例了,舉例如下:
...
public function testFind()
{
DB::table('rangers')->insert(array(
'name' => 'Jace Ju',
));
$ranger = Ranger::find(1);
$this->assertEquals('Jace Ju', $ranger->name);
}
接著開啟終端機,切換到 workbench/vendor/name
目錄下,執行:
phpunit
就可以看到測試結果了。
多個測試類別共用同一資料連線
目前我們是在 RangerTest
類別中連結資料庫,但通常我們會有很多 Model 需要被測試,所以需要共用上述的資料庫連結的部份。
先建立一個 TestCase
類別,它與 RangerTest
放在同一個目錄下。
再將先前的資料庫連結的部份複製到新的 TestCase
類別裡,成果如下:
namespace Vendor\NameTest;
use Illuminate\Database\Capsule\Manager as DB;
class TestCase extends \PHPUnit_Framework_TestCase
{
protected static $db = null;
public static function setUpBeforeClass()
{
static::connectTestDb();
static::initTables();
}
protected static function connectTestDb()
{
// ... 同上
}
protected static function initTables()
{
// ... 同上
}
}
在 initTables
方法中,可以初始化所有會用到的資料表。
然後回到 RangerTest
類別,將連結資料庫的部份移除,並改為繼承 TestCase
:
namespace Vendor\NameTest;
use Illuminate\Database\Capsule\Manager as DB;
class RangerTest extends TestCase
{
// ...
}
這時候如果執行 phpunit
的話,會發現找不到 TestCase
這個類別。
解決方法是在 composer.json
裡面告訴 composer 要去哪裡找這個類別檔案:
"classmap": [
"tests/NameTest/TestCase.php"
],
這麼一來, Model 與資料庫的測試就容易許多了。
Migration
雖然我們可以測試 model 了,但實際作業還是需要把資料表建立在正式資料庫上。這裡就要透過 Laravel 的 migration 機制。
這裡就簡單說明一下剛剛在 Ranger
類別建立的 callback 如何使用:
首先要為 workbench 中 package 建立 migrations 資料夾:
mkdir workbench/vendor/name/src/migrations
然後我們要為 package 建立一個 migration :
php artisan migrate:make create_rangers_table --bench=vendor/name
這會建立 workbench/vendor/name/src/migrations/xxxx_xx_xx_xxxxxx_create_rangers_table.php
這個檔案 (xx 會視建立時間而有所不同) ,其類別名稱為 CreateRangersTable
。
在 CreateRangersTable
中,我們可以直接利用先前在 Ranger
類別中定義的 $tableName
與 getBlueprint
來完成 migration 的 up
及 down
:
use Illuminate\Database\Migrations\Migration;
use Vendor\Name\Ranger;
class CreateRangersTable extends Migration
{
public function up()
{
Schema::create(Ranger::$tableName, Ranger::getBlueprint());
}
public function down()
{
Schema::drop(Ranger::$tableName);
}
}
這麼一來就不需要重複定義 schema 的程式碼了。
總結
最後歸納幾個重點:
-
將 Schema Builder 需要的 callback 放在 Model 中,讓測試與 migration 可以共用同一個 schema 。
-
利用 sqlite 的
:memory:
來做為測試資料庫是非常方便的。 -
將資料庫連結的程式碼移到共用類別中。
-
利用在 model 定義的
$tableName
與getBlueprint
來完成 table migration 。
歡迎指正或提供更好的建議。