こんにちは。PR TIMES開発本部でインターンをしている笹山雷雅です。
PHPUnitを用いたDBテストを改善したのでブログにします。
改善前のDBテストについて
新規コードに関してはSQL呼び出しのルールが決まっています。しかし、過去の問題があるコードを全部新しいルールで書き直すのは現実的ではありません。
そこで、過去のSQLの呼び出しのコードだけを書き換える目的で、LegacyDAOというシンプルな仕組みに移行している最中です。
LegacyDAOを追加するときにUnit Testを追加するルールにしていますが、元々Unit Testを追加し始めた時はTest周りの仕組みが未整備で、CIの導入もされてませんでした。その後、CIの仕組みが導入されましたが、初期段階としてLegacyDAOのテストはCIで実行される対象から外されている状況でした。
そこで今回はLegacyDAOのテストをCIで実行する対象にするために行った対応を紹介します。
PHPUnitのUnit Testについて
PHPUnitでは、実行時に phpunit.xmlを渡すことで、様々なオプションを簡単に付加することができます。その中にテストスイート(testsuite)というものがあり、テストスイートを実行時にオプションで渡すことで、特定のファイル・ディレクトリをテストできるようになります。CIでもテストされるように改善を決意し、まずディレクトリ全体を対象に含めるようにphpunit.xmlに追記しました。CIで実行されているテストスイートはprtimes-ciです。
<testsuite name="prtimes-ci">
<!-- snip -->
<directory>tests/PRTIMES/PrTimes/Tests/Repo/LegacyDAO</directory>
<!-- snip -->
</testsuite>このように追記すると、いくつかのテストがエラー・失敗していたので「これから追加されるテストがCIで実行できること」を優先しました。excludeタグをテストスイート内に追記することで、特定のファイル・ディレクトリを対象から除外できます。
<testsuite name="prtimes-ci">
<!-- snip -->
<directory>tests/PRTIMES/PrTimes/Tests/Repo/LegacyDAO</directory>
<exclude>tests/PRTIMES/PrTimes/Tests/Repo/LegacyDAO/FailedTest</exclude>
<!-- snip -->
</testsuite>このようにPHPUnitはXMLでテストの設定を記述できます。その他の記法などはドキュメントを参照してください。
https://phpunit.de/documentation.html
ひとまずの段階として、このようにLegacyDAOをテスト対象に含めることができ、CIでもDB系のUnit Testが行えるようになりました。
その後、テストで保証する内容を変えずに、CI上でもローカルでも通るようにテストの実行形態を整えました。
なぜ失敗していたか
テストの失敗の主な原因は、テストの実行形態とデータベース(DB)の設定にありました。具体的には、各テストが独立していなかったことが問題でした。
DBがテストごとで独立でなかった
PHPUnitでは、テスト実行前後にsetUp()とtearDown()が呼び出されます。これらをテストクラスで適切に実装することで、各テストを独立させて実行することが可能です。しかし、setUp()やtearDown()の実装がない場合、またはデータベースに対してトランザクションをテスト実行前後で張っていない場合(beginTransaction()やrollBack()が呼び出されていない場合)、データベースの初期化がテストごとに実行されません。その結果、テスト間で干渉が生じ、外部キーで参照しているレコードを削除しようとしたり、すでに存在する主キーで挿入を試みるといった違反が発生しました。失敗したテストには、データベースの初期化を行うコードを追記しました。ただし、この方法でテストが通るようになるのは、SQLクエリが正しく、テストデータの条件が適切なテストケースのみです。つまり、SQLクエリに誤りがなく、テストデータが適切に設定されている場合に限ります。
<?php
use PDO;
use PHPUnit\\Framework\\TestCase;
public class CompanyDAOTest extends TestCase
{
private PDO $pdo;
// 実行時に必要な定数など
public function setUp(): void
{
parent::setUp();
$this->pdo = getNewPDO();
$this->pdo->beginTransaction();
}
public function tearDown(): void
{
parent::tearDown();
$this->pdo->rollBack();
}
// snipテストケースの実行ごとにトランザクションが張れるよう、setUp()でbeginTransaction()、tearDown()でrollBack()を呼び出すことで、テストの独立性を確保するようにします。
テストに使う値をハードコードしていた
Assertionに失敗するテストで多かったのは、検索に使う値をハードコードで渡していたことです。
テストが独立でなかったため、ローカルとCIで扱うデータベースの状態が異なる可能性がありました。これにより、同じテスト内容であっても結果が一致しない問題が発生しました。ハードコードは各所に同じ値が出現し、テストの変更時に修正しづらい問題があります。後述するSeederの導入に合わせ、用意したテスト用の値を定数としてテストクラスのメンバにすることで、ハードコードを削減しました。
テストデータのセットアップに素のSQLファイルを用いていた
DBのテストでは、扱うクエリに関係するテーブルに対して、仮のデータをテストケースごとに挿入し、そのデータを用いてテストを行います。
テストデータの準備方法として、以前のPR TIMESではユニットテストがまだ存在しない状況から始まり、開発用のデータベースに既に存在するデータを前提としてユニットテストを導入しました。
その後、CIでテストを実行するために、開発用のデータベースに存在するデータをテスト用のデータベースに移行する必要がありました。そのため、直接SQLファイルを作成し、その内容をPHPファイルで読み込んでデータを移行する形になっていました。
多くのカラムを持つテーブルにデータを挿入するためには、長いSQLファイルが必要となります。さらに、PHPコードからは挿入するデータの詳細を把握することが難しく、これがテストの効率性と完全性を損なう要因となりました。
そのため、新たにSeederの仕組みを導入し、必要なテーブルごとにSeederを作成することで、これらの問題を解決しました。これにより、テストデータの準備がより効率的になり、テストの完全性も向上しました。
<?php
use PDO;
use PHPUnit\\Framework\\TestCase;
use Seeder\\CompanySeeder;
public class CompanyDAOTest extends TestCase
{
private PDO $pdo;
// 実行時に必要な定数など
public function setUp(): void
{
parent::setUp();
$this->pdo = getNewPDO();
$this->pdo->exec("TRUNCATE company CASCADE");
$this->pdo->beginTransaction();
CompanySeeder::seed($this->pdo, [
'company_name' => self::COMPANY_NAME,
// 必要なデータを渡す
]);
}
//snip...Seederの導入や詳しい説明については、以下のリンク先の記事で詳しく扱っています。

beginTransaction()を呼び出してからSeederでデータを挿入することで、各テストが独立したデータを使用することが可能になります。これによりテスト間でデータが干渉することなく、一貫したテスト結果を得ることができます。
デフォルト引数実装によってテストの独立性がなかった
エラーの中にundefined offset 0 のようなエラーが発生することがあり、テストデータの挿入でも解決できなかったものの原因は、トランザクションブロックを共有するためのPDOを実行関数に渡していなかったことでした。
前に作成されていたDB用の関数の中には、PDOをデフォルト引数として設定し、nullだった場合に関数内でPDOを作成する、という実装をとっていました。そのためテスト関数でPDOを渡さずとも警告が出ることなく、エラーが起きたり、テストに失敗していました。
class HogeDAO {
public static function getRowHoge(int $id, PDO $pdo = null) {
if ($pdo == null) {
$pdo = getNewPDO();
}
// snip...
}
}
まとめ
テストの修正はexclude タグを一つずつ消せるようにして、PRを細かく立てて11個のPRで、追加したexclude タグを全て消すことができました。
この修正を通じて、データベースに関する知識や、可読性を上げるための工夫などを学ぶことができました。また社内テックブログにドキュメントを残し、今後LegacyDAOに移行する際の参考にできる環境を整えることも行いました。
今回は問題のあったテストを修正しましたが、上記に挙げたような問題のあるコードはまだ存在しています。今後も改善していきたいです。

