在 Laravel 中使用 Behat 來加強測試的可讀性 - 進階篇
在上一篇文章中,我介紹了如何把 Behat 整合到 Laravel 裡;不過後來我發現在專案規格越來越複雜時,把所有 step definitions 都寫在 FeatureContext
類別中變得非常不易閱讀。另一個問題就是我不想要用 putenv
函式來定義環境變數,而是希望能有 Behat-Laravel-Extension 把環境變數放在 .env.behat
裡的用法。
而經過不斷地改良後,我找到了一個目前我很滿意的做法;所以接下來我會延續上一篇的範例來介紹新的做法。
改良環境改數的設定方式
為了讓 Behat 執行時可以讀取 .env.behat
,我們需要在 Application 初始化時載入新的環境設定檔案。因此我們就不能直接用 Laravel 提供的 Tests\CreatesApplication
這個 trait 了,因為它預設會載入 .env
檔;這時這也表示我們不再需要直接繼承 Tests/TestCase
這個類別,因為它也只是使用了 Tests\CreatesApplication
這個 trait 。
取而代之的是我們改為繼承 Illuminate\Foundation\Testing\TestCase
這個類別,然後自行覆寫 createApplication
這個方法:
<?php
use Behat\Behat\Context\Context;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Foundation\Testing\TestCase;
class FeatureContext extends TestCase implements Context
{
// ...
protected const ENV_FILE = '.env.behat';
/**
* @return \Illuminate\Foundation\Application
*/
public function createApplication()
{
$app = require __DIR__ . '/../../bootstrap/app.php';
$app->loadEnvironmentFrom(self::ENV_FILE);
$app->make(Kernel::class)->bootstrap();
return $app;
}
可以看到新的 createApplication
方法主要是在 Kernel::bootstrap
之前,讓 Application 改讀 .env.behat
。
接下加入 .env.behat
,內容可參考 phpunit.xml
裡的 <php>
區段設定,例如:
APP_ENV=testing
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
BROADCAST_DRIVER=log
CACHE_DRIVER=array
SESSION_DRIVER=array
BCRYPT_ROUNDS=4
QUEUE_DRIVER=sync
分類 Step definitions
當 step definitions 很多時,通通都放在 FeatureContext
類別裡就不是個明智的做法了。所以接下來我依照 step definitions 的類型來建立不同的 Context 檔,這樣維護起來也很方便。
首先我們要在專案根目錄下建立一個 behat.yml
檔, Behat 在執行時會讀取它裡面的設定:
default:
suites:
default:
contexts:
- ApiFeatureContext
- DatabaseAssertionContext
然後我們執行:
vendor/bin/behat --init
這麼一來 Behat 會幫我們自動產生所有 context 檔案:
+f features/bootstrap/ApiFeatureContext.php - place your definitions, transformations and hooks here
+f features/bootstrap/DatabaseAssertionContext.php - place your definitions, transformations and hooks here
註:雖然這裡只針對範例拆分,但你可以加入其它的 context 檔,後面我會給一些例子。
接著編輯每個 context 檔,先讓它們繼承 FeatureContext
類別,並拿掉 __construct
建構子;然後再把原來放在 FeatureContext
類別裡的 step definitions 搬到對應的 context 類別裡。
API 相關的 step definitions 放在 ApiFeatureContext
類別:
<?php
use Behat\Gherkin\Node\TableNode;
class ApiFeatureContext extends FeatureContext
{
/**
* @var string
*/
private $apiUrl = '';
/**
* @var array
*/
private $apiBody = [];
/**
* @var \Illuminate\Foundation\Testing\TestResponse
*/
private $response;
/**
* @Given API 網址為 :apiUrl
* @param string $apiUrl
*/
public function apiUrl(string $apiUrl)
{
$this->apiUrl = $apiUrl;
}
/**
* @Given API 附帶資料為
* @param TableNode $table
*/
public function apiBody(TableNode $table)
{
$this->apiBody = $table->getHash()[0];
}
/**
* @When 以 :method 方法要求 API
* @param string $method
*/
public function request(string $method)
{
$this->response = $this->json($method, $this->apiUrl, $this->apiBody);
}
/**
* @Then 回傳狀態應為 :statusCode
* @param int $statusCode
*/
public function assertStatus(int $statusCode)
{
$this->response->assertStatus($statusCode);
}
}
資料庫相關的 step definitions 放在 DatabaseAssertionContext
類別:
<?php
use Behat\Gherkin\Node\TableNode;
class DatabaseAssertionContext extends FeatureContext
{
/**
* @Then 資料表 :tableName 應有資料
* @param string $tableName
* @param TableNode $table
*/
public function assertTableRecordExisted(string $tableName, TableNode $table)
{
$this->assertDatabaseHas($tableName, $table->getHash()[0]);
}
}
當然不僅 API 和資料庫可以拆分,例如建立 Model 資料、 Event 或 Queue 相關的 step definitions 可以這樣做。
Model Factory :
<?php
class ModelFactoryContext extends FeatureContext
{
/**
* @Given 存在使用者 :name
* @param string $name
*/
public function user(string $name)
{
factory(\App\User::class)->create([
'name' => $name,
]);
}
}
Event 相關:
<?php
use App\Events\UserCreated;
use Illuminate\Support\Facades\Event;
class EventAssertionContext extends FeatureContext
{
/**
* @BeforeScenario
*/
public function setUpFake(): void
{
Event::fake();
}
/**
* @Then 應發送事件「已新增用戶」
*/
public function assertDeployStatusCreatedEventDispatched()
{
Event::assertDispatched(UserCreated::class, 1);
}
}
其他就請大家自行發揮了。
進一步改良
上述的調整有個缺點,就是 FeatureContext::before
方法及 FeatureContext::after
方法都會初始化 Context 類別時都跑一次,但每個情境在執行時都會再次初始化所有 Context 類別;換句話說每個情境在執行時,有幾個 Context 類別,就會執行幾次 FeatureContext::before
方法及 FeatureContext::after
方法。這樣一來 $this->app
就會被重複初始化,徒然浪費執行時間。
我們希望 FeatureContext::before
方法及 FeatureContext::after
方法在每個情境執行前後各執行一次就好,可是每個 context 物件實體因為作用域的關係,它們拿到的 $this->app
都不會是同一個;這時也需要一個機制來記住已經被初始化的 $this-app
,才不用每個 context 都做一次。
綜合上述的想法,最後完整的 FeatureContext
類別如下:
<?php
use Behat\Behat\Context\Context;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\TestCase;
abstract class FeatureContext extends TestCase implements Context
{
use RefreshDatabase;
protected const ENV_FILE = '.env.behat';
/**
* @var \Illuminate\Foundation\Application
*/
protected static $contextSharedApp;
/**
* @return \Illuminate\Foundation\Application
*/
public function createApplication()
{
$app = require __DIR__ . '/../../bootstrap/app.php';
$app->loadEnvironmentFrom(self::ENV_FILE);
$app->make(Kernel::class)->bootstrap();
return $app;
}
/**
* @BeforeScenario
*/
public function before(): void
{
if (!static::$contextSharedApp) {
parent::setUp();
static::$contextSharedApp = $this->app;
} else {
$this->app = static::$contextSharedApp;
}
}
/**
* @AfterScenario
*/
public function after(): void
{
if (static::$contextSharedApp) {
parent::tearDown();
static::$contextSharedApp = null;
}
}
}
相信上面的程式碼應該很簡單,這邊就不再多做說明了。最後我一樣把範例放在 GitHub 上,請大家自行下載參考。