🤔

CakePHPのテストコード実装時に、dataProvider内でFactoryを使っていたら躓いた出来事

2023/08/29に公開

はじめに

こんにちは。株式会社ペライチ のエンジニアの城戸・船橋です。

皆さん、テストコードは書いていますか?
実装時はついついめんどくさいと思っちゃいがちですが、テストコードがあることでコードの品質も上がるし、自動化することでリファクタ等の変更で起きる予期せぬ不具合を検知できたりと、個人的にはアプリケーションの命綱だと思っています。
今回はそのテストコードの実装時に起きたこと、躓いた出来事をつらつらと書いてみようと思います。

ことの始まり

とある分報のつぶやきから始まりました。

ChatGPT Botへの相談 (※ペライチではChatGPTへの質問等をBot化して運用してます!便利!)
ChatGPT Botへの相談

それを拾うエンジニア
分報のつぶやき

やりたいことしては、対象のテストケースに対して、data provider 内でデータを先に準備し、そのデータを使ってテストを実行するというもの。
その準備するデータを Factory を使って作成しようとしたところ、なぜかエラーが発生してしまいました。

今回はそれについて向き合ったお話です。

余談ではありますが、ペライチでは分報文化が根づいており、社内のコミュニケーションツールとしても活発に利用されています。
Slackの分報チャンネルで困ったとつぶやくと、わらわらとみんな集まって助けてくれます。
ペライチで働いてよかったーと思う瞬間のひとつですね😊

実際動かしてみたところ

ひとまず当該事象で困ってる部分だけ抜き出すと

public function dataProvider(): array
{
    $hoge = UserFactory::make([])->persist();
    $fuga = UserFactory::make([])->persist();
    return [];
}

こういった data provider を作成してテスト動かすだけでエラーが発生する模様。

There was 1 error:

1) Error
The data provider specified for App\Test\TestCase\HogefugaTest::testHogefuga is invalid.
CakephpFixtureFactories\Error\PersistenceException: Error in Factory App\Test\Factory\UserFactory.
 Message: SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'c2cdda66-81e5-3060-a4eb-049b4a810d76' for key 'PRIMARY' 

主キーが重複している🤔?

何が起きてるかの確認

兎にも角にも事実の確認ですね。
fact で物事を語ろうぜ、とメンバー同士でも良く話してます。

まず読み取れる主キー重複エラーですね。
まっさらなテスト用の DB に INSERT してるはずなのに何故🤔?といった感じではありますが。

そして先程のエラーログでは表現しきれなかったのですが、毎度同じ値(uuid)で重複エラーが起きていることもわかりました。
Factory で作ったので中の uuid は Faker で生成されているだろうに何故🤔?とこっちも疑問は残りますが、一旦前に進みます。

仮説

先程の内容をもとに以下の仮説を立てました。

  • fixture のレコードと uuid が被ってる説
  • ランダム生成の uuid の発行が被ってる説

1個ずつ調べていきます。

検証

fixture のレコードと uuid が被ってる説

初歩的なミスとして、fixture のレコードと主キーが被っている可能性があると考えました。
(まぁそんなことがあればこの記事は書いてないですが)案の定 fixture と衝突しているなんてことはありませんでした😇

Faker が生成している uuid の発行が被ってる説

よくよく考えると毎回同じ uuid が発行されていることがおかしいのでは?となりコードを深ぼってみると、ありました。
https://github.com/vierge-noire/cakephp-fixture-factories/blob/4aaf499f6f06a42e3538d2d7c602f04a3a45cfe8/src/Factory/BaseFactory.php#L200-L215

seed を '1234' で固定しているんですね。
そりゃ毎回同じ値が発行されますよね。

1件落着!と思ったのですが話はそんな単純じゃなかった。
このときは、
毎回同じ主キーを発行している = Factory で make を複数行うと重複エラーが起きる!
と勘違いしてしまっていました。
(冷静に考えればそんなことはないと後々気が付きましたが、当時は混乱しちゃってました)

それならこうしたらいいんじゃない?ということで修正したのがこちら。
そして実行してみましたが再び重複エラーに…

public function dataProvider(): array
{
    $hoge = UserFactory::make([])->persist();
    return [];
}

再度起きてることの確認

1件でも重複エラー起きるじゃん!なんで!
ってことで、多少ゴリ押しですが、こういう形に修正。

public function dataProvider(): array
{
    $user = TableRegistry::getTableLocator()->get('User');
    $user->deleteAll([]);
    $hoge = UserFactory::make([])->persist();
    return [];
}

当たり前ですが、INSERT 前に全部 DELETE かけているのでもちろんうまく行きました。
ということは、何かしらの理由で INSERT 前にレコードが存在しているということ🤔?

再度調査

public function dataProvider(): array
{
    $user = TableRegistry::getTableLocator()->get('User');
    print_r($user->find()->count());
    $hoge = UserFactory::make([])->persist();
    return [];
}

改めて INSERT 前にデータが存在しないかチェック。

10

なにやら 10 件ほど存在している…
それぞれのレコード見ると、心当たりが…
「あれ?これってローカル環境のデータじゃないか?」

テストのどのタイミングで接続先を変更しているのか

CakePHP では、テストケースの前処理として実行される処理によって DB の接続先が config/app.php の test データソース設定を読み込んで接続先をテスト用の DB に変更しているはず。

テストケースの前処理として実行される処理って、どこ・・・?

曖昧なままではいけません。
fact で物事を語ろうぜ、ということで実際にどこで処理されているか見ていきます。

CakePHP4 では PHPUnit を拡張して テスト実行時に接続先を変更しています。
具体的に何をしているかと言うと Cake\TestSuite\Fixture\PHPUnitExtension を PHPUnit の extensions に指定することで PHPUnit の BeforeFirstTestHook を使用してテスト実行前に接続先を変更しています。
余談ですが、PHPUnit には他にも AfterLastTestHook というフックなど様々なものが存在します。※ 但し、PHPUnit10 では これらの Hook は削除され、Subscriber というものに置き換わっています。

PHPUnit の設定
https://github.com/cakephp/cakephp/blob/4.x/phpunit.xml.dist#L22-L24

Cake\TestSuite\Fixture\PHPUnitExtension の処理
https://github.com/cakephp/cakephp/blob/4.x/src/TestSuite/Fixture/PHPUnitExtension.php#L35-L36

ConnectionHelper::addTestAliases が何をしているかというと主に以下の処理をしています。
https://github.com/cakephp/cakephp/blob/4.x/src/TestSuite/ConnectionHelper.php#L43

ここで testdefault に別名定義していることで DB の接続先名が default と指定された場合は test の接続情報で DB にアクセスするというわけです。
(別名が定義されている場合、通常の接続名よりも優先されます。)

どこで接続先が変更されているかはわかりました。

それではなぜ dataProvider の時点では接続先が変更されていなかったのか。

PHPUnit の実行順序を確認してみる

それぞれにログを仕込んで実行順序を確かめたところ、以下のような順番で処理されていることがわかりました。

  1. dataProvider
    何よりも先に一度だけ実行される
  2. BeforeFirstTestHook
    テスト実行時の最初に一度だけ実行される
  3. setUpBeforeClass
    各テストクラスの最初に一度だけ実行される
  4. setUp
    各テストメソッドの実行前に実行される
  5. テストメソッド
  6. tearDown
    各テストメソッドの実行後に実行される
  7. tearDownAfterClass
    各テストクラスの最後に一度だけ実行される
  8. AfterLastTestHook
    テスト実行後の最後に一度だけ実行される

何よりも先にdataProvider が実行されていることがわかりました😇

つまり
「dataProviderが実行されるときはDBの接続先はtestではなくdefaultになっている」
ってことですね!
(そりゃ意図しない挙動にもなるよね)

まとめ

  • dataProvider でレコードを生成すると、ローカル環境の DB にレコードが保存されていた(本来のテスト用 DB ではなく)
  • テストが終了した後、テスト環境の DB のみ truncate によってデータは削除されるが、 dataProvider で生成されたレコードはローカル環境の DB に保存されたまま、データが残ってしまっていた(接続先が変わっているため)
  • seed 値が固定されており、毎回同じ uuid が発行されてそれを主キーとして INSERT したので、衝突してしまった

というオチでした。

一言で言ってしまえば
「dataProvider の中でDBを操作しちゃダメ絶対!」
です。

わかってしまえば大したことはないのですが、諸々手探りでやっていたので少し遠回りしたかもしれません。

なのですが、
今回はペアプロを行いながらワイワイガヤガヤ作業できたので、一人で不安にかられながらの作業ではなかったのが救いでした。(一人だったらもっと時間がかかっていたはず)
ペライチでは作業時にペアプロを積極的に行っており、今回もその時に起きたことを基に書いています。
ペアプロを始め、メンバー同士のコミュニケーションが活発で、困ったときに相談しやすい雰囲気ができあがっており、
とても心理的安全性が高い状態で作業できていると私は思っています。
他のメンバーがペライチの心理的安全性について振り返ってみた記事もありますので、ぜひチェックしてみてください。

採用情報

現在エンジニア募集しています!

▼ 選考をご希望の方はこちら(募集職種一覧)
https://hrmos.co/pages/peraichi/jobs?category=1629135637016141824&utm_source=techblog&utm_medium=referral&utm_campaign=article-01h5kh54w1vyz36ax87jba7qeq

▼ まずはカジュアル面談をご希望の方はこちら
https://hrmos.co/pages/peraichi/jobs/0000029?utm_source=techblog&utm_medium=referral&utm_campaign=article-01h5kh54w1vyz36ax87jba7qeq

募集中の職種についてご興味がある方は、お気軽にお申し込みください(CTOがお会いします)

ペライチ

Discussion