PHP「あいまい比較」におけるテスト対象選定のプロセスをまとめてみた

はじめに

この記事は、CYBOZU SUMMER BLOG FES '25の記事です。

こんにちは、QAエンジニア職能のreo(@i_moqa)です⛄️
私はGaroonで使用しているOSS(オープンソースソフトウェア)の更新や、セキュリティ維持・向上のための開発・保守を担当するYukimiチームに所属しています。

blog.cybozu.io

garoon.cybozu.co.jp

気づけばサイボウズに入社して2年目、チームのプロジェクトに本格的に関わる機会も増えて、少しずつできることの幅が広がってきたなと感じています。

さて今回は、私が所属するYukimi チームで取り組んでいるプロジェクトのひとつ、PHP 8.0から導入された「あいまいな比較演算子の仕様変更」のテスト対象を選定したプロセスについて紹介します。PHPの仕様変更が Garoonの既存コードにどのような影響を及ぼすのかを調べて、リスクに応じてテストすべき対象を絞り込んでいったプロセスをQAエンジニアの視点からまとめました。

1. PHP「あいまい比較」の仕様変更について

PHPの比較演算子は、大きく分けて2種類あります。 ひとつは、値だけでなく型も含めて比較する「厳密な比較」(たとえば === や !== など)。 もうひとつは、異なる型でも値だけを比較する「非厳密な比較」(たとえば ==、!=、>= など)です。 このうち非厳密な比較は、比較対象の型が違っていても自動的に型変換を行うため、「あいまい比較」とも呼ばれています。

PHP 8.0のアップデートで、あいまい比較に数値と非数値文字列を比較する場合、 数値を文字列にキャストし、文字列と比較するという変更が入りました。

これにより、型が異なる値同士を比べたときの結果が、以前のバージョンとは変わってしまうケースが出てしまいました。

たとえば、以下のような例:

<?php

// PHP 7.4
var_dump(0 == "yukimi"); // true
// 0(整数型)と "yukimi"(文字列型)を比較
// 文字列が数値に変換できない場合は0とみなされるため、0 == 0 となり true になる

// PHP 8.0
var_dump(0 == "yukimi"); // false
// 0(整数型)と "yukimi"(文字列型)を比較
// 数値と数値に変換できない文字列を比較するときに、false になる


Garoonのコードにもこのあいまい比較を使用している箇所がいくつか存在しており、意図しない挙動が発生する可能性がありました。

現在は、こちらで紹介しているPHP 7.4とPHP 8.0で結果が変わるような比較演算を検出する自作のパッチを用いて、挙動が変わらないようにしています。ただ、このパッチはPHPのバージョンアップのたびに保守が必要で、将来的には技術的負債になる恐れがあります。そのため、将来的にパッチを外す場合を見据え、あいまい比較による影響範囲を事前に洗い出し、テストによって安全性を担保する必要がありました。

2. 調査の目的と方針を決める

この仕様変更の影響を調査するにあたって、最初に意識したのは以下の2点です。

  • この変更を受けて、Garoonにどのような影響が出そうか?
  • 影響を受ける箇所の中で、どの機能を優先してテストするべきか?

変更の影響をすべての画面やパターンでテストすると工数も時間もかかりすぎてしまうため、現実的ではありません。 そこで今回は、リスクに応じて優先順位付けを行ったうえで、影響が出そうな箇所を洗い出し、テストの対象を絞り込むという方針をとりました。

まずは、Garoonで比較結果が変わる既知のケース(たとえば0 == "yukimi"のようなパターン)をいくつか実際に試してみて、PHP 7.4とPHP 8.0の挙動の違いを確認しました。 このような再現を通じて、影響が出やすい処理の特徴をつかみ、それらがGaroonのどこで使われているかを調査する手がかりとしました。

3. ASTを用いて、影響箇所になりうるケースを洗い出す

次にGaroon内の各アプリケーションごとに、影響を受けそうな比較処理がどの程度含まれているかを調査しました。 最初は == などのあいまい比較をgrep コマンドで検索しようとしましたが、コメントや文字列リテラル内などの記述まで検出されてしまい、正確に抽出するのは難しいことがわかりました。

この課題に対して、実際に意味のある比較処理のみを抽出する必要があると判断し、1AST(抽象構文木)を用いた構文解析のアプローチを採用することにしました。 この方法は、こちらの記事を執筆された松尾さんに提案していただきました。

サンプルコード

<?php
require __DIR__ . '/vendor/autoload.php';

use PhpParser\ParserFactory;
use PhpParser\Node;

function main(): void
{
    $targetPath = '対象パス'; // 対象パスを指定
    $parser = (new ParserFactory)->createForNewestSupportedVersion();

    // 指定ディレクトリ配下のすべてのPHPファイルを再帰的に調べる
    $iterator = is_dir($targetPath)
        ? new RecursiveIteratorIterator(new RecursiveDirectoryIterator($targetPath))
        : [new SplFileInfo($targetPath)];

    foreach ($iterator as $fileInfo) {
        if (!$fileInfo->isFile()) continue;
        $ext = $fileInfo->getExtension();
        if (!in_array($ext, ['php'])) continue;

        // ファイルの内容を、AST(抽象構文木)に変換する
        $code = file_get_contents($fileInfo->getPathname());
        $parser = (new ParserFactory())->createForNewestSupportedVersion();
        try {
            $ast = $parser->parse($code);
            inspectAst($ast, $fileInfo->getPathname());
        } catch (Throwable $e) {
            // エラー処理
        }
    }
}

// ASTから検出したいパターンを探す
function inspectAst($nodes, string $filepath): void
{
    if (is_array($nodes)) {
        foreach ($nodes as $node) {
            inspectAst($node, $filepath);
        }
        return;
    }
    if (!$nodes instanceof Node) return;

    // 検出したいノードのパターンを追加(例として「変数と整数リテラルの比較」を検出するパターン)
    if (
    ($nodes instanceof PhpParser\Node\Expr\BinaryOp\Equal ||
     $nodes instanceof PhpParser\Node\Expr\BinaryOp\NotEqual ||
     $nodes instanceof PhpParser\Node\Expr\BinaryOp\Smaller ||
     $nodes instanceof PhpParser\Node\Expr\BinaryOp\Greater)
    &&
    (
        ($nodes->left instanceof PhpParser\Node\Expr\Variable && $nodes->right instanceof PhpParser\Node\Scalar\LNumber)
        || ($nodes->left instanceof PhpParser\Node\Scalar\LNumber && $nodes->right instanceof PhpParser\Node\Expr\Variable)
    )
    ) {
        echo "{$filepath}:{$nodes->getStartLine()}\n"; // ファイルパスを出力
    };

    // 他のパターンも必要に応じて追加

    // 再帰的に子ノードを探索
    foreach ($nodes->getSubNodeNames() as $subNodeName) {
        inspectAst($nodes->$subNodeName, $filepath);
    }
}

main();

実施したこと

具体的に行っていることは以下です。

  1. nikic/php-parser を使って、PHPソースコードをASTに変換
  2. 既知の影響を受けるケース(例:0 == "yukimi" のような比較)に該当するノードのパターン(Expr_BinaryOp_EqualExpr_BinaryOp_NotEqualなど)と照合し、該当箇所を抽出

ただし、このアプローチでもデータ型の解釈までは行っておらず、たとえば「どの変数が数値でどの変数が文字列か」といった型の特定や、型の流れ(型推論)までは追えていません。そのため、比較の組み合わせが「実際に危険なあいまい比較になるかどうか」までは評価しきれず、あくまで構文上の候補を列挙するに留まりました。

この調査を通じてすべてのケースを網羅できたわけではありませんが、Garoon内の各アプリケーションで、あいまい比較による影響がどの程度ありそうかを大まかに把握しました。

4. リスク評価で優先度をつける

Garoonのコード内で影響が出そうな比較処理をAST解析で洗い出したあと、2リスクベースドテストの考え方を取り入れて、テストの優先順位を決めました。 すべての箇所を網羅的にテストするのではなく、リスクの高い部分に絞って重点的に確認することで、限られたリソースの中でも効率よく品質を確保できることを期待しました。

今回の評価では、以下の2つの観点を重視しました。

頻度

AST解析の結果、あいまい比較が多く使われているアプリケーションほど影響が大きいと判断し、使用頻度が多いものを優先的にテスト対象としました。

影響度

テスト対象アプリケーションの機能一覧を作成し、各機能で曖昧比較が使用されている箇所を特定しました。その上で、以下のように仕様変更による影響度を評価し、ユーザーへの影響が大きい機能から優先的にテスト対象とすることにしました。

影響度 説明
ユーザーが正常に操作できなくなる致命的な影響が出るケース
管理画面などのユーザーの直接操作は影響しないが支障が出る可能性あるケース
内部処理や限定的条件下のみ影響が出るケース


こうした評価をもとに、ユーザー入力や画面表示に関わる処理を中心に重点的にテストを行うことにしました。一方、影響範囲が限定的な管理機能などは、今回はテストを見送り、今後も注視する対象にしています。

5. 今後の展望

今回のASTを使ったアプローチは、テスト範囲を絞る上で有効でしたが、解析には限界があり、すべての影響箇所を網羅するには不十分でした。今後は、より精度の高い検出を目指し、別のアプローチも検討したいなと考えています。

まとめ

今回のテスト対象選定のプロセスを通じて、手探りで情報を集めながらテストの優先順位を決定できたことは学びです。
不具合が一切発生しないことを証明するのは現実的に難しい(というか不可能)です。しかし、「すべてのケースをテストする」のではなく、限られた工数の中で効果的に品質を守ることを考えることは、この仕事の面白さだなと再認識できたタスクでした。


1 指定されたプログラミング言語の文法に従って、ソースコード構造を表す階層的中間プログラム表現。

2 テスト活動とリソースのマネジメント、選択、優先順位付け、利用を対応するリスクタイプとリスクレベルに基づいて行うテストアプローチ。