uzullaがブログ

uzullaがブログです。

header後にdieするテストのアンチパターン

明後日土曜日はPHPConference Fukuoka 2019ですね、楽しみなuzullaです。

さて、Hachioji.pmのSlackでPHPのテスト手法について質問があって、結果的にその解決策はいいのが無かったんですけど、過去こういうことはしたことがあるよってメモ書きです。

エントリ名に「header後にdieするテストのアンチパターン」とかきましたが、そもそもheader() からのexit() コンボがアンチパターンですので、イケてるフレームワークを正しく使う方々には本エントリは役立たないはずです。

PHPのexitは終了してしまう

さて、phpで以下みたいな「リダイレクトした直後にexit」というコードはしばしば見かけるものです。

<?php
if($need_redirect){
  header("Location: /");
  exit;
}

ぐぐってみるとわかりますが、本当に多くて、本当かよと思います。しかし、実際俺もかいてた事があるので本当です。

で、こういうコードをテストしようと思うと、理論上以下みたいになるので、phpunitが終了します。

<?php
use PHPUnit\Framework\TestCase;

class MykTest extends TestCase
{
    public function testOne()
    {
      //実際にはここにべた書きするわけないですが
        if($need_redirect){
          header("Location: /");
          exit;
        }
    }

    public function testTwo()
    {
        // ここがテストできない
    }
}

つらい。

こういうこともできない

register_shutdown_functionとかを知っていると、以下みたいにかけるかなと思いますが、わりとできません。

<?php
use PHPUnit\Framework\TestCase;

class MyTest extends TestCase
{
    /**
     * @test
     * @expectedException \Exception
     */
    public function testOne()
    {
        register_shutdown_function(function () {
            throw new \Exception("don't die!");
        });

        exit();
    }

    public function testTwo()
    {
        echo "plz test me!!!";
    }
}

register_shutdown_functionのスコープはphpunitの外にとびでちゃうので、ここで投げてもphpunitがキャッチしてくれません(というふうに私は認識しています)

そもそもexitはphpunitをまるごと殺そうとしますので、キャッチできたとしてもdieまっしぐらです。

evalもだめ

<?php
use PHPUnit\Framework\TestCase;

class MyTest3 extends TestCase
{
    public function testOne()
    {
        eval("exit();");
    }

    public function testTwo()
    {
        echo "plz test me!!!";
    }
}

他の言語の経験があると、一見できそうです。でもevalはやっぱりこのスコープで実行されるのでダメです。(終了しちゃいます)

https://www.php.net/manual/ja/function.eval.php

解決策は?

無い(迫真)

無いので、以下みたいなものもあるようです。ブクマも数件ついてます、皆さん悩みは一緒ですね。

github.com

緩募:Pure PHPで、exit/dieでphpunitを殺さない黒魔術をご存知の方は教えてください。

さて、「runkitは?」という声もきこえてきますが、exitは関数ではない。

(動的な)ソースフィルタは?という怖い声がきこえてきますが、尋常な精神では無理。

「いやいや、コードをリファクタしようよ!」という正気の意見はここでは黙殺します、世の中にはできないこともある。

「exitを例外に単純置換しよう!」といったある意味PHPerらしい動けばいい系アドホック解決策は、解決はするかもしれませんが早晩似たような地獄に到達するのでやめたほうがよいです。

(え?exitの代わりにユーザー定義な致命的Errorを投げる?…フム、興味深さもありますね)

で、結局、exitが絡む時にphpunitを殺さない為には別プロセスで実行するしかない*1。みんな大好きなexecしましょう(正気かギリギリのライン)

事案

で、「私の場合は」あるリダイレクトがちゃんと指定のURLにリダイレクトされているか確認したかったことがある。

非常にアレなのでわすれてましたが、こういうことを過去してしまいました。

<?php
use PHPUnit\Framework\TestCase;

class MyTest3 extends TestCase
{
    public function testOne()
    {
        // dumb_exit.phpは勿論最初からファイルをつくっておいておいてもよい
        file_put_contents("dumb_exit.php", "<?php 
        register_shutdown_function(function(){
            echo var_export(headers_list()); // ウッ
        });

        // アプリをうごかしているような体裁を泣きながら整える
        $_SERVER['REQUEST_URI'] = "/hogehoge";
        $_GET['hoge'] = "fuge";
        require_once(__DIR__.'/vendor/autoload.php');

        WebApp::bootup();
        // この中で、
        // header(\"Location: http://example.jp/\");
        // exit();
        // が実行されるとおもってください
        ");
      
        exec("php-cgi dumb_exit.php", $out, $exitCode); // php-cgiでないとヘッダーがとれない

        $this->assertEquals(0, $exitCode); // お好みで

        $stdout = implode("\n", $out);
        list($header, $body) = preg_split("/\n\n/u", $stdout, 2); // ヘッダー分離

        $header_list = eval("return {$body} ;"); // アッ
        $this->assertEquals(1, count(preg_grep("|\Alocation: https://example.jp/\z|ui", $header_list)));
    }

    public function testTwo()
    {
        $this->assertIsString("plz test me!!!");
    }
}

安易に真似されても何なので説明は省略しますが、exitが呼ばれた時にセットされていたヘッダーを、標準出力の文字列経由で配列で受け取って検証する、という感じです。

これはregister_shutdown_functionの中でも、headers_list()はうごくし、var_exportと標準出力をうまいことすればとれないことはない(気もする)という話でもあるのですが、

書いておいて唐突ですが、これは本当によくないので、アンチパターンですね、マネしないようにしましょう!!

(なお、「headers_listしなくても出力のヘッダーを分離すればいいだけじゃん?」というご質問については全くそうなのですが、私は文字列操作が嫌いだったようです、上の中でもめっちゃしてるくせに)

がんばってseleniumとかpuppeteerとかでE2Eしような!!

header吐いてexitしちゃう、しかもreq/resオブジェクトみたいなは無いし、テストできない。そういったレガい案件ではすなおにE2Eするのがセオリーなのだと思います。俺だって人にきかれたらそう答える。

しかし、人も金もなく、ガンガン走らせたいユニットテストごときにヘッドレスブラウザを飼育するのもコスパがよいとはいえません。そのために人はこういった苦難の道を歩みがち、実に人生という感じがします。

素直に最初テスタブルなアプリにしましょう。

しかし人生には取り返しのつかない事だってあるよね。

こちらからは以上です。

*1:そういえばforkで解決…できるのかな…?