やりたいこと
単体テスト用のデータベースをCSVファイルで用意したい
Laravelに限ったことではありませんが、データベースを使ったモジュールの単体テストをするとき、それが参照するデータベースをどうするか?という問題があります。単純に本番環境のデータベースを使ったり、それをコピーしたものだと、件数が多すぎて時間がかかるし、また実行したタイミングによって中身が違って結果が変わってしまうというのが、テストには向きません。
かと言って逆に、テストコード内でInsertしたりするのは、書くのも大変だしメンテも大変、何より量が増えてくるとデータに埋もれてしまって肝心のテストコードが見えなくなってしまいます。
毎回きちんと同じ状態からスタートして、少し規模が大きくても取扱に困らず、バージョン管理システムにテストファイルと同時にコミットできて、さらに何のために用意したテストデータなのかメモしておけるような、そんなテスト用データベースを作りたい。
そう、CSVファイルでええやん、というお話。
結論
Laravelのプロジェクトに下記の追加修正で、特定のディレクトリに、テーブル名=ファイル名としたCSVファイルを置いておけば、テスト開始時にその状態にしてくれます。(以下のサンプルはLaravel5.5をベースにしていますが、Laravel5以上なら概ね同じ感じと思います。)
ディレクトリ構成
全体像としてはこのようになりました。追加や修正しているファイル自体はわずかです。
順に設置していきます。
flynsarmy/csv-seeder をインストール
今回とてもお世話になっている、CSVファイルをデータベースにインポートしてくれるライブラリ
flynsarmy/csv-seeder
を追加します。phpunitと同様に、基本的に開発中にしか使わないものなので、-devをつけています。
composer require --dev flynsarmy/csv-seeder
CSVを自動的にインポートするSeederを作る
flynsarmy/csv-seeder は、1ファイル1テーブルに対して使う設計になっているので、これをマルチに動かすSeederを新しく作ります。
artisan で Seeder を作ることができますが、今回はちょっと性質が違うものだし、これが管理するCSVファイルと一緒に別管理したかったので、あえて別フォルダに手動でファイルを作りました。
2018.5.24 更新
ロケールをきちんと指定しないと内部で使用しているfgetcsvが失敗することがあるので明記。
<?php
use Flynsarmy\CsvSeeder\CsvSeeder;
setlocale(LC_ALL, 'ja_JP.UTF-8');
class MultiCSVSeeder extends CsvSeeder {
public function run()
{
// 大きなCSVをインポートするときにはしておいたほうが良い、とのこと
DB::disableQueryLog();
// 一括インポート時には邪魔になるDB整合性チェックをOFF
DB::statement('SET FOREIGN_KEY_CHECKS=0;');
// CSVファイルを置いておくディレクトリ
$csvdir = __DIR__.'/csv';
// 上記ディレクトリに入っているCSVファイルをすべて、
// ファイル名=テーブル名として自動インポート
foreach( scandir( $csvdir ) as $filename ){
if( !preg_match( '/(.*)\.csv$/', $filename, $m ) ) continue;
$this->table = $m[1];
$this->filename = "$csvdir/$filename";
// [後述] データベースを空っぽにする
// 次のステップで migrate してDB構造から作り直すので不要だけど
// 毎回migrateするのが重いなら、migrateを外して、代わりにここでtruncateする
// DB::table($this->table)->truncate();
// インポートのプログレス表示 邪魔なら外してください
echo "seeding {$this->table} from {$this->filename}.\n";
parent::run();
}
DB::statement('SET FOREIGN_KEY_CHECKS=1;');
}
}
クラスマップに追加する
新しいフォルダに設置したクラスファイルをautoloadしてもらうために、composerを実行しておきます。
"autoload": {
"classmap": [
"database/seeds_test", # <-追加
"database/seeds",
"database/factories"
],
$ composer dump-autoload
autoload_classmap.php
に追加されるのを確認します。
'MultiCSVSeeder' => $baseDir . '/database/seeds_test/MultiCSVSeeder.php',
テスト用データベースを用意する
最初に phpmyadmin などで、テスト専用のデータベースを作成しておきます。
phpunit.xml でDB名を切り替える方法
テストのために本番とは違うデータベースを用意する方法はいくつかありますが、最もカンタンなのはデータベース名だけ切り替える方法で下記の1行のみ。
<php>
<env name="APP_ENV" value="testing"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/>
<env name="DB_DATABASE" value="mydatabase-test"/> <!-- 追記 -->
</php>
テストで切り替えを確認する
今回は「データベースを初期化する」という恐ろしい操作を行う予定なので、テスト用データベース名を指定する前後で、きちんとデータベースが切り替わっているかを確認してください(まさか本番環境でテストを走らせるなんてことはしませんよね!?)。
たとえば、レコード件数を調べるとか。試しに TestTest.php
というテストケースを作って…
<?php
namespace Tests;
class TestTest extends TestCase
{
/**
* @test
*/
function testTest(){
$this->assertEquals( 0, \App\User::count() );
}
}
コマンドラインからテストを実行してみます。
データベースが初期状態(ユーザーが0人)でないとテストが通らないようにしています。
まずは上記DB切り替えをせずに実行して失敗を確認。11741人もユーザーがいるとかどんな開発環境なのかと。
$ phpunit tests/TestTest.php
PHPUnit 7.0.0 by Sebastian Bergmann and contributors.
F 1 / 1 (100%)
Time: 205 ms, Memory: 12.00MB
There was 1 failure:
1) Tests\TestTest::testTest
Failed asserting that 11741 matches expected 0.
/tests/TestTest.php:11
FAILURES!
Tests: 1, Assertions: 1, Failures: 1.
ちなみに我らが Windows だとコマンドはこう。
> vendor\bin\phpunit tests/TestTest.php
次に切り替えて実行してみます。
$ phpunit tests/TestTest.php
PHPUnit 7.0.0 by Sebastian Bergmann and contributors.
E 1 / 1 (100%)
Time: 203 ms, Memory: 12.00MB
There was 1 error:
1) Tests\TestTest::testTest
Illuminate\Database\QueryException: SQLSTATE[HY000] [1049] Base table or view not found:
1146 Table 'mydatabase-test.users' doesn't exist
(SQL: select count(*) as aggregate from `users`)
テスト用のデータベースは空っぽのままなので「mydatabase-test
にそんなテーブルは無いぜ!」と明確にエラーが出ます。
このようにデータベースの切り替えが確認できてから、次のステップへ進みましょう。
テスト前にデータベースを初期化する
[簡易版] migrate して、migrate:reset する
ひとまず書くのが簡単な簡易版で実装します。
テストメソッドを呼ぶたびにデータベースをまるごと作り直す方法です。
書くのはカンタンですが、マイグレーションの規模が大きくなるとそれだけテストの実行が遅くなります。
基底クラスに下記2つのメソッドを追加するだけです。
public function setUp()
{
parent::setUp();
echo "migrating\n";
\Illuminate\Support\Facades\Artisan::call('migrate');
}
public function tearDown()
{
echo "migrate resetting\n";
\Illuminate\Support\Facades\Artisan::call('migrate:reset');
parent::tearDown();
}
echo は実際に動かすときには消してください。
複数のテストを実行してみるため、先程の TestTest.php のメソッドをコピペで増やして、テストを実行してみると下記のようになります。ファイル単位でなくメソッド単位で migrate と reset を繰り返しているのがわかります。
$ phpunit tests/TestTest.php
PHPUnit 7.0.0 by Sebastian Bergmann and contributors.
.migrating
migrate resetting
.migrating
migrate resetting
.migrating
migrate resetting
.migrating
migrate resetting
4 / 4 (100%)
Time: 1.76 seconds, Memory: 16.00MB
OK (4 tests, 4 assertions)
CSV を設置する
下記のようなCSVを用意します。ファイル名=テーブル名としておけば、ファイルを追加すれば自動的にそれをテストデータベースにインポートしてくれます。
id,name,email,password,remember_token
1,John,john@dummy.com,john123,dummytokendummytoken
2,Paul,bill@fake.com,paulABC,dummytokendummytoken
3,George,george@test.com,george#$%,dummytokendummytoken
4,Ringo,ringo@fantasy.com,ringo@(9),dummytokendummytoken
エクセルで出力するときは「CSV UTF-8(コンマ区切り)」として保存します。BOMが付きますが問題ありません(詳しくは解説で)。
Seeder を呼ぶ
基底クラスに追記します。
public function setUp()
{
parent::setUp();
echo "migrating...\n";
\Illuminate\Support\Facades\Artisan::call('migrate');
// 追記
$this->seed('MultiCSVSeeder');
}
テスト実行!
テストファイルを下記のように変更して…
<?php
namespace Tests;
class TestTest extends TestCase
{
function testTest(){
$this->assertEquals( 0, \App\User::count() );
}
function testTest2(){
$this->assertEquals( 4, \App\User::count() );
}
}
実行してみます。
$ phpunit tests/TestTest.php
PHPUnit 7.0.0 by Sebastian Bergmann and contributors.
F
migrating
seeding users from /database/seeds_test/csv/users.csv.
migrate resetting
.
migrating
seeding users from /database/seeds_test/csv/users.csv.
migrate resetting
2 / 2 (100%)
Time: 604 ms, Memory: 16.00MB
There was 1 failure:
1) Tests\TestTest::testTest
Failed asserting that 4 matches expected 0.
/tests/TestTest.php:13
FAILURES!
Tests: 2, Assertions: 2, Failures: 1.
1つ目のテストで 0 件を予期していたのでコケることを確認。
上記テストでは migrate:reset をしているのでデータベースはすっからかんになって終了しますが、すっからかんにしないとこのようなデータが格納されます。
解説
flynsarmy/csv-seeder の仕様 こんなときどうする?
今回使用した flynsarmy/csv-seeder は、なかなかうまくできていて、いろんな要望によく対応してくれています。ここではソースコードを見ながら素朴な疑問の答え合わせをしてみます。
ぱ、パスワードがハッシュ化されてますよ!
そうなんです。上の結果を見てちょっと驚きました。
flynsarmy/csv-seeder がインポートする際に、指定された名前のカラムはDBに入れる前にハッシュ値にしてくれます。デフォルトは password
です。
public $hashable = 'password';
// ...
if ($this->hashable && isset($row_values[$this->hashable])) {
$row_values[$this->hashable] = Hash::make($row_values[$this->hashable]);
}
BOM付きでも大丈夫?
大丈夫です。先頭のBOMは明示的に除去してくれています。
$mapping[0] = $this->stripUtf8Bom($mapping[0]);
注意
先頭のBOMは除去してくれますが、上記コードの通り「CSVカラムとして解釈された後」です。下記のように1つ目がクオーテーションでくくられているとCSV解釈に失敗して1列目が無視されます。
"id","name",...
1,"試験太郎",...
id,"name",...
1,"試験太郎",...
NULLはどうやって入れる?
単に null string = ,,
としておくと、明示的にNULLとしてInsertしてくれます。CSVの末尾のデータが無いところにカラムを置いても大丈夫です。
if (!isset($row[$csvCol]) || $row[$csvCol] === '') {
$row_values[$dbCol] = NULL;
}
下記のように、列数が足りない行が合った場合、足りない部分はNULLセットしてくれます。
id,name,email,password,remember_token,created_at,updated_at
1,John,john@dummy.com,john123,dummytokendummytoken,2018-02-10 10:00:00,2018-02-10 10:00:00
2,Paul,bill@fake.com,paulABC
3,George,george@test.com,george#$%
4,Ringo,ringo@fantasy.com,ringo@(9)
カラムの順番は?
DBのカラム順とマッチしている必要はありません。
下記のように、カラムの順番がバラバラでも大丈夫です。
name,id,remember_token,email,password
John,1,dummytokendummytoken,john@dummy.com,john123
Paul,2,dummytokendummytoken,bill@fake.com,paulABC
George,3,dummytokendummytoken,george@test.com,george#$%
Ringo,4,dummytokendummytoken,ringo@fantasy.com,ringo@(9)
DBに存在しないカラムを書いても大丈夫?
DBテーブルにカラムが存在するかどうかを事前にチェックしてくれているので、存在しないカラムはスルーされます。
// skip csv columns that don't exist in the database
foreach($mapping as $index => $fieldname){
if (!DB::getSchemaBuilder()->hasColumn($this->table, $fieldname)){
array_pull($mapping, $index);
}
}
ということは、test-comment的なカラムを追加して、テストデータを追加した意図などを書き残しておくこともできます。どのデータが度のテストに関連しているのかひと目で分かるので、忘却の後にとても力を発揮してくれます。
id,name,email,password,remember_token,test-comment
1,John,john@dummy.com,john123,dummytokendummytoken,パスワードが英数字の組み合わせの場合
2,Paul,bill@fake.com,paulABC,dummytokendummytoken,パスワードが大文字と小文字の組み合わせの場合
3,George,george@test.com,george#$%,dummytokendummytoken,パスワードに記号を含む場合
4,Ringo,ringo@fantasy.com,ringo@(9),dummytokendummytoken,パスワードに記号を含む場合2
うまくいかない? エラーが起きてる?
try {
DB::table($this->table)->insert($seedData);
} catch (\Exception $e) {
Log::error("CSV insert failed: " . $e->getMessage() . " - CSV " . $this->filename);
return FALSE;
}
エラーはLaravel標準のログに出力されているので、/storage/log/laravel.log
をチェックします。
上記コードを echo にすれば画面に出てくるようになりますし、tryを削除すればテスト中に例外としてレポートされます。
改良版
「結論」として紹介していた方法は、最もシンプルで簡単な方法でした。
とりあえず動作を体感するのには十分ですが、実用性はイマイチです。
ここからはパフォーマンスや利便性を考慮したオルタナティブエディションも紹介します。
テスト専用データベースを用意する
.envをまるごと切り替える方法
phpunit.xml を使う方法だとデータベース名以外に色々切り替えたくなってきたとき、むやみに phpunit.xml に書きまくる事になってしまうため、だったら .env をまるごと切り替えたらええやん、という方法です。
現状の .env
がこうなっているとしたら、
# 本番環境の設定
APP_ENV=production
...
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=mydatabase
DB_USERNAME=myname
DB_PASSWORD=password
それをコピーしてこのようなファイルを作っておきます。
APP_ENV=testing # 他の変数もまとめて切り替えできる
...
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=mydatabase-test # DB名を変更
DB_USERNAME=myname
DB_PASSWORD=password
それを、テストでアプリケーションが生成される直前に指定します。
イマドキのLaravelならCreateApplication.php
というトレイトが用意されているのでそこに。
public function createApplication()
{
// 追加
if (file_exists(dirname(__DIR__).DIRECTORY_SEPARATOR.'.env.test')) {
(new \Dotenv\Dotenv(dirname(__DIR__), '.env.test'))->load();
}
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
Hash::driver('bcrypt')->setRounds(4);
return $app;
}
それがなければ、基底クラスTestCase.php
に書きます。
// このメソッドを追加
public function createApplication()
{
if (file_exists(dirname(__DIR__).DIRECTORY_SEPARATOR.'.env.test')) {
(new \Dotenv\Dotenv(dirname(__DIR__), '.env.test'))->load();
}
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
return $app;
}
テスト前にデータベースを初期化する
[高速版] 明示的に setupDatabase して結果は残す
簡易版は毎回マイグレーションをし直すのが、規模の大きなアプリケーション、というか普通規模のアプリケーションで既にストレスになるほど遅いので、それを改善します。あと好みかもしれませんが、テスト後にDBが綺麗さっぱり空っぽになっているのも寂しいものです。
(空っぽになっていたら「予めテストデータベースに値を入れておいても無意味だ」というわかりやすいメッセージになりますが、逆に「ここにテスト用データが入る」ということがわかりにくくなります。好き好きと思います。)
というわけで、明示的にセットアップするメソッドを用意します。こちらを参考にさせていただきました。
基底クラスを下記のとおりまるっと置き換えます。
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use \Illuminate\Support\Facades\Artisan;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
protected static $databaseSetup = false;
protected function setUpDatabase()
{
if (static::$databaseSetup) {
return;
}
// データベースを初期化
Artisan::call('migrate');
// ここに移動
$this->seed('MultiCSVSeeder');
static::$databaseSetup = true;
}
public function setUp()
{
parent::setUp();
// \Illuminate\Support\Facades\Artisan::call('migrate'); // コメントアウト
}
public function tearDown()
{
// \Illuminate\Support\Facades\Artisan::call('migrate:reset'); // コメントアウト
parent::tearDown();
}
}
migrate:reset しなくなったのでデータベースをインポートした結果が残ります。そのままだと新しくテストを実行した時にインポートできないので、Seederのコメントを外して毎回空っぽにしてからインポートします。
// [後述] データベースを空っぽにする
// 次のステップで migrate してDB構造から作り直すので不要だけど
// 毎回migrateするのが重いなら、migrateを外して、代わりにここでtruncateする
DB::table($this->table)->truncate(); // コメント解除
さらに、データベースが必要なテストクラスにだけ、下記の通りトレイトを追加して、setup を追記します。
use \Illuminate\Foundation\Testing\DatabaseTransactions;
public function setup(){
parent::setup();
$this->setUpDatabase();
}
DatabaseTransactions を追加しておくことで、このクラスのテストでDBに加えた変更は、次のテストに移る前に初期化されます。これが、イチから入れ直すより高速で快適です。
全体の流れは下記のようになります。
- DBが必要なテストの1つ目が起動
- migrateする(前回からDBに変更があれば適用される)
- テーブルを空っぽにする
- CSVからインポートする
- テストを実行する
- テストで加わった変更を元に戻す
- DBが必要なテストの2つ目が起動
- テストを実行する
- テストで加わった変更を戻す
感想
そこそこ大きな規模になってきたアプリケーションの単体テストをするのに、自分的にスッキリ簡単なデータベースシードの管理の仕方をいろいろ妄想したり試行錯誤したりしてきた結果、今回新しいプロジェクトに適用させてもらったので、それを機にまとめてみたものです。
ホントは「単体テストのベストプラクティス」的な良著を熟読してからにすべきところ、今の自分の考え方を先に書いておいても良いかなーと思った、と順番が前後しているのでイマイチどころか非常識な部分もあるかと思います。
お気づきなことがあれば、ビシバシとご指摘いただけるとありがたいです(^^)
2019年2月追記
- 運用上の最も重要なプラクティスは 以前に用意したテストデータを別のテストで再利用しない ということです。面倒かもしれませんが、似たようなテストでも、テストデータをコピーして、こっちは〇〇のテスト、こっちは△△のテストと明記しておくようにしましょう。
- やはり単体テストのベストプラクティスとしては、そのテストに必要なデータを毎回テスト前に追加してから実行 することみたいです。SeederやFakerはそのために用意されているもの、ということですね。なんてめんどくさい。