engineering hosting php バージョンアップ ユニットテスト

PHP8系へのバージョンアップに伴うライブラリのリプレイス その2 〜PHPUnit/DbUnit編〜

engineering hosting php バージョンアップ ユニットテスト

こんにちは。@matsusukeです。 私はホスティング事業部でWebアプリケーションエンジニアを務めており、プロダクトの開発と運用保守に携わっています。 現在、私たちはアプリケーションのPHPバージョンを8系に向上させるプロジェクトを進行中です。 このバージョンアップで、一部の依存ライブラリが互換性を失う事態が生じました。これについて、シリーズで詳細をご紹介していきます。

第一回ではPHPのユニットテストのテストダブルライブラリであるAspectMockの事例について紹介しました。

第二回である今回は、PHPUnitの拡張ライブラリであるDbUnitの事例について紹介します。

前提

まず、今回紹介する事例のプロジェクトの現状について説明します。 現環境(PHP7.2)で動いているPHPUnitのバージョンは6.5であり、新環境(PHP8.2)のPHPUnitのバージョンを10系へ上げることを計画しています。 PHPUnit10はPHP8系以降のバージョンのみに対応しており、PHP7系以前のバージョンでは動きません。 PHP8系であればPHPUnit9系もサポートしていますが、PHPUnitの公式ページを見るとEnd of Bugfix Supportの期限が2024/2であり、既に終了しているため、stableバージョンであるPHPUnit10系を採用する方針としました。

img

PHPUnit/DbUnitについて

PHPUnit7系以前まではPHPUnitの拡張ライブラリとしてDbUnitを使用することができました。 DbUnitはユニットテストの中でFixtureの作成などを行うことができ、プロジェクト内のユニットテストでもこの機能を使ってテストコードが実装されています。

しかし、このライブラリは2019年時点での最新版であったPHPUnit8系から既に使用ができなくなっており、Issueでもその見解が示されています。

PHPUnitを6系から10系へバージョンアップする場合、DbUnitを用いている既存の実装をリプレイスする必要があります。 今回のプロジェクトはPHPおよびPHPUnitのバージョンアップが主な目的であるため、できるだけリファクタリング等は行わない方針でリプレイスを進めます。

PHPUnit/DbUnitを用いている既存の実装

今回のリプレイス対象であるPHPUnit/DbUnitを用いているテストコードは、下記のように実装されています。

<?php
use PHPUnit\DbUnit\DataSet;
use PHPUnit\DbUnit;

class BaseTestCase extends PHPUnit\DbUnit\TestCase
{
    static private $pdo = null;
    private $conn = null;

    protected function setUp()
    {
        $fixturePath = TEST_ROOT . '/datasets/' . get_class($this) . '/' . $this->getName() . '.yml';
        if (file_exists($fixturePath)) {
            $this->_dataSet = new PHPUnit\DbUnit\DataSet\YamlDataSet($fixturePath);
        } else {
            $this->_dataSet = new PHPUnit\DbUnit\DataSet\DefaultDataSet();
        }
        parent::setUp();
    }

    public function getConnection()
    {
        if ($this->conn === null) {
            if (self::$pdo == null) {
                $dbDsn = "mysql:dbname={$_ENV['DB_DBNAME']};host={$_ENV['DB_HOST']}";
                self::$pdo = new PDO($dbDsn, $_ENV['DB_USER'], $_ENV['DB_PASSWD']);
            }
            $this->conn = $this->createDefaultDBConnection(self::$pdo, $_ENV['DB_DBNAME']);
        }
        return $this->conn;
    }

    public function getDataSet()
    {
        return $this->_dataSet;
    }

    // テスト後にテーブルをTRUNCATEする
    public function getTearDownOperation()
    {
        return PHPUnit\DbUnit\Operation\Factory::TRUNCATE();
    }
}

テストコード内のBaseTestCaseは全てのテストケースの親クラスであり、このクラス内でPHPUnit/DbUnitが使用されています。 また、BaseTestCasePHPUnit\DbUnit\TestCaseを継承しているため、継承元のクラスも変更する必要があります。

上記のクラス内でPHPUnit/DbUnitの実装を用いているsetUp関数にフォーカスします。

protected function setUp()
{
    $fixturePath = TEST_ROOT . '/datasets/' . get_class($this) . '/' . $this->getName() . '.yml';
    if (file_exists($fixturePath)) {
        $this->_dataSet = new PHPUnit\DbUnit\DataSet\YamlDataSet($fixturePath);
    } else {
        $this->_dataSet = new PHPUnit\DbUnit\DataSet\DefaultDataSet();
    }
    parent::setUp();
}

setUp関数では、

実行しているテスト名でyamlファイルのfixtureが存在すれば、それをテスト用のデータとしてテスト用DBにinsertする

という処理を行なっています。

つまり、「fixtureとしてデータを作る必要があるテストの場合にはダミーデータを作成する」という処理をPHPUnit/DbUnitを使わずに実現できればOKと言えそうです。

修正した実装

修正点その1: 継承元の親クラスを修正する

修正前はPHPUnit\DbUnit\TestCaseを継承元の親クラスとしていましたが、PHPUnit\Framework\TestCaseを継承するように変更しました。

修正点その2: yamlファイルをパースするために新たなライブラリを導入する

修正前はyamlファイルをパースするためにPHPUnit\DbUnit\DataSet\YamlDataSetクラスを用いて実装していました。

$this->_dataSet = new PHPUnit\DbUnit\DataSet\YamlDataSet($fixturePath);

ここの実装箇所で具体的にどのような処理を行なっているか、PHPUnit/DbUnitソースコードリーディングを行なってみます。

/**
 * Creates a new YAML dataset
 *
 * @param string      $yamlFile
 * @param IYamlParser $parser
 */
public function __construct($yamlFile, $parser = null)
{
    if ($parser == null) {
        $parser = new SymfonyYamlParser();
    }
    $this->parser = $parser;
    $this->addYamlFile($yamlFile);
}

/**
 * Adds a new yaml file to the dataset.
 *
 * @param string $yamlFile
 */
public function addYamlFile($yamlFile): void
{
    $data = $this->parser->parseYaml($yamlFile);

    foreach ($data as $tableName => $rows) {
        if (!isset($rows)) {
            $rows = [];
        }

        if (!\is_array($rows)) {
            continue;
        }

        if (!\array_key_exists($tableName, $this->tables)) {
            $columns = $this->getColumns($rows);

            $tableMetaData = new DefaultTableMetadata(
                $tableName,
                $columns
            );

            $this->tables[$tableName] = new DefaultTable(
                $tableMetaData
            );
        }

        foreach ($rows as $row) {
            $this->tables[$tableName]->addRow($row);
        }
    }
}

実装を見ていくと、コンストラクタ内でyamlファイルのパースを行い、addYamlFileメソッドで、yamlファイルで定義したテーブルに対し、レコードの追加を行なっていることがわかります。 つまり、PHPUnit\DbUnit\DataSet\YamlDataSetクラスのリプレイスを行う場合にはこの処理を別のライブラリを使うことで実現できればOKです。

yamlファイルのパースはSymfony\Component\Yaml\Yamlを代わりに使うようにしました。

修正した実装は下記の通りです。

<?php

use PHPUnit\Framework\TestCase;
use Symfony\Component\Yaml\Yaml;

class BaseTestCase extends TestCase
{
    private $db;
    private $fixturePath;

    protected function setUp(): void
    {
        $this->fixturePath = TEST_ROOT . '/datasets/' . str_replace("::", "/", $this->toString()) . '.yml';

        // fixtureのyamlファイルがなければDBへの接続処理は行わない
        if (!file_exists($this->fixturePath)) {
            return;
        }

        // テストデータベースをトランザクションでクリーンアップ
        $this->db = Dao_Table_Init::standby();
        $this->db->beginTransaction();

        // フィクスチャをロード
        $this->loadFixtures();
    }

    protected function getDb()
    {
        return $this->db;
    }

    public function loadFixtures()
    {
        $fixtures = Yaml::parse(file_get_contents($yamlFilePath));
        
        foreach ($this->fixtures as $table => $rows) {
            foreach ($rows as $row) {
                $this->db->insert($table, $row);
            }
        }
    }

    protected function tearDown(): void
    {
        // fixtureのyamlファイルがなければDBのロールバック処理は行わない
        if (!file_exists($this->fixturePath)) {
            return;
        }
        // ロールバックしてテストデータベースの状態をクリーンアップ
        $this->db->rollBack();
    }
}
  • fixtureとして登録したいyamlファイルがある場合のみDB接続する
  • yamlファイルをSymfony\Component\Yaml\Yamlを用いてパースする
  • トランザクションを貼ってテスト実行前後でレコードの状態が変わらないことを担保する

以上のような実装に修正することで、既存のユニットテストの挙動を変えずにPHPUnit\DbUnitのリプレイスを行うことができました。

最後に

言語のバージョンアップを行うと、ライブラリが使えなくなる場合が多くあります。 そういった事象に遭遇した時に、「今まで通りの挙動を担保するためにどのように問題を解決するか」が大切であることを今回の課題を通して学ぶことができました。 バージョンアッププロジェクトを進めていく中で今回のような問題にまた遭遇する可能性は高いですが、1つ1つ問題を解決していこうと思います。