インフィニットループ 技術ブログ

2016年12月26日 (月)

著者 : m-yamagishi

PHP7 の assert による簡易テストはいいぞ。

最近通勤を自転車or徒歩からジョギングに変えて、体調がとても良くなった YamaYuski です。
こちらの PHP7 RFC の記事でも話題に上げましたが、 PHP7 では assert がより使いやすい形に改善されました。
今回は「そもそも Assertion とは?」という話から、実際の PHP コードではどう使えばいいのかまでを紹介します。
Assertion!

Assertion/Expectation とは?

PHP の assert は「Assertion(表明)」もしくは「Expectation(期待)」と呼ばれる機能を提供します。
Assertion とは、「ここではこういった入力・結果がtrueである(つまりそれ以外は異常)」という前提条件を コード内に記述(=表明) することです。

// 正常である
int user_count = getLoginUserCount(); // returns 1500
assert(user_count >= 0, "ログインユーザ数が異常である");
// 異常である(強制終了)
int user_count = getLoginUserCount(); // returns -4
assert(user_count >= 0, "ログインユーザ数が異常である");

その定義から外れた値が入力された場合、多くはそれを「ソースコードのバグ」とみなし、致命的なエラーとして処理を終了させます。
ユニットテストの機能としてこの Assertion が導入されていることも多いです。

// 例) phpunit
$user_count = getLogincUserCount();
$this->assertTrue($user_count >= 0, "ログインユーザ数が異常である user_count=" . $user_count);

表明については 表明 – Wikipedia が詳しいです。

Assertion 導入のメリット

さて、 Assertion を導入することでどのようなメリットがあるでしょうか。

1. 実装を見れば、どういう条件でこの場所が動くのかを確認出来る

該当箇所を見るだけで、そこで必要な前提・事後条件をプログラマーが確認することが出来ます。

function getUserLevelAverage(array $users) : float
{
    return array_reduce($users, function ($sum, $user) {
        // $user 連想配列に正しいキーが設定されていること、というコメントを書かなくてもわかるようになります
        assert(array_key_exists('level', $user));
        return $sum + $user['level'];
    }, 0) / count($users);
}

2. デバッグにかかる時間が減る

実装コードに対してAssertionを各所に埋め込むことで、 デバッグの効率化 を生み出します。
とりあえずレスポンスは来たけど、この値はおかしい、となった場合。
まずこの値を設定しているコードを上から一つ一つ追う必要があります。非常につらいし、時間がかかります。
例外により処理が終了した場合も、スタックトレースをうつらうつら眺めながら、その中のどこが悪かったのか探す必要がありますね。
途中で例外がキャッチされていると、スタックトレースが出なかったりしてさらに分かりづらくなってしまいます。
Assertion 機能は、異常値が入った瞬間に処理を終了させるため、コード上のどの部分が異常なのかがすぐに分かります。
それだけではなく、例外はそのエラー文を自前で生成する必要がありますが、 Assertion では「異常と判断された式」がエラー文として出力されます。
先ほどの例で行けば、
Fatal: AssertionError: assert($user_count >= 0, "ログインユーザ数が異常である") in -:7
といったように、どういった式がダメだったのかがエラー文の段階で分かるので、すぐにその変数や関数の調査に当たることが出来ます。

3. 前提条件が変わった時、エラーとなりコード修正が必要であることを早期発見出来る

例えばテーブル定義を変更して、依存していたカラム名が変わった場合、 assert を利用していない場合は例えば
NOTICE: undefined index
が出るだけで、 null として処理が進んでしまうかもしれません。
ログをちゃんと読んだり、 null では例外になるのであれば発見可能ですが、場合によっては見逃してしまう危険があります。
そこで
assert(!is_null($record["user_icon_name"]));
のようなものを一行埋め込んでおくだけで、見逃さず確実に修正を促すことが出来ます。

4. 本番運用ではゼロコストで、開発時のみの補助と出来る

「運用時にはその機能を停止/除去する」といった機能を持っているものがほとんど( PHP7 も同様)なので、開発時のみ厳重なチェックを行い、運用時に不要な処理を省いて速度に影響を与えません
そのため、どれだけたくさん書いても、どれだけ処理に時間がかかるロジックでチェックしても、本番には全く影響しません。
※書きすぎると本物のロジックが分からなくなってしまうので、適度に加えるのが大事。

Assertion導入のデメリット

正しい利用を心がけないと、どんな機能にもリスクはあります。
assert は基本的に本番運用では機能が無視されることを前提にしています。
なので、ユーザ入力値のバリデーションやDBの値の確認に使ったりすると、外部の要件チェックが本番だけ除かれ、不正な値で処理を続けてしまう危険があります。

const comment = getUserInput("comment");
// × 本番では0文字でも通ってしまう
assert(comment.length > 0);
// 〇 バリデーションは例外として扱う
if (comment.length == 0) {
    return new Error("コメントを入力してください");
}

また、 assert 構文内に副作用を持つ関数を埋め込むと、本番時のみその副作用が反映されないといった不具合にも繋がります。

// × 本番でこの副作用(レコードの更新)が反映されなくなる
assert(updateUserRecord($record));
// 〇 副作用はassert外で行うこと
updateUserRecord($record);

環境によって起こるかどうかが変わる、という挙動は調査を難しくする原因になりますので、間違った使い方をしないのを徹底する必要がありますね。

似たような機能との使い分け

Assertion に似た機能がいくつかあります。上記にも書いた通り、使い道を間違えると Assertion が逆にバグを生んでしまうので、使い分けを覚えましょう。

バリデーション

ユーザ入力値やDBに保持された値など、アプリケーションの外部から入力された不定値が正しいかどうかを判定する機能です。
正しくない場合は処理をせず、不整合や異常値の保存を未然に防ぎます。
これはもちろん本番でも利用する機能なので、 Assertion は使えません。

例外

例外は、不正な処理が行われた場合にエラーを大元の関数に返したり、通常は起こらないが、外部要因(接続障害等)により起こる可能性があるものを取得して、適切にエラー処理を加えるための機能です。
PHPでいうと RuntimeException は Assertion と全く異なる意図を持っているのですが、 LogicException とは似たような目的を持っています。これに関しては後半で詳しく。

ユニットテスト

最初の例の通り、Assertionがユニットテストで行われることも良くあります。

  • 運用環境で実行されない点は同じ
  • Assertion は実装コード上に条件が書かれるので、テストコードを見なくても条件が分かるというメリットがある
  • Assertion は書かれてもその部分を実行しない限りチェックが通らないが、ユニットテストは書く=実行するということなので、ちゃんと実行時判定を行える
  • ユニットテストは関数(メソッド)単位の処理が正しいかどうかをチェックする

Assertion はより粒度の細かい(1変数単位)ユニットテストとして見ることも出来ます。
ユニットテストはモックを用意したり副作用を確認したりと実装コストがかかりますが、 Assertion は一行条件式を書くだけなので、サクサク手軽に追加することが出来ます。

PHPで assert を使う場合に注意すること

PHP: assert – Manual
公式マニュアルを見てもわかる通り、 assert という関数は PHP4 時代から既に実装されていました。しかしこれが中々使いづらく、

  • 条件式を文字列で渡さない限り、エラー文に式が出力されない
  • 運用時に機能を無視することが出来ない(必ずコストがかかる)

ため、 Assertion/Expectation としての要件を満たしていないのでほとんど利用実績がありませんでした。
しかし、 PHP7 で ビルトイン関数 から 言語構造 (isset とか empty とかと同じ)に代わり、要件を満たすようになりました。
PHP5.6 プロジェクトで利用するのは控えた方が良いと思いますが、 PHP7 ではガンガン使っていって欲しいです。
但し、色々と注意する事がありそうなので、そちらは気を付けていきましょう。

ini設定を開発環境と運用環境で変更するのを忘れない

二つのiniディレクティブが大事です。

# 開発環境(vm,dev等)
zend.assertions = 1
assert.exception = 1 # これを設定しないと、デフォルトではErrorが投げられません
# 運用環境(staging,production等)
zend.assertions = -1
assert.exception = 0

エラー時のメッセージを追記すると、式が表示されなくなる

$ php -d zend.assertions=1 -d assert.exception=1 -r "$this_is_true = false; assert(true === $this_is_true);"
Fatal error: Uncaught AssertionError: assert(true === $this_is_true) in Command line code:1
Stack trace:
#0 Command line code(1): assert(false, "assert(true ===...")
#1 {main}
thrown in Command line code on line 1

こうすると式がErrorの$descriptionに入っていますが、

$ php -d zend.assertions=1 -d assert.exception=1 -r "$this_is_true = false; assert(true === $this_is_true, 'this is false');"
Fatal error: Uncaught AssertionError: this is false in Command line code:1
Stack trace:
#0 Command line code(1): assert(false, "this is false")
#1 {main}
thrown in Command line code on line 1

自分で説明を入れると、代わりに式が消えます。両方表示してほしかった。
また、Elixirのテストのように変数の中身を展開とかもしてほしかったけど、さすがに難しいか…

LogicException との兼ね合い

この Assertion は、LogicException(と InvalidArgumentException 等の継承例外)と同じような働きを期待します。
要するに、どちらも「実装が正しいかどうか」の判断で用いられます。
二つの違いは、「信頼出来ない外部からの入力が正しいかどうか」を判断するのか、「自分が管理し実装している部分に関しての入出力が正しいかどうか」を判断するのかに分かれます。

// 少し雑な例ですが...
class Requester
{
    public function get(string $url, array $params = []) : Response
    {
        return $this->request('GET', $url, $params);
    }
    private function request(string $method, string $url, array $params = []) : Response
    {
        if (0 !== strpos($url, 'http')) {
            // 外部の入力が信頼出来ないので、運用環境でも失敗時は例外として送出
            throw new \InvalidArgumentException('有効なURLを指定してください');
        }
        // この変数は内部からでしか生成されないはずなので、assert
        assert('GET' === strtoupper($method) or 'POST' === strtoupper($method), '異常なメソッド指定 ' . $method);
        // ...
    }
}

このように、機能を正しく利用していないことを外部(=上層)に指示するために、 RuntimeException と同じく例外を送出することでハンドリングを促す場合は LogicException を用い、信頼出来る内部の状態や操作に対して、「自分の実装が正しいことを期待(Expectation)する」ために assert を利用する、などの使い分けが良いでしょう。
ダメなものをダメと言うのが LogicException、良いものを良いと言うのが Assertion(=Expectation)と言えば分かりやすいかもしれません。
ただし、LogixExceptionでは、

try {
    throw new \LogicException("未実装");
} catch (\Exception $e) {
    Log::error($e->getMessage());
}
// 処理を続けてしまう

このようにRuntimeExceptionと区別されずにキャッチされてしまい、そのまま処理が続行してしまうような危険性もあるので、使い分けには注意が必要です。
assert に失敗した場合は AssertionError extends Error implements Throwable を投げるので、 Error とか Throwable とかでcatchしていない限りちゃんと強制終了してくれます。

PHPでの実際の使い方

assert は非常に簡素な機能なので、誰でも簡単に実装に加えることが出来ます。

function getUserLevelAverage(array $users) : float
{
    if (empty($users)) {
        // これは本番でも想定される値なのでバリデーション
        return 0.0;
    }
    $sum = 0;
    foreach ($users as $user) {
        // 正しい値かどうかのチェック
        assert(array_key_exists("level", $user));
        assert(is_int($user["level"]));
        assert($user["level"] > 0);
        $sum += $user["level"];
    }
    return $sum / count($users);
}
switch ($code) {
case 1:
    // ...
    break;
case 2:
    // ...
    break;
case 3:
    // ...
    break;
default:
    assert(false, "不正なcodeを入力した code=" . $code);
}
function getLevel() : int
{
    assert(false === is_null($this->level), "プロパティ設定前に取得は出来ないぞ");
    return $this->level;
}

「こうなっていて欲しい」と自分のコードに期待する思いを、 assert に書き起こしてください!
ということで、皆さん PHP7 で良い assert ライフを送ってくださいね!

参考文献

ブログ記事検索

このブログについて

このブログは、札幌市・仙台市の「株式会社インフィニットループ」が運営する技術ブログです。 お仕事で使えるITネタを社員たちが発信します!