CakePHP + MySQL アプリのテスト時間を 72% 削減した話

この記事は CakePHP Advent Calendar 2018 の23日目の記事です。

CakePHP アプリケーションのユニットテストでボトルネックになりがちなのがデータベースへのディスク I/O 時間ですが、そういったプロジェクトでは FriendsOfCake/fixturize プラグイン を導入すると大幅に高速化できる可能性があります。

実例として、テストに18分も掛かっていた MySQL を使ったプロジェクトありましたが、導入後なんと5分にまで短縮されました。(約 72% の高速化🎉)

結果として早いサイクルで CI を回せるようになり開発スピードも上がりました。

何がボトルネックになっていたか?

テストケースごとに実行されるテストデータのリロードクエリがボトルネックになっていました。リロードクエリとは TRUNCATE TABLE クエリと INSERT クエリのセットのことで、積み重なると高コストなクエリになります。

テストデータのリロード時間はテストケースが増えるほど支配的なものになる

CakePHP のユニットテストは、テストデータを Fixture クラスとして定義しておき、テストクラスの側で使用したい Fixture 名の一覧を記述します。

例として、テストクラスは以下のようになります。

<?php
namespace App\Test\TestCase;
use Cake\TestSuite\TestCase;

class ArticlesTest extends TestCase
{
    // 3テーブル分のテストデータの使用を宣言
    public $fixtures = [
        'app.articles',
        'app.comments',
        'app.users',
    ];

    public function testCase1() {
        // テストコード
    }

    public function testCase2() {
        // テストコード
    }
}

このテストを実行すると testCase1()testCase2() のテストケースの実行前に、それぞれ3テーブル分のリロードクエリが実行されます。同じように、このクラスに100個のテストケースがあったとしたらリロードクエリは最低で300回実行されます。

極端な例を挙げましたが、リロード回数はテスト数とフィクスチャ数の掛け算で増えていくため、簡単に無視できない I/O コストになることは想像に難くないと思います。

毎回テストデータをリロードする理由

無意味に何度もリロードしている訳ではありません。毎回リロードすることで、テストが書きやすくなるというメリットがあります。

テスト中にいくらテストデータを書き換えたとしても、次のテストでは元の状態にリセットされているので、テストケース間でテストデータの状態について副作用を気にする必要がなくなります。

この高速化プラグインがすること

FriendsOfCake/fixturize のソースを見ると分かりますが、このプラグインのすることは非常にシンプルです。上述の「毎回同じテストデータの状態でテストを開始したい!」という要求を満たしつつ、必要なときだけリロードするようにし、リロードクエリの実行回数を削減します。

具体的には、初回のフィクスチャロード後に CHECKSUM TABLE クエリ でテーブルのチェックサム値を取っておき、次以降のリロードは差分があったときだけ行うようにします。

チェックサム値に差分がなければテストデータが変更されていないということなので、リロードもスキップして問題ないということになります。

プラグインで高速化できる可能性のあるプロジェクト

ということで、以下に該当するプロジェクトであればプラグインを導入するだけで大幅にテスト時間を削減できる可能性があります。

  • CakePHP 3.x 製アプリ
  • MySQL / MariaDB / Percona のいずれかを使用している
  • テストケース数とテーブル数が多いプロジェクト
  • 複数テーブルを参照する結合的なテストをしているプロジェクト

また、もしこのプラグインが使えない環境のプロジェクトであっても、不要なロードを減らすというアプローチ自体は有効かもしれません。

おまけ: その他のアプローチ

他のアドベントカレンダーになりますが、素晴らしい記事を見かけましたのでそちらも紹介しておきます。

DB ストレージを tmpfs というオンメモリのファイルシステムに差し替え、高コストなディスク IO を無くすることで高速化するというアプローチです。私も手元のプロジェクトで試してみましたが、実際に 40% ほど高速化できました。FriendsOfCake/fixturize プラグイン と併用することで更に高速化が見込めます。

PHP で empty() を使うことを避けてみる

PHP には empty() という便利な関数がありますが、empty() はよく理解して使用しないと意図しないバグを生む原因になります。
( empty() は厳密には関数ではなく言語構造ですが便宜上関数と呼びます。 )

よく言われる話ですが、改めてまとめてみたいと思います。

なぜ empty() は安全なコーディングを妨げるか

通常 PHP は未定義の変数を使用しようとすると「そんな変数ないよ」と警告を表示してくれますが、empty() や isset() には未定義の変数に対して使用しても警告がされないという特別な性質があり、変数名のタイプミスに気がつけなくなってしまいます。

例えば以下のようなケースです。

$fruits = array('apple', 'orange');
if (empty($fruts)) {
    echo 'これは空配列です';
}

これを実行すると $fruits が空配列 ではない にも関わらず「これは空配列です」と出力されてしまいます。empty() に渡す変数名の途中の i が抜けておりタイプミスしているからです。

また、公式マニュアルの PHP 型の比較表 のページを見ると分かりますが、空文字や 0 の場合など、empty() は多くのパターンで true を返してしまします。判定の基準が緩い関数のため、よく理解した上で使用しないと意図しないバグを生んでしまうリスクがあるということです。

ではどうするか

empty() を使用したくなったとき、なぜ使用したいのかを改めて考えてみるといいと思います。

その気になれば empty() を全く使わずにコーディングすることも可能です。
まずは empty() を使用しなくても済むかどうかを検討してみることは良いことです。

変数が null かどうかを調べたい場合

if ($var === null)if (is_null($var)) などの方法であれば純粋に変数が null かどうかだけを調べられるので安全です。

変数が空配列かどうかを調べたい場合

if (is_array($var) && !$var) などで調べられます。

PHP 5.4.0 以降の場合はシンプルに if ($var === []) で調べられるのでおすすめです。[]array() の短縮記法です。

変数が未定義かどうかを調べたい場合

事前に変数を定義するよう心がけてコーディングすれば、大抵の場面で未定義かどうかを調べなくても済むはずです。

どうしても未定義かどうかを調べたい場合は empty() より isset() を使用するほうが安全だと思います。ただし isset() を使用した場合であっても変数名のタイプミスに気がつけなくなる危険性があることは認識しておいた方がいいです。

それでも empty() を使いたい場合

色々 empty() を使わずに済ます方法を書いてきましたが、それでもなんとなく NG な値かどうかを調べたい場合 empty() は便利ですよね。

ですがちょっと待ってください。そんなときでも Boolean への強制変換を使えば empty() と全く同じチェックをすることが可能です。
PHP 型の比較表 を見ると empty() と Boolean への強制変換 if ($var) の結果は完全に対になっていることが分かります。

つまり if (empty($var) === !$var) は常に成立するということです。
念のため調べてみます。

<?php
$vars = [
    'TRUE' => TRUE,
    'FALSE' => FALSE,
    '1' => 1,
    '0' => 0,
    '-1' => -1,
    '"1"' => "1",
    '"0"' => "0",
    '"-1"' => "-1",
    'NULL' => NULL,
    'array()' => array(),
    '"php"' => "php",
    '""' => "",
];
foreach ($vars as $label => $var) {
    $result = (empty($var) === !$var) ? 'TRUE' : 'FALSE';
    echo sprintf("%s: %s\n", $label, $result);
}

これを実行した結果 は以下になったので間違いなさそうです。

TRUE: TRUE
FALSE: TRUE
1: TRUE
0: TRUE
-1: TRUE
"1": TRUE
"0": TRUE
"-1": TRUE
NULL: TRUE
array(): TRUE
"php": TRUE
"": TRUE

緩くチェックできることは PHP の良いところでもあるので PHP 型の比較表 とにらめっこしつつ活用していくといいと思います。

2018/02/18 10:17 追記:

「何故まともな静的解析ツール無しにコーディングをするのが危険なのか」というタイトルに書き直した方が良いとのコメントを頂きましたが、いま私が使用している PhpStorm や phpmd だと empty($undef) としても検出できないのですよね。

実際に typo したコードが静的解析やコードレビューをすり抜けてしまい痛い目を見たことがあるのでこの記事を書きました。ただ「そのために言語機能を制約してコーディングするのもどうなの」というのもごもっともな話だと思います。プロジェクト内で方向性があればそれに従えばいいと思います。

そもそも未定義の変数に対して empty() を使うことは言語として想定されている使われ方なので検出できる静的解析ツールも少ないのかなと思うのですよね。静的解析方面にあまり詳しくないのですがそこら辺どうなのでしょう。