Symfony Advent Calendar 2021 8日目の記事です
何か分からないことがあるとついググってしまいます。
ググると色々なところで提供していただいている情報にアクセスできて問題解決になることが多いのですが、変にハマってしまうことや、間違った知識(あるいは古い知識)を元に開発を進めてしまうリスクもあります。
Symfony のドキュメントはとても充実していて、理解しやすくできています。
ググる前に、公式ドキュメントを調べることをしたほうがいいよという、僕の失敗からの教訓です。
ここで書くことは、ほぼほぼ Symfony 公式の Testing の記事を見ればわかることです。
テストには
- Unit Test: 単体テスト
- Integration Test: 結合テスト
- application Test: アプリケーションテスト
があるのはご存知のことと思います。
この記事で主に対象とするのは Integration Test と Application Test についてです。
KernelTestCase
Symfony の最も素晴らしいところは、サービス・コンテナだと思っています。
それのお陰で、単にコンストラクタの引数に他のクラスを指定するだけで、そのクラスを利用することができてしまいます。
単体テストを書く場合は、モックやスタブを用意してコンストラクタに渡してやればテストができます。
結合テストを書く場合は、KernelTestCase
を使って、サービス・コンテナに依存するクラスを注入して貰えば、結合テストを書くことができます。
// tests/Service/NewsletterGeneratorTest.php
namespace App\Tests\Service;
use App\Service\NewsletterGenerator;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class NewsletterGeneratorTest extends KernelTestCase
{
public function testSomething()
{
// (1) Symfony のカーネルをブートします
self::bootKernel();
// (2) static::getContainer() を使ってサービス・コンテナを取得します
$container = static::getContainer();
// (3) get メソッドでサービスを取得してテストします
$newsletterGenerator = $container->get(NewsletterGenerator::class);
$newsletter = $newsletterGenerator->generateMonthlyNews(...);
$this->assertEquals(..., $newsletter->getContent());
}
}
static::getContainer()
で取得したコンテナでは、本来 get
で取得できないプライベートなサービスも取得できます。
tests/Service/NewsletterGeneratorTest.php (公式より)
static::getContainer()
で取得したコンテナでは、本来 get
で取得できないはずのプライベートなサービスも取得できます。
Doctrine の Repository や Doctrine の機能を利用したクラスを書いた場合、それらのテストを書くときには、Doctrine のあれこれをモックして書く、という方法が考えられると思います。
例えば、QueryBuilder を使ってデータベースから必要なデータを取得するためのコードがそのクラスにあるとして、その単体テストを書くとき。QueryBuilder をモックして、そのクラスの挙動をテストしようとしたとします。そうするとfrom や select などの QueryBuilder の様々なメソッドの挙動をつなげた、巨大な Arrange セクションが必要になり、一体何をテストしているのか分からないようなテストコードが出来上がります。
<?php
$qb = self::createMock('QueryBuilder');
$qb->expect(self::once())
->method('select')
->with(... )
->willReturn(...);
$qb->expect(self::once())
->method('from')
->with(... )
->willReturn(...);
:
(延々と続く)
:
もう自分のコードをテストしているのか QueryBuilder のテストを書いているのか分からなくなるような状態です。また、そのテストは実装に依存したものになってしまいがちです。
ですから、そういったクラスのテストを書く場合は、実際にデータベースを利用してテストを書くほうが理にかなっています。Repository に直接ロジックを書いた場合に、その Repository のコードをテストする場合もやはりデータベースを利用したものとなるでしょう。
そんな時も KernelTestCase
が使えます。
Fixture
テストで実際にデータベースを利用する場合に問題になるのは、テストの前提となるデータと速度です。
テスト用のデータを作成するには、Doctrine Fixture Bundle を使います。
Fixture を作成しておくと、次のコマンドを実行するとそのデータをデータベースに投入することができます。
php bin/console doctrine:fixtures:load -e test
速度を速くするためには、データベースに Sqlite を使います。
テストの時に使用するデータベースを変更するのです。
doctrine:
dbal:
driver: pdo_sqlite
path: "%kernel.cache_dir%/test.db"
url: null
config/packages/test/doctrine.yaml
実際にRDBを使うよりも結構速くなります。
読み取りに関するテストであれば、doctrine:fixtures:load
コマンドを事前に実行しておいて、テストを回せばいいのですが、更新を伴うテストの場合は、データが変更されてしまうので、それ以降のテストの状態が変わってしまいます。
どうしたものかと考えて、いつものようにググりました。そうしたら liip/test-fixtures-bundle
というのを見つけましたので使ってみました。
これは、テストの中で fixture のロードができるものです。
この bundle を使って setUp
メソッドなどに fixture をロードするコードを追加してやれば、テスト開始時のデータを一定に保つことができます。
ところが、ご想像の通りこれは遅いんですね。Sqlite を使ってても全然遅い😅
テストメソッドごとにデータを入れ直すわけですから当然遅くなってしまいます。
公式のドキュメントを読んでいましたらいいことが書いてありました。Resetting the Database Automatically Before each Test
DAMADoctrineTestBundle は Doctrine のトランザクションを使い、各テストが変更されていないデータベースとやりとりできるようにします。
DAMADoctrineTestBundleは、テストの前にトランザクションを開始して、テストが終わる時にはロールバックする仕組みです。テスト中に変更された結果は teardown の時にロールバックされますので、テストごとに fixture をロードする必要がありません。
これでテストは非常に快適な速度で実行されるようになりました。(最初から公式読んでおけば良かった)
コントローラーのテスト
コントローラーのテストということは、つまりアプリケーションの最初(リクエスト)から最後(レスポンス)までの通しのテスト、つまりアプリケーション・テストということになります。
その場合には、WebTestCase
を使うことになります。
// tests/Controller/PostControllerTest.php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class PostControllerTest extends WebTestCase
{
public function testSomething(): void
{
// KernelTestCase::bootKernel() を呼び出して
// ブラウザの役目をする "client" を生成します
$client = static::createClient();
// テストしたいページにリクエストを送)
// リクエストが成功することと内容を確認します
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h1', 'Hello World');
}
}
Symfony 公式より
static::createClient()
で KernelBrowser
という Web クライアントを生成してリクエストを送ります。
その結果を、各種のアサーションで確認するという流れです。
セキュリティを有効にしたコントローラーのテスト
僕が今作っているシステムでは、JWT を使った認証を採用することにしました。
LexikJWTAuthenticationBundle を利用することにしました。
テストの際に認証を通す方法がドキュメントに書いてありました。
/**
* Create a client with a default Authorization header.
*
* @param string $username
* @param string $password
*
* @return \Symfony\Bundle\FrameworkBundle\Client
*/
protected function createAuthenticatedClient($username = 'user', $password = 'password')
{
$client = static::createClient();
$client->request(
'POST',
'/api/login_check',
array(),
array(),
array('CONTENT_TYPE' => 'application/json'),
json_encode(array(
'_username' => $username,
'_password' => $password,
))
);
$data = json_decode($client->getResponse()->getContent(), true);
$client->setServerParameter('HTTP_Authorization', sprintf('Bearer %s', $data['token']));
return $client;
}
LexikJWTAuthenticationBundle のドキュメントより
このサンプルを真似て、テストを実行してみましたが、とっても遅いのです。
ローカルで動かしているとこの LexikJWTAuthenticationBundle の認証には結構な時間がかるんですね(何か環境が悪いのかもしれません)とてもじゃないけどテストが回せない感じになってしまいました。
これを助けてくれたのも Symfony 公式ドキュメント でした。
// tests/Controller/ProfileControllerTest.php
namespace App\Tests\Controller;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ProfileControllerTest extends WebTestCase
{
// ...
public function testVisitingWhileLoggedIn()
{
// クライアントの生成
$client = static::createClient();
$userRepository = static::getContainer()->get(UserRepository::class);
// テスト用のユーザーを取り出す
$testUser = $userRepository->findOneByEmail('john.doe@example.com');
// ユーザーがログインしたことにする
$client->loginUser($testUser);
// profile ページをテストする
$client->request('GET', '/profile');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h1', 'Hello John!');
}
}
Symfony 公式より
このように client->loginUser()
を使う方法が載っていました。
早速、この方法でテストを書き直してみると、爆速ですw。
また、公式ドキュメントに助けられました。
この方法はどの認証方法でも使えるみたいですしね。とても便利です。
まとめ
色々困ったことや分からないことがあったら、まず Symfony 公式ドキュメントを読もう!