上一篇文章中,我介紹了如何把 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 上,請大家自行下載參考。