概要
- laravel6にてFeatureテストのテストコードを記載した。マスタデータやテストに必要な関連テーブルのレコードをfactoryで作成してテストしている。
- テストクラスではDatabaseTransactionsトレイトをuseしているのでテストメソッド毎にトランザクションが貼られる。テストメソッドが走り切ったらそのトランザクション内部で変更が加えられたテーブルはロールバックする。
- テストそのものは問題なく完了するが何故かテスト完了後に一部のfactoryで作成したレコードが削除されていない。
- ちょっと原因究明まで時間がかかったのでまとめる。
前提
- 実際に問題が発生したlaravelのバージョンを踏襲し、本記事の内容はlaravel6準拠で記載する。最新バージョンとfactoryの呼び出し方法や定義方法が異なる可能性がある。
問題のテストコード
-
実行するとデータが残るテストコードを下記に記載する。
<?php use Illuminate\Foundation\Testing\DatabaseTransactions; namespace Tests\Feature; class HogeControllerTest extends TestCase { use DatabaseTransactions; private static $hoge; /** * 各テストメソッドが実行される前に一度だけ実行される * * @return void */ public static function setUpBeforeClass(): void { // hogeマスタテーブルにレコードを追加 self::$hoge = factory(MstHoge::class)->create(); } public function setUp(): void { parent::setUp(); CarbonImmutable::setTestNow(); } public function test_true() { // hogeマスターテーブルの情報を使う何かしらの処理 $this->assertTrue(true); } }
-
setUpBeforeClass()
にマスターテーブルにレコードを追加した処理を記載したのは意図的である。マスタデータをsetUpで都度作らないのは各テストメソッドで統一されたマスター情報を使ってテストしたかったためである。 -
当該のマスターテーブルにseederは存在しない。かつ、一定の「テストの独立性」を捨てていることも覚悟の上である。
setUpBeforeClassメソッド
- これはテストメソッドが実行される前に一度だけ実行される静的メソッドである。
- なんならlaravelのフレームワークがまだ完全に起動していない状態で実行されている。
setUpメソッド
- 各テストメソッドごとに実行されるメソッドである。
- テストメソッド毎の初期化処理(必要データをfactoryで用意するなど)は基本こちらで行われる。
DatabaseTransactionsトレイト
- このトレイトはテストにおいて非常に便利なトレイトである。
- 効能としてはこのトレイトをuseしておくだけで「テストメソッド毎に、変更されたテーブルの情報をロールバック」してくれる。
今回の原因
- 勘の鋭い皆さんならもうお気づきだろう。
- 自分は「setUpBeforeClassメソッド」でfactoryを使ってマスタテーブルのレコードを用意している。
- 「setUpBeforeClassメソッド」はテストメソッドが実行される前に実行される。
- 「DatabaseTransactionsトレイト」はテストメソッド毎にトランザクションを作成してテーブルの状態を元に戻している。
- つまり「setUpBeforeClassメソッド」のfactoryで追加されたレコードは「DatabaseTransactionsトレイト」のトランザクション外で作成されているレコードなのでロールバックしていても、テーブルに残り続けてしまう。
誤った解決方法
-
「setUpBeforeClassメソッド」とは逆の「tearDownAfterClassメソッド」というものが存在するのでそれを使って「setUpBeforeClassメソッド」で追加されたレコードを削除してしまえばいいと考えた。そして下記のようなコードを書いた。
<?php use Illuminate\Foundation\Testing\DatabaseTransactions; namespace Tests\Feature; class HogeControllerTest extends TestCase { use DatabaseTransactions; private static $hoge; /** * 各テストメソッドが実行される前に一度だけ実行される * * @return void */ public static function setUpBeforeClass(): void { // hogeマスタテーブルにレコードを追加 self::$hoge = factory(MstHoge::class)->create(); } public static function tearDownAfterClass(): void { // テストメソッド外で用意したマスタ情報を削除 self::$hoge->delete(); }} public function setUp(): void { parent::setUp(); CarbonImmutable::setTestNow(); } public function test_true() { // hogeマスターテーブルの情報を使う何かしらの処理 $this->assertTrue(true); } }
-
上記のテストコードをウキウキしながら実行したところ下記のエラーが出た。
Target class [config] does not exist.
「Target class [config] does not exist.」の原因
-
どうやら「setUpBeforeClassメソッド」と「tearDownAfterClassメソッド」はPHPUnitのテストライフサイクル内で実行されるかなり特別なメソッドらしい。
-
そしてテストに限ってはlaravelフレームワークのライフサイクルの外にPHPUnitのライフサイクルが存在する。
-
図にすると下記のような状態になる。
-
「tearDownAfterClassメソッド」が実行されるときには既にlaravelのライフサイクルの外ということになるため、laravelの機能であるモデルのインスタンスを使ったレコードの操作ができない。だから「Target class [config] does not exist.」のエラーが出るっぽい。
さらなる疑問
-
ここで疑問に思った。なぜ「setUpBeforeClassメソッド」内部ではfactoryを使ってレコードを追加する事ができるのだろうか。
-
ちょっと調べてみたところ「setUpBeforeClassメソッド」実行時は「laravelのアプリケーションがブートストラップされていない可能性がある」状態らしい。ようはlaravelのライフサイクルがはじまっているか、始まっていないがわからない状態っぽい。
-
なので「setUpBeforeClassメソッド」でfactoryなどを使うとエラーが発生する可能性があるっぽい。たまたまlaravelとPHPUnitのバージョンの組み合わせでlaravelのライフサイクルに入ってから「setUpBeforeClassメソッド」が実行されているだけの可能性もある。
-
そのため「setUpBeforeClassメソッド」でデータを用意することは非推奨となっている模様
-
素直にsetUpメソッドでデータを用意したほうがいいかもしれない。
-
もしくは「setUpBeforeClassメソッド」で
self::$hoge = factory(MstHoge::class)->make();
のようにモデルだけ作成して「setUpメソッド」でself::$hogeからモデルを取得しレコードを作成するなどの方法を取ればレコード作成はトランザクション内部ということになるので入れたデータは消えるはず。(「setUpBeforeClassメソッド」でlaravelの機能を使うことそのものが非推奨っぽいので、あくまで現状「setUpBeforeClassメソッド」でfactoryが使えていることを前提とし、非推奨なことを理解した上での苦肉の策である。) -
あとは「tearDownAfterClassメソッド」の中でPDOを使う方法もあるらしいが、よっぽどのことでない限りこんなことはしたくない。下記は動くかわからないがイメージとしてはこんな感じらしい。
public static function tearDownAfterClass(): void { $pdo = new \PDO('mysql:host=localhost;dbname=test', 'user', 'password'); // テストメソッド外で用意したマスタ情報を削除 $pdo->exec('DELETE FROM mst_hoge WHERE id = ' . self::$district->id); }
結論
- テストの独立性の観点や、セオリーに沿ってsetUpの中でデータを作ってメソッド毎に独立したテストをするようにしよう。
メモ
-
ちなみに「setUpBeforeClassメソッド」「tearDownAfterClassメソッド」「setUpメソッド」「tearDownメソッド」はそれぞれ
vendor/phpunit/phpunit/src/Framework/TestCase.php
に定義されている。vendor/phpunit/phpunit/src/Framework/TestCase.php/** * This method is called before the first test of this test class is run. */ public static function setUpBeforeClass(): void { } /** * This method is called after the last test of this test class is run. */ public static function tearDownAfterClass(): void { } /** * This method is called before each test. */ protected function setUp(): void { } /** * This method is called after each test. */ protected function tearDown(): void { }
-
「setUpメソッド」「tearDownメソッド」の実態そのものは
vendor/laravel/framework/src/Illuminate/Foundation/Testing/TestCase.php
に定義されている。vendor/laravel/framework/src/Illuminate/Foundation/Testing/TestCase.php/** * Setup the test environment. * * @return void */ protected function setUp(): void { Facade::clearResolvedInstances(); if (! $this->app) { $this->refreshApplication(); } $this->setUpTraits(); foreach ($this->afterApplicationCreatedCallbacks as $callback) { $callback(); } Model::setEventDispatcher($this->app['events']); $this->setUpHasRun = true; } /** * Clean up the testing environment before the next test. * * @return void */ protected function tearDown(): void { if ($this->app) { $this->callBeforeApplicationDestroyedCallbacks(); $this->app->flush(); $this->app = null; } $this->setUpHasRun = false; if (property_exists($this, 'serverVariables')) { $this->serverVariables = []; } if (property_exists($this, 'defaultHeaders')) { $this->defaultHeaders = []; } if (class_exists('Mockery')) { if ($container = Mockery::getContainer()) { $this->addToAssertionCount($container->mockery_getExpectationCount()); } try { Mockery::close(); } catch (InvalidCountException $e) { if (! Str::contains($e->getMethodName(), ['doWrite', 'askQuestion'])) { throw $e; } } } if (class_exists(Carbon::class)) { Carbon::setTestNow(); } if (class_exists(CarbonImmutable::class)) { CarbonImmutable::setTestNow(); } $this->afterApplicationCreatedCallbacks = []; $this->beforeApplicationDestroyedCallbacks = []; Artisan::forgetBootstrappers(); if ($this->callbackException) { throw $this->callbackException; } }
参考文献