出来るだけコードからは不確実性を除外したい、という話


株式会社オズビジョンのユッコ (@terra_yucco) です
私は古く挙動が明らかでないコードに対してテストコードを書き、そのあと該当処理に機能追加をしたりリファクタリングをしたり、というのが好きです。
何度かそれを行ってきた中で、頻出してテストコードが書きにくいパターンがあるなあ、と感じたので個人メモに残します。

業務上重要な値を 現在日時 で定義する

サンプルコード

<?php
class Hoge {
    public $dataDir = '/tmp/hoge';
    public function createFugaFile()
    {
        $rawData = $this->getRawData();
        $filePath = $this->dataDir . '/' . date('Ymd') . '.log';
        file_put_contents($filePath, $rawData);
    }
}

上記の問題点

まだ Ymd なので優しい方ではあるのですが、動作したタイミングによっては日を跨ぐ可能性があります。
この例では、日が変わるギリギリなどに起動した場合、起動自体は 2019-05-17 だったのにファイル名が 20190518 になるなんてことは普通に発生します。
※それどころか getRawData() に要する時間がめちゃめちゃ長ければどんどん日付はズレていきますが、それはまた別の話

テストコード

「起動した日付でファイル名を付ける」という仕様認識だと、以下のようなテストコードを書きたくなりますが、CI などで深夜にテストが実行されると失敗する可能性があります。

<?php
use PHPUnit\Framework\TestCase;

class HogeTest extends TestCase
{
    public function testCreateFugaFile()
    {
        exec('rm -fr ' . DATA_DIR . '/*');

        $class = new Hoge();
        $class->createFugaFile();

        $expectedPath = '/tmp/hoge/' . date('Ymd') . '.log';
        $this->assertFileExists($expectedPath);
    }
}

$class->createFugaFile(); 内で日付を取得するタイミングと、テストコードの最後で assert 用の日付を取得するタイミングの間で日を跨いだ場合では日が変わっていた場合、などがこれに該当します。

実装を変更しない前提で、日跨ぎにある程度耐えるテストコード

実際の処理が終わってからテストコード側でファイル名を作ることを利用し、そのタイミング、もしくは 1 日前の日付でファイルを作成し、存在する側で assert をかけています。
とはいえ、さらにズレた場合には対応できませんし、数書くテストコードで全部これになど対応できません。

<?php
use PHPUnit\Framework\TestCase;

class HogeTest extends TestCase
{
    public function testCreateFugaFile()
    {
        exec('rm -fr ' . DATA_DIR . '/*');

        $class = new Hoge();
        $class->createFugaFile();

        $today = date('Ymd');
        $yesterday = date('Ymd', strtotime($today . '-1 day'));
        $expectedPath = array(
            '/tmp/hoge/' . $today . '.log',
            '/tmp/hoge/' . $yesterday . '.log',
        );
        $existPath = file_exists($expectedPath[0]) ? $expectedPath[0] : $expectedPath[1];
        $this->assertFileExists($existPath);
    }
}

サンプルコードの改善提案

コード自体はそこまできれいなものではありませんが、ここで書きたいのは $date のような形で外からも注入ができるように実装しておけば、各段にテストが楽になるということです。
もちろん「指定しない場合に現在日時から取得した Ymd になる」というケースはテストが必要で、その部分を夜間自動実行などするなら冒頭と同じ問題が付きまといます。ただ、少なくともその部分を一部に閉じ込めることで、他の大半のテストは冪等性を保った状態で自動でも実行できるようになります。

<?php
class Hoge {
    public $dataDir = '/tmp/hoge';
    public function createFugaFile($date = null)
    {
        if (is_null($date)) {
            $date = $this->getCurrentDate();
        }
        $rawData = $this->getRawData();
        $filePath = $this->dataDir . '/' . $date . '.log';
        file_put_contents($filePath, $rawData);
    }
}

テストコード

<?php
use PHPUnit\Framework\TestCase;

class HogeTest extends TestCase
{
    public function testCreateFugaFile()
    {
        exec('rm -fr ' . DATA_DIR . '/*');
        $targetDate = '20190517';

        $class = new Hoge();
        $class->createFugaFile($targetDate);

        $expectedPath = '/tmp/hoge/' . $targetDate . '.log';
        $this->assertFileExists($expectedPath);
    }
}

※これらのコードは PHP を実際には実行できない環境で記載しており、一部の定義などを省略していますが、大きな誤りなどを見つけたらぜひご指摘ください。

Conclusion

歴史のあるコードほどこういう部分があるのですが、実行したタイミングなどに依存する不確実性を持った部分は、できるだけ小さく閉じ込め、その影響が少なくなるように他を設計できると幸せになれるような気がしました。