PHPのテストで本当にdataProviderを使う必要があるのだろうか

PHPUnitにはdataProviderという機能がある。
パラメタライズドテスト、またはテーブルドリブン(駆動)テストで書くための仕組み。

僕はずっとこの機能が使いづらくて、普通にforeachで回す方が良いのではと思っている。
今日たまたまdataProviderを推奨している記事を続けて目にしたので、改めて考えを整理しておく。

dataProviderのメリット

foreachと比較したdataProviderのメリットはこちらの記事が分かりやすかったので引用する。
PHPUnitとデータプロバイダとテストケース生成

メリット1: setUp/tearDownが使える

確かにモックを使うテストならその方が助かる。

メリット2: どのデータでテストが落ちたのか分かりやすい

確かにforeachだとちょっと面倒。Goでテストを書いている時にt.Error()をいちいち書いていく感覚に似てる。

メリット3: 例外のテストとの組み合わせ

確かに例外が発生した段階で止まっちゃうと面倒。

dataProviderの(個人的)デメリット

上記のメリットはどれも同意するけど、個人的にはそれ以上にデメリットを感じてしまう。
それはテストの本体とdataProviderのメソッドが離れていること。ロジックとデータは近いところにある方が読みやすいと思うけど好みなのかもしれない。

例えばPHPUnitリポジトリにあるこのテストコード。
https://github.com/sebastianbergmann/phpunit/blob/cca308e970eebb1f21cd702071b09bfd40a1c705/tests/unit/Util/RegularExpressionTest.php

  1. testValidRegexのテストを読もうとする
  2. validRegexpProviderの定義を見る

という視線の移動と記憶が必要になる。
これなら↓のようにテスト本体とデータは一緒にあった方が個人的には読みやすい。

<?php
public function testValidRegex(): void
{
    foreach([
        ['#valid regexp#', 'valid regexp', 1],
        [';val.*xp;', 'valid regexp', 1],
        ['/val.*xp/i', 'VALID REGEXP', 1],
        ['/a val.*p/', 'valid regexp', 0],
    ] as $case) {
        $this->assertEquals($case[2], RegularExpression::safeMatch($case[1], $case[0]));
    }
}

(エラーメッセージとか一時変数に置くとかは省いている)

もちろんdataProviderの方が適切というケースもあると思うけど、同様に普通にforeachの方が良いケースもあると思う。何でもかんでもdataProviderを使うのは違う気がする。
このテストとか冗長なだけなのでは。
ただ「使うかどうかは個人の判断」にしちゃうとテストの書き方に統一性がなくなるから、プロジェクトとしては「必ず使う」でルール化した方がいいのかもしれない。

また個人的に読みにくいと感じる理由の一つは、dataProvider側だけを見ても値の意図が分からないから。

<?php
public function canonicalizeProvider(): array
{
    return [
        ['{"name":"John","age":"35"}', '{"age":"35","name":"John"}', false],
        ['{"name":"John","age":"35","kids":[{"name":"Petr","age":"5"}]}', '{"age":"35","kids":[{"age":"5","name":"Petr"}],"name":"John"}', false],
        ['"name":"John","age":"35"}', '{"age":"35","name":"John"}', true],
    ];
}

これはphpunit/tests/unit/Util/JsonTest.phpから拝借した。
それぞれの値の意図は、ここを見ただけでは分からない。
テストコード側の引数を見て初めて理解する。

<?php
public function testCanonicalize($actual, $expected, $expectError): void

テストコードとデータを行き来しないと読み解けない。これが僕にはつらい。

結局どっちが良いのか

最初に引用したdataProviderのメリットのうち2つは、「テストが落ちた時のメンテ性を上げる」ためのものだった。
僕があげたdataProviderのデメリットは「他人がコードを読む時」を想定したものだった。

つまり優劣というより重要視する観点が違うんだと思う。僕は可読性を大事にしている。でも「dataProviderの方が見やすい。foreachの方が見づらい」って人もいるだろうから、そうなるとforeachの方が良い理由はほとんどないのかな。

phpcon2018のLTで見たPREG_JIT_STACKLIMIT_ERRORについて調べた

先日のPHP Conference 2018でこのようなLTがありました。
RegExp Error caused by PHP upgrade 5.6 to 7.2

スライドはLT用にシンプルなので、同じ発表者さんのQitta記事を見た方が伝わるかもしれません。
PHPを7.2にバージョンアップしたら正規表現でマッチしない現象に出くわした

上記の背景は「5.6から7.2へのバージョンアップで発生した」わけなので、5.6と同じ挙動になるならpcre.jit=0で問題ないという判断かと思います。
(Qiitaにも書いてある通り本来はロジックを変えるのが理想だが、それが困難な状況ではJITを無効にするしかない)

でも、何か気になったので調べました。先に述べておくと、pcre.jit=0にするという結論に違いはありません。

簡潔なまとめ

安定のstackoverflow。ベストアンサーで簡潔にまとまっています。
PHP PREG_JIT_STACKLIMIT_ERROR - inefficient regex
ちなみにPHP7.2であれば、この質問者のコードをそのままコピペして実行すれば再現できます。
最後にvar_dump(preg_last_error());を追加すれば6(PREG_JIT_STACKLIMIT_ERROR)が表示されます。

後述するようにテキスト量ではなく正規表現の複雑さが問題になるので、自分で再現するコードを書くのは面倒なため助かります。
ただしこの記事の最後に書いた通り、PHP7.3だと正常終了します。

詳細なまとめ

PCRE JITの仕様書から

https://www.pcre.org/original/doc/html/pcrejit.html よりいくつか抜粋します。

JIT support applies only to the traditional Perl-compatible matching function. It does not apply when the DFA matching function is being used. The code for this support was written by Zoltan Herczeg.

いきなりですが、ここは良くわからなかった。
JITPerl互換のあるマッチングの場合のみ利用するよ。DFAマッチングの場合はJITは効かないからね」とありますがPCREはNFAなのでは?DFAで動くケースがあるんだろうか。

JIT support is an optional feature of PCRE. The "configure" option --enable-jit (or equivalent CMake option) must be set when PCRE is built if you want to use JIT

「pcreをインストールする時に--enable-jitオプションを付ければJITが有効になるよ」
PHP7からPCRE JITはデフォルト有効なので強制的に--enable-jitを付けているのかな。(未確認)

When a pattern is matched using JIT execution, the return values are the same as those given by the interpretive pcre_exec() code, with the addition of one new error code: PCRE_ERROR_JIT_STACKLIMIT. This means that the memory used for the JIT stack was insufficient

本題。
JITによる実行ではpcre_exec()と同じ実行結果に加え、PCRE_ERROR_JIT_STACKLIMITという新しいエラーコードを追加します。このエラーコードはJITスタックのメモリ確保が不十分という意味です」
PCRE_ERROR_JIT_STACKLIMITは冒頭のPREG_JIT_STACKLIMIT_ERRORと違う定数ですが、これは後述のコードリーディングにて。

When the compiled JIT code runs, it needs a block of memory to use as a stack. By default, it uses 32K on the machine stack. However, some large or complicated patterns need more than this. The error PCRE_ERROR_JIT_STACKLIMIT is given when there is not enough stack.

JITの実行にはスタック用にメモリを確保します。これはデフォルトでは32KBです。もしこのサイズで不十分な場合はPCRE_ERROR_JIT_STACKLIMITを発生させます」
デフォルトでは、とあるものの設定値をいじれるのはPCREのソースレベル(コンパイル前のC言語レベル)なので、PHP側から変更できる手段はないはず。

PCRE (and JIT) is a recursive, depth-first engine, so it needs a stack where the local data of the current node is pushed before checking its child nodes

「PCRE(とJIT)は深さ優先探索なので、ノードを確保するためにスタックが必要なんだ」

つまり↓のような理解です。

  • PCRE(とJIT)は正規表現のロジックとして深さ優先探索であり、各ノードを読み込むためにメモリ確保が必要
  • この処理のために使われるメモリは32KBで、これを超えるとエラーが発生する
    • この上限値はPHP側では変更できない
  • 深さ優先探索用のノードなので、テキスト量ではなく正規表現の書き方に依存する

PCREのソースコードから

PHP7.2.13のソースコードを読んでいます。
まず冒頭にあった発端のPREG_JIT_STACKLIMIT_ERRORの定義から。

https://github.com/php/php-src/blob/1549c6d26ed866f0322ba740effc7820a54805c8/ext/pcre/php_pcre.c#L222

REGISTER_LONG_CONSTANT("PREG_JIT_STACKLIMIT_ERROR", PHP_PCRE_JIT_STACKLIMIT_ERROR, CONST_CS | CONST_PERSISTENT);

PHP_PCRE_JIT_STACKLIMIT_ERRORという値を使って定義されていて、この定数はここで利用されている。

https://github.com/php/php-src/blob/1549c6d26ed866f0322ba740effc7820a54805c8/ext/pcre/php_pcre.c#L105-L109

#ifdef HAVE_PCRE_JIT_SUPPORT
  case PCRE_ERROR_JIT_STACKLIMIT:
    preg_code = PHP_PCRE_JIT_STACKLIMIT_ERROR;
    break;
#endif

エラーコードがPCRE_ERROR_JIT_STACKLIMITだったら代入していて、このエラーコードはたぶんこの辺りの処理。たぶん。

https://github.com/php/php-src/blob/1549c6d26ed866f0322ba740effc7820a54805c8/ext/pcre/pcrelib/pcre_jit_compile.c#L11249-L11250

/* Allocating stack, returns with PCRE_ERROR_JIT_STACKLIMIT if fails. */
/* This is a (really) rare case. */

「この例外はレアなケースさ(マジで)」というコメントから始まり

https://github.com/php/php-src/blob/1549c6d26ed866f0322ba740effc7820a54805c8/ext/pcre/pcrelib/pcre_jit_compile.c#L11271-L11272

/* We break the return address cache here, but this is a really rare case. */
OP1(SLJIT_MOV, SLJIT_RETURN_REG, 0, SLJIT_IMM, PCRE_ERROR_JIT_STACKLIMIT);

「でもコレはマジでレアなケースだから」というコメントに終わる。
ここの処理はC言語力が無くて良く分からなかった。雰囲気でC言語を読んでいる僕はここが限界。

コメントにある通り実装者はレアなケースだと想定したんだろうけど、ググってみるとそれなりにハマっている人はいるようだった。

PHP7.3でスタックサイズの確保が増えた

「動いていた正規表現が動かなくなった」というバグ報告により、スタックサイズが多めに確保されるようになりました。
(php-src側に変更が入った)

そのため、冒頭のstackoverflowのサンプルコードではエラーが再現しません。
結局サイズを超えたら同じ現象になりますが、7.2の頃よりは発生しにくくなったのだと思います。

fishとzsh - プロセス置換などコマンドの書き方の違いあれこれ

普段はfishを使っていて満足だけど、次のようなケースでbash/zshとの違いに戸惑うことがある。

  • Web上の記事からコピペで実行したい時
    • そういえばfishのコマンドがそのまま載った記事はほとんど見ない気がする
  • チーム開発をしていて、他の人がシェアしたコマンドをコピペ実行したい時
  • チーム開発をしていて、自分が使ったコマンドを他の人にシェアしたい時
    • そのまま載せたらbash/zsh環境では動かない時

ほとんどの場合エラーメッセージに答えがあるけど一応メモしておく。
以下のコマンド例は注釈がなければfishを指す。

変数定義

zshではAAA=111と書けるが、fishではsetを使う。

AAA=111
Unsupported use of '='. In fish, please use 'set AAA 111'.

⟩ set AAA 111echo $AAA
111

参考: set:シェル変数の設定・一覧・消去・確認する6活用

RAILS_ENV=test rails consoleみたいな書き方

コマンド実行時だけ変数定義をしたい時、zshと同じような指定はエラーになる。

RAILS_ENV=test ./bin/rails c
Unsupported use of '='. To run './bin/rails' with a modified environment, please use 'env RAILS_ENV=test ./bin/rails…'

エラーメッセージに書いてある通りenvを付ける。

⟩ env RAILS_ENV=test ./bin/rails c
Running via Spring preloader in process 59475
Loading test environment (Rails 5.0.0.1)

&&や||の書き方

fishとzshの違いで良く見るケース個人的1位。
fishでは&&||をサポートしていない。代わりに;and/orを使う。

echo 1 && echo 2
Unsupported use of '&&'. In fish, please use 'COMMAND; and COMMAND'.
fish: echo 1 && echo 2echo 1 ;and echo 2
1
2

# orの例
⟩ cat aa ;or echo 2
cat: aa: No such file or directory
2

ちなみにandを忘れて;だけにすると、コマンドが失敗しても継続されるので注意。

# andがないと継続される
⟩ cat a ; echo 2
cat: a: No such file or directory
2

# andがあれば継続されない
⟩ cat a ;and echo 2
cat: a: No such file or directory

終了ステータスのとり方

$?ではなくて$statusを使う。

echo $?
$? is not the exit status. In fish, please use $status.
fish: echo $?
            ^

⟩ echo $status
0

$(...) - コマンド置換

コマンドの実行結果を他のコマンドに渡す時の書き方。

⟩ cat $(echo out.txt)
$(...) is not supported. In fish, please use '(echo)'.
fish: cat $(echo out.txt)
          ^

⟩ cat (echo out.txt)
this is out

なおfishではバッククォートをサポートしていない。

⟩ cat `echo out.txt`
cat: `echo: No such file or directory
cat: out.txt`: No such file or directory

<(...) - プロセス置換

例えばディレクトリ内に含まれるファイル一覧の差分を取りたいとき。

/Users/kanno/tmp% tree dir1 dir2
dir1
└── a.txt
dir2
└── b.txt

zshでは<()を使うことで一時ファイルを通すことなく比較できる。
参考: 一時ファイルはもういらない - プロセス置換

# これはzsh
/Users/kanno/tmp% diff -u <(ls dir1) <(ls dir2)
--- /dev/fd/11  2018-12-15 05:17:56.000000000 +0900
+++ /dev/fd/12  2018-12-15 05:17:56.000000000 +0900
@@ -1 +1 @@
-a.txt
+b.txt

普段使わないけど、前職でサーバーに入ってサクッと使っている人がいてカッコいいと思った。

同じことをfishでやるにはpsubを組み合わせる。

⟩ diff -u (ls dir1 | psub) (ls dir2 | psub)
--- /var/folders/n5/z3tch4l11q796wz2m5m_6xd80000gn/T//.psub.eIiVpGBx3M  2018-12-15 05:20:18.000000000 +0900
+++ /var/folders/n5/z3tch4l11q796wz2m5m_6xd80000gn/T//.psub.QhISb6ERoH  2018-12-15 05:20:18.000000000 +0900
@@ -1 +1 @@
-a.txt
+b.txt

参考: psub:fish独自のプロセス置換を行う

リダイレクトの書き方

標準エラー出力^を使う。追記する場合は^^

⟩ cat aaa ^out.txt

⟩ cat out.txt
cat: aaa: No such file or directory

存在しないファイルを上書きしないようにするには>?^?を使う。

echo aa >? out.txt
The file 'out.txt' already exists

標準出力と標準エラー出力を同時に出すにはこう書く。

echo aa > out.txt ^&1

絶対忘れるが、bash/zsh2>&1も毎回ググっているので問題ない^^

ワイルドカードの解釈

**/*.phpのような指定をすると、zshと違いカレントディレクトリは対象外になる。
カレントディレクトリも対象とするには***.phpとする。

ls **/*.php
php/empty.php

⟩ ls ***.php
algo.php
php/empty.php

iTerm2からTerminal.appに戻ろうとして断念した

最近Vimとターミナル環境をリニューアルしている。
これまでずっとiTerm2を使っていたけど特に理由はなかった。最初にMac環境を色々構築した時に「iTerm2がナウいよ」って記事を読んで、良く分からないまま適当に入れただけだった。
標準のTerminal.appに出来なくてiTerm2じゃないと出来ないこと(使う理由)も良く分かっていない。

「背景画像を設定できる」のと「縦分割ができる」というのを良く見かけるが、僕は背景画像を設定しないし、縦分割はtmuxで出来るので必要ない。

そんなわけでiTerm2をやめてTerminal.appに戻ろうとしたんだけど、日本語を入力した時の挙動が気になってしまった。

f:id:kanno_kanno:20181213035929g:plain

入力中の日本語に応じて右にずれていく。撮り忘れたけど確定すると元の表示位置に戻る。
ちなみにGifでは縦分割だが、1ペインで入力中も時刻など右側に表示しているものがずれていく。
iTerm2だと再現しない。

調査する時間は持てず、結局iTerm2に戻ってきた。

さよならVim

これはVim大好きだったプログラマVim情報を追わなくなり、メインで使わなくなって、また帰ってくるまでの5年間の記録です。

Vimの情報を追わなくなった

それまでVimの情報を追い続けて、面白そうなプラグインは試して、自分でVimプラグインを作っていた僕がその手を止め始めたのは、転職をした2014年だと思う。
友人に誘われてフリークアウトに転職した時の僕は、フリークアウトが扱っているアドテクもPerlも一切知らなかった。

さらにフリークアウトのエンジニアはみんなレベルが高くて、入社してすぐに僕は自分が下から数えた方が早い技術力だと自覚した。
新卒のレベルですら非常に高いことに驚いた。その前の会社では僕を含め、新人といえばプログラミング未経験だったので衝撃は大きかった。
しかも新卒の人たちは広告に興味があって入ってきているわけで、業務知識は彼らの方が遥かにあった。現状に甘えたらすぐに追い抜かれる焦りを感じた。

エディタは自由なので当然Vimを使ったが、Vimをいじりたおす余裕はなかった。
入社してしばらくは自分が望む進捗を出せずに悶々としていた気がする。

唯一の活動としてprevimは使ってくださるユーザーさんがいたので、出来る範囲でサポートを続けてきた。

1年が経つ頃には業務にも慣れてきたけど、同時に「Vimの情報を追わない」ことにも慣れてしまっていた。
入社した段階で「自分のVim環境」は出来上がっていたので、無理に追う必要もなかった。それよりは仕事を進めたり、他の技術的な興味を追っかけたりする方が楽しくなっていた。
広告分野にしろ技術的なことにしろフリークアウトで学べることはたくさんあって、そちらを優先する方が大事に思えた。


フリークアウトに入って2年が経ち、2016年に僕はフリーランスになった。

請け負って一人で開発したのも含めるとRuby, Python, PHPなど色々な環境を経験できた。
直近の仕事を除き全てVimで開発してきた。Vimがあれば十分だった。

でも.vimrcの設定の多くは2014年の状態で止まったままで、少しだけフリークアウト時に修正したものが残っている程度。
プラグインの更新もほとんどしていない。更新して動かなくなることが嫌だから。
僕の中でVimが単なるツールに成り下がっていた。

Vimは楽しい」という感覚はこの頃に失っていたと思う。これまでの習慣で身についた「Vimの操作性」から離れられず、惰性で使っていたに過ぎない。

Vimをメインで使わなくなった

フリーランスになって少しして、僕は自分のサービスを作ることに目覚めた。
僕は本が好きで、本屋が好きだから、今の出版不況を何とかしようと燃えていた。初めて生き甲斐を見つけたと興奮した。
(リトルスタッフ開発者の自己紹介)

自分のサービスを開発するのもVimを使っていた。当時はまだ。
サービス開発に熱中していたし出版業界を知るので精一杯だったから、Vimに限らず技術的なキャッチアップもほとんどしなくなった。開発以外に企画やら営業やら新しい作業は山積みにある。使える時間は限られている以上、何かを切り捨てないといけなかった。

一方で生活費が必要な僕はフリーランスの仕事も続けた。友人に誘ってもらって2017年12月からPHPの現場で働き始めた。

そこでPCを支給してもらい開発環境のセットアップを始めた僕は、もちろんVim環境を構築した。
ただここで問題が起きた。まともに開発するには「何か挙動がおかしい」感じだった。
すごく漠然とした書き方になっているのは、ほとんど調査をしなかったからだ。

.vimrcを読み込まずに素のまま立ち上げたら問題なかったので、.vimrcの設定か、プラグインがおかしいことは明らかだった。
そこまで分かったとき僕はVimにイラッとした。(正確にはVimそのものじゃなくて自分の設定やプラグイン側の話だが)
面倒くさいと強く思った。

当時「Vimはツール」という感覚まで冷めていた僕は、「こんなことでハマるくらいならIDEを使う」と気持ちを切り替える。
PhpStormに馴染むには時間がかかったけど、慣れてしまえばすごく便利だった。
(Vimキーバインド以外で開発など無理な体になっているので、もちろんIdeaVimは入れた)

Vimとの比較でいえば「定義ジャンプ」と「ブレークポイントによるデバッグ」がダントツで便利だった。
以前の僕なら「それVimでも出来るよ」とか「別にそこまで必要じゃない」と思っていたけど、体験すると価値観が変わった。
Java + Eclipseで長いこと開発してきた経験もあるからそれらの便利さはもちろん知っていたけど、長らく使っていなかったせいか、こんなに効率違ったかと反省した。今までだったら関数定義をgrepしてファイルを開いていた操作が、PhpStormならショートカットキー1発で正確に飛べる。
特にこの現場での作業は調査寄りのことが多かったので、定義ジャンプとデバッガの有無は大きく影響した。
(それまでの現場のように追加開発がメインだったら、感想は違ったかもしれない)

そして自分のサービス開発もVimからPhpStormに切り替えた。

Vimへの熱がなくなり、自分のサービスに専念したい僕はprevimも有志のメンテナさんへ引き継ぎをお願いした。
快く引き受けてくださる方が数名いて、非常に助けられました。ありがとうございます。
(previmを他の方にお譲りしたい)

僕は開発において、完全にVimを使わなくなった。

それでもVimに帰ってきた

メインでVimを使わなくなって約1年が過ぎたが、最近「Vimをまたいじろうかな」という気持ちが芽生えていた。
きっかけは分からない。技術的なキャッチアップから遠のいている焦りかもしれないし、今の生活へのマンネリかもしれない。

技術的なキャッチアップをしなくなってから、つまりここ数年はTwitterのメインTLを見ていない。
それがここ最近は少しずつリハビリした方がいいかもと思い始めていて、たまにTwitterを開くようになっている。
そんな中、たまたま開いたらVimConf 2018のリアルタイム実況が流れてきた。めちゃくちゃ楽しそうだった。

TLを追いながら、そうだVimは楽しいんだった、ということを思い出していた。
最初にVimへ興味を持ったのは「Vimを覚えたら効率が上がるかもしれない」というツール的側面だったけど、学ぶうちに「Vimを使うこと」や「Vimを使っている自分」が楽しくて、その体験が目的になっていた。それが大事だった。

Vimが楽しいからプログラミングにも意欲的になれる。プログラマとしての自分を楽しめる。僕はそういうタイプのはずだった。

PhpStormの便利さは変わらないから引き続き使い続けるかもしれない。ライセンス更新のタイミングでどうするかはまだ決めていない。

今はただ、Vimの楽しさを思い出そう。僕はVimの情報を追い始めている。