単体テストのないソフトウェアまたは単体テストを書く習慣のないチームに単体テストを導入する方法

単体テストの習慣がない場所で必ず聞く言葉がある。

単体テストを書けば不具合はなくなるの?」

答えはNOである。

単体テストを書けば工数が減るの?」

厳密に言えば、答えはNOである。


単体テストは変更・追加開発した場合のデグレーションを減らすことはできるが、変更・追加箇所に不具合がないことを保証する方法論ではない。もちろんTDDで開発して、単体テストを予めかいておけば、いくらか不具合を減少させることはできるし、検証テストでは作るのが難しいテスト条件でのテストを行うこともできるので、まったく完全なNOではない。検証テストで発見される不具合が少なければ、不具合修正と再テストの時間を減らすことができるので、工数が減る場合もある。


でも、単体テスト銀の弾丸ではない。単体テストができるのは、テストケースで定義した状態・状況において不具合が発生すると教えることだけである。


ということを頭においた上で、テストを導入する方法を考えていかなければならない。



テストを導入するにあたって考えるべきは下記の5点。

  1. 単体テスト分の工数増加をどのようにプロジェクトマネージャやプロダクトマネージャに認めさせるか
  2. 自動化テストの環境をどうやって構築するか
  3. 自動化テストの範囲をどこまでと定めるべきか
  4. テストを書きたがらないエンジニアにどうやって書かせるか
  5. 膨大な既に書かれているテストをどうやってマネージメントするか

1. 単体テスト分の工数増加をどのようにプロジェクトマネージャやプロダクトマネージャに認めさせるか

単体テストに関係する記事や本などを読んでいると必ず出てくる問題だが、僕はこれが一番簡単な障害だと思っている。


まずひっそりと一人ではじめ、その結果が出たらマネージャに見せる。
これだけだ。


立ち上げたばかりの会社で比較すべきプロジェクトが全くない状態であればこの手法は効かないが、多分そういう会社は少ないだろう。僕の会社はプロジェクト規模大小様々あるので、必ずしも比較できるわけではないが、それでも一人でひっそりと始めることはいつからでもできる。一人で出来る範囲であれば、できることも限られている。大きいプロジェクトであれば、テストコードのない部分とある部分が必ずでき、そしてその部分での検証テスト結果が出るはずだ。
テストコードのある部分にだけきわめて検証テストの不具合発生率が低ければ成功である。
では失敗した場合は?

失敗の原因は幾つもあると思う。テストの経験がなかった、自分のスキルが低かった、テスト導入に時間をとられてコードの質が下がった(本当はそれはよくないので、そんなになるくらいなら寧ろテストは書かないほうが良い)などなどあるが、それはいくらでもあとから挽回できる。一回目ダメだったら二回目を狙おう。せっかく書いたコードはレポジトリにサブミットしておけばよい。本番では使われないコードなので多少の質は悪くても問題ない。大事なのは続けることである。


成功した場合で注意すべきは、現状の工数で問題なかったから、という理由で次回以降でも単体テスト分の工数を上乗せすることを許してもらえない場合だ。これはかなり根深い問題で、もし検証テストが思ったよりも早く終わったなら、その分を実装の工数にいれてくれというしかない場合もある。最悪の場合、検証が早く終わるならその分スケジュールも詰められるだろうと猶予分を削られる場合だ。これはもう本当に根強くこんこんと説得をし続けるほかない。上司が物分かりがよいか、新しいもの好き、もしくは周りに流されるタイプであることを願うほかない。そうでないのなら、仕方ないのでひっそりと頑張るしかないだろう。いずれにせよ、冒頭で書いたとおり単体テストは工数を短縮するための銀の弾丸ではないので、必要なときに必要なところだけを書くという運用方法に自分で切り替えていくことも必要かもしれない。

2. 自動化テストの環境をどうやって構築するか

これは自分の環境に合わせてやってください、なんだが、一応。
最低限必要なのは、

たまに仕様書・設計書を見ずに間違ったテスト対象コードからテストを書いて、不具合がつかまえられないのでテストは意味が無いという人がいるが、テストを書くときに仕様書・設計書を確認して意識的にテストNGを出して欲しいと思う。

3. 自動化テストの範囲をどこまでと定めるべきか

プロジェクトによるので一概には言えないが、結合テストシステムテストの範囲を定めるように単体テストの範囲も定めるべきである。自動化テストは(あれば)UIテストも含むので、それにはシステムテストの範囲も含まれることがある。どこからは手動のテストでマネジメントをするのかきっちり定めないと、自動化テストの範囲も決まらない。

本来は単体テスト結合テストの前までの範囲を受け持つので、結合先はモックなどをつくって擬似データを発生させる必要がある。状態の変化が必要な場合はモックがかなり高度・大規模になる場合もあり、テストが非常に煩雑になるので、そこはシステムテストの責任を委譲するのも手である。個人的にはモックを作成しなければならないテストは、結合テストの範囲とするのが一番シンプルかなと思う(が、だからと言ってしないわけには行かないこともたくさんある)

範囲として考えられるのは小さい方から順に下記の通り

  1. モックを使用しない範囲で正常系だけを通す(プロジェクトが小さい・不具合発生時のリスクが小さい場合)
  2. モックを使用しない範囲でデシジョンカバレッジを100%にする
  3. モックを使用しない範囲でコンディションカバレッジを100%にする
  4. モックを使用して正常系を通す(結合時の不具合発生が予想され、かつ不具合の特定が難しいと判断出来る場合もしくは結合時の不具合発生をできるだけ抑えたい場合)
  5. モックを使用してデシジョンカバレッジを100%にする(状態の変化をデータで持っておく必要があるため、テストデータが爆発的に増える)
  6. モックを使用してコンディションカバレッジを100%にする
  7. モックを使用したテストが出来るように全体的にコードの設計を見直し、ステートメントカバレッジを100%にする(非現実的)

個人的には2までにするか、4までにするか5までにするか、それ以上を頑張るかの判断を最初ですべきだと思う。
僕の扱うプロジェクトはソフトウェアだけで完結するものではなく、自分では手を加えることのできない他社製品を制御するのがほぼ必須である。この場合は不具合発生時のリスクを見積もった後、2か4かの選択をする。大抵の場合モックが必要な箇所は4, 必要ない場所は3で作る事が多いかな。枯れたシステムを制御する場合は2でもほぼ問題がない。制御するシステムが増えてくると4または5でないと、問題切り分けができなくなる。
また、くわえて上位の自分では手を加えられない大規模なシステムと結合しなければならない場合もある。この場合は結合テスト時の不具合発生の問題切り分けが非常に煩雑でリスクが高いので、4または5を目標にすることが多い。余力がある場合は6。一部システムテストで責務を負うべきところも単体テスト・自動化テストでやってしまうこともある。ここらへんは品質みつつという感じかなぁ…

4. テストを書きたがらないエンジニアにどうやって書かせるか

テストを書くのは意味が無いという人は必ずいるし、その信念はひとつの真理ではある。社会人として尊重した上で、それでも書いて欲しいとお願いするときにどうするかということを考えていかなければならないこともある。社会人なので。

テストを導入する手順は次のようにするとわりとスムーズだ。

  1. まず一人である程度枠組みを作る(レポジトリへのサブミットと同時に単体テスト走らせ、その結果を通知または閲覧できるようにする)
  2. コードレビュー時にかならず単体テストコードもレビュー対象に含める
  3. 新しいもの好きな人を巻き込み、複数人でプロジェクトにテストを含める作業をする
  4. いろいろ問題が出てくると思うので、テストコードを改善する
  5. テストをかきたがらないエンジニアのコードレビュー時にテストは?と必ず聞く
  6. 検証テストの不具合発生時、不具合発生理由を聞いた際にまずテストを書いてそれが本当かどうかエビデンスを出してくれと頼み、テストの書き方を教える。書けるまで徹底的に付き合う。その場合の文句とかはちゃんときき、一緒に解消方法を考える
  7. 必要ならテストをフレームワーク化する
  8. 不要な部分までテストが必要!という人がいたら、こういう目的で今回は単体テストを行っているので、あればよいですが無理しなくてよいですと伝える
  9. プロジェクトの変更・追加の前に単体テストの責務の範囲についてチームで話し合う。また検証テストと単体テスト・自動化テストの被る部分についてコンセンサスをとっておく
  10. 2, 4, 5, 6, 8, 9を繰り返す

スキルのないエンジニア(俺のことだ)の場合要求分析の練習にもなるので、6とかはすごく大事だとおもう。9ができるようになればほぼ定着していると思うので、そのまま続ける。新しいもの好きな人はあきるのも早い場合が多いので、ある程度テストコードが増えてきたら、テストを書きたがらないエンジニアを巻き込むことを考え始めたほうが良い。

5. 膨大な既に書かれているテストをどうやってマネジメントするか

TDDなどでは殆ど触れられないが、テストコードのマネジメントは非常に難しい。プロジェクトの規模が増えればもちろんテストコードは増えるし、結合先の状態が変わるテストをしようと思ったら、テストデータも増える。

テストコードにはコメントを書く

そもそもテストコードは読みにくい。僕はコメントはできるだけ書かない派(どうしても必要な箇所にだけ書くべきだが、それ以外は書くべきではない派)だが、テストコードにはコメントを入れておいたほうが良いかもしれない。テストケース名も、どれだけ長くなっても見るだけで何のテストかわかるほうが良い。テスト失敗したっていうメールが飛んできたけど何のテストでコケたのかパッと見分からないというのは結構不幸だ。

また、テストコードはできるだけテンプレート化しておいたほうが良い。これは複数人で同じテストコードを書く場合・スキルの低いエンジニアがテストを書く場合もあるためだ。むしろテストコードはスキルの低いエンジニアこそ書くべきなので(その人が書く場所は不具合が発生しやすくなるため)、テンプレートとガイドラインは必須だと思っておいたほうが良いのかもしれない。


テストコードをオブジェクト指向にする

状態を持つようにしたり、特定のテスト条件のデータを返す必要がある場合、データはデータ用コンポーネントに全て持っておいたほうが良い(DBのことではない)。またテスト条件を作成するコンポーネント、テストを実行するコンポーネント、モック作成のコンポーネントなど、オブジェクト指向にすればするほど、複数人での並行開発が楽になる。もちろん単体テストの範囲が狭い場合はそこまで頑張る必要はない。

寧ろテストは頑張らないくらいの勢いで書いていないと続かない

テストコードをレガシー化させない

テストが膨大になるに連れ、必ずレガシー化したテストがでてくる。だが単体テストの場合、そのテストを飛ばして実行するということができてしまう。これではテストの意味がない。
もしレガシー化している・なんのためにあるのかよくわからないテストが出てきたら、そのテストを実行し、なにをテストしているのかを確かめたあと、書き換えよう。時には捨ててしまうこともありかもしれない。
テストコードもソースコードの一部なのだ。テストコードだからといってレガシー化させてよいわけではない。死んだコードは不要なコードだ。おなじステートメントを通るテストケースが2つあり、そのふたつが別である意味がないのであれば、片方は削除すべきだ。別である意味があるのなら、それはコメントに書くべきである(大抵の場合は意味ないが)。

テストコードはできるかぎり低コストで高パフォーマンスを実現したほうがよい。必ずしも上司やチーム内に認められるわけではないテストコードだからこそ、それは常に頭の片隅において置かなければならない。しかし開発時のどこからともなく来る不安や、不具合発生時のやけくそなテストコードは別段非難されるべきものではない。あとから適切に処理してやればよいのである。そんな時間はないだろうか? だが、処理をするのは、テストコードを書いた人でなくてもいい。時間がないなら、作る。作れないのなら、他の人に委譲する。一人で開発しているのでなければ、たぶんきっとできるはずだ。


http://www.hyuki.com/yukiwiki/wiki.cgi?FlawedTheoryBehindUnitTesting

テストを書く

http://t-wada.hatenablog.jp/entry/debugging-tests
和田さーん!

テスト駆動開発(TDD : Test Driven Development)は、プログラマが自分の不安を克服し、自分が書くコードに自信を持ちながら一歩一歩進んでいくための手法です。不具合の発生は、端的に言えばこれまでの「自信」を揺らがせる事態です。テスト駆動開発者は不具合にどう立ち向かうのでしょうか?

やはりテストを書いて立ち向かってゆくのです。


チーム内にテストを書く習慣を持ち込んで三年、最初のうちは工数が増えるだけだ(あるある)、テストを書いても不具合がでるじゃないか(あるある)、システムテストでカバーすればいい(あるある)などという抵抗があり、それでも僕は淡々と雨の日も、晴れの日も、雪の日も、朝も夜も深夜も、終電後のオフィスでも、GW中の人気のないオフィスでも、自動テストをかき、そのプラクティスの勉強会をし、Jenkinsを導入し、定着を図ってきた。


三年で、ずいぶんと書いてもらえるようになったと思う。


エンジニアは新しいものが好きだ。新しいものがあれば古いものはすぐに捨てて、そちらに飛びつきたがる。自動テストで毎日定期的にビルドを走らせているのに、静的解析ツールが出たと聞けばそれを試したがり、一度使えることがわかればそのまま忘れ去ってしまう。その横で、僕は単体テストを書き、やがてそれだけでは足りないので社内用単体テストフレームワークを作り、その使い方のドキュメントを書き、GUI自動テストを導入し、ひとまず上司を納得させるためにそれで長期試験と負荷試験を実施し、そしてそれをCIツールに組み込んでいった。今月の半ばにようやくその長い道のりは終わった。


それでも、不具合は発生する。
そのたびに誰かが口にする。ユニットテストだけではダメなんじゃないか。自動テストではだめなんじゃないか。
そうではないのだ。不具合が発生したのは、テストが書かれていない部分なのだ。テストは常に完全ではなく、また完全になることはない。しかし書き続けていけばやがて不具合が検出される。不具合が検出された場所は、もう二度と不具合の出ない場所だ。なぜなら、不具合が発見されれば、テストコードが書かれるからだ。
もちろん、そのコストとリターンが見合わないということは大いにあるだろう。どこまでを自動化テストの責務とするか、それはその時状況によって決めていくしかない。ステートメントカバレッジ100%にすることが常に正義なわけではない。プロダクトアウトの製品だけを作っているわけではないのだから、ここでは「最低限正常系のテストだけでも」という方針でテストを書くので構わない。1%でも、あるいはたった0.1%の進歩でしかないとしても、ないよりはマシである。それがテストを書くということなのだと思う。

http://anond.hatelabo.jp/20130325165709

去年一年振り回されたプロジェクト(まだ振り回されている)がこんなかんじだな。

  • 工期が決まっていない→なんとなく決まってはいるが、しかし長くなることはなく短くなる一方である
  • 企画書の完成日が決まっていない→うちが企画元ではないのであれだが、なんとリリースされても企画書が出て来なかった
  • 競合調査より先に自分たちだけで企画を考える→一応やってはいたぽい
  • ターゲット層の調査より先に自分たちだけで企画を考える→ターゲット層も使う機器についても調査せずに企画が先行
  • 議事録を取らない→まじでなかった
  • 議事録をとり始めても訂正や意見がない→誰も議事録を見ていない
  • 企画書のひな形を作っても曖昧な表現で駄目出しだけする→ひな形すら出て来なかったので、うちで勝手に書いた
  • 思いつくままに会議を進める→これだ
  • 業務進行の定石を無視して効率の悪い方法を採用する→これだ
  • リーダーがウェブに疎い→まさにこれである
  • メンバーに技術者がいない→技術者は一応いたが展開中だった
  • キラーコンテンツの準備より先に課金方法を取り上げる→これだwww
  • ブレスト中にダメ出しする→企画が上がってこないので勝手に書いたストーリーで(それもどうなんだ)要求分析したが確かに駄目出しが多かった(かと言って代案もない)
  • 事あるごとにその分野に疎いことをほのめかす→一部の人々が。いまだに言ってる
  • 素人だからできると豪語する→それはなかったな
  • 素人をターゲットにする→まぁこの分野玄人が少ないので…
  • 素人であるの指摘に対して口論で勝利しようとする→そういう人がいました
  • ユーザとしての疑似体験を却下→却下したわけではないが取り入れる時間がなかった
  • 問題の切り分けをしない→しないひとがいました。それより優先順位が付けられないことが一番の問題だと思う
  • 先人が利用してきたツールや手法を否定する→ウォーターフォール全否定、とかね 否定するなら新たにきっちり決めるかとおもいきやなし崩しで始まったりとか。それを指摘すると逆ギレするとか。別にウォーターフォール反対派ではないしかと言ってアジャイル慎重派でもないんだが、こう行こうという指針がないまま始めるのはどうなのかと思う。だが、だいたいアンチ・ウォーターフォール派はそれをきかないんだよなぁ。なにか決めたらアジャイルじゃなくなると恐れてるみたいで。
  • 一度選定したテーマを途中で覆す→企画が途中でひっくり返ったりとかね
  • 高すぎる目標を設定する→無理だと言っているのに即販ツール作ってとかね
  • 高すぎる目標に対して全部企画に盛り込もうとする→無理だと言ってるのにね。でも企画が一番強いので逆らえない→なし崩し→現場が阿鼻叫喚というのが何度も繰り返された
  • 小さくリリースするの発想を理解しない→最初に理解してもらえなかったが、最近は理解してくれている
  • 一ヶ月以内に市場調査及びユーザの調査をしない→それは一応してたかな
  • 一ヶ月経過してもサービスの制作に入る見込みが立たない→要求は決まってなかったけど最低限絶対に必要なところは開始するなどした
  • 一ヶ月経過してもチームは素人のまま→一部はちゃんと玄人化したがいつまでも素人、もいる
  • 素人の作るサービルは成功の確率が低いことを理解しない→それはなかったな

ギリギリ許容される範囲内での失敗+すこしずつ啓蒙して良い方向へ向かおうとしている+どんな時でも最低限テストは書き、できるテストは自動化し、ツールを導入して繰り返しの作業は消していくという地道な作業で現在も何とか動いているし、これからは改善するだろうと思っている。ぼくはただ淡々と自動化して悪いところはやめて煩雑にならないようにきをつけて(おれがめんどい)、だれにでも使えるようにしてる(呼び出されるのめんどい)けれども、やはりアンチ・ウォーターフォール派の抵抗が根強くてそれをひっくり返される。アンチ・ウォータフォールだからといってアジャイルなのかというとそういうこともないのが困る。ただとにかく書類を書きたくない、責任を取りたくないという信念のもとに行動する人とどうやってチームメンバとして付き合っていけばよいのか、これからも考えなければならない。

今春からようやく会社でレガシーコード改善ガイドを使って勉強会をするようになった(やっと…!)のでちょっと古いエントリを再掲しておく。

「テストを書けばよいのでは?」の一言がためらわれる時もある

整然とし、秩序だった美しい世界を構築する人がいた。設計もコードも、リファクタリングする指針でさえも一貫したポリシーを持って行われていることが、経験のない僕にもよくわかった。たしかに彼が手を入れた部分は美しかった。でもその周りには広大な廃墟とスラムが存在していたのだ。古い、改修しながら使っているライブラリはすでに原型をとどめていない。彼はすこしずつ手直しをしてすこしずつ改善していたけれども、ライブラリには手をつける暇もなく去っていった。広大なスラムだけが残った*1

しばらくそこを離れていた僕がもどってきたとき、最初の仕事はコードレビューだった。かの整然とした世界を構築できる人の代わりにしては僕はあまりにも物事を知らなさすぎるが、それでも何百分の一かの貢献は期待されている。
似たようなコードをそのままコピーして新しいメソッドにしている部分を目にして僕は、同じ処理を抽出してメソッド化したらよいのではないかというレビューをしたが、担当者はスラム化したライブラリがうまく動かなくなるのを怖がってその指摘を受け入れなかった。ライブラリにはもちろんテストがない。クラス関係も複雑で、設計書も仕様書もない。そのコードは関連会社から譲り受けたものだから構築した人もいない。そして、設計ができずスキルの低い僕は自信がなかった。それでもやるべきだとは言えなかった。

レガシーコード改善ガイド (Object Oriented SELECTION)

レガシーコード改善ガイド (Object Oriented SELECTION)

「レガシーコード改善ガイド」を読み始めた。

序文からしてすでに涙目である。
自分のコードがひどいのは前から分かっているし、なにもひどさに涙しているわけではない。

僕はコードが書けない。知識はあっても、簡単なプログラミングができても、僕はコードを書けないと思っている。自信がないのだ。自分が作るものが本当に意図したとおりに動くのか、わからない。作れば作るほど発散し、自分でも構造がつかめなくなっていくことを知っているから怖いのだ。誰かが作ったコードを正確に読み取り、その動作がなんの機能に当たるのか、わかっているつもりになっている気がする。そして僕は変更しない方向を選んでしまう。「できるだけ変更せずにとっておいて新たに書き加える」。その繰り返しが膨大な二度と使わないメソッドとクラスのゴミの山を積み上げることになると知っているのに。

きれいなコードは有益ですが、それだけでは不十分です。テストなしに大規模な変更をしようとすると、チームは危険な賭けに出ることになります。転落防止の網を張らずに空中ブランコをするようなものです。とてつもないスキルが必要であり、ステップごとに何が起きる可能性があるかをきちんと把握しておかなければなりません。
(中略)
ここまで、テストについてかなりたくさん書いてきましたが、本書はテストについて解説している本ではありません。どんなコードでも自信を持って変更できるようにするための本です。本分の各章では、コードを理解し、テストで保護し、リファクタリングし、機能を追加するための手法について説明しています。

はじめ、単体テストを書くのは嫌いだった。面倒でしょうがなかった。でもモックオブジェクトに出会って以来、僕は単体テスト職人になりたいとすら思うようになっている。モックオブジェクトで依存関係を切り、仕様書をにらみ、設計書と照らし合わせながら一つずつテストケースを組み立て、そのテストはどうやってすべきか頭をひねる作業は単純に楽しいというのもある。でもそれよりも効率よくそのメソッドが、クラスが、何を意図しているのか、そしてどういう振る舞いをするのか頭の中に自信を持って組み立てていくことができるということの方が大きい。自分の行った変更が問題なかった、ほかの部分を壊していないと自信を持って言えることの方が大きい。
向かう対象が大きく複雑で汚いほどその作業は楽しいことを僕は知っている。そうやって少しずつ設計とは何かを理解できるようになる。仕様として出てくる機能の背景が気になるようになる。

きっとこれから10年、幸運にも開発者でいられたとしてもきっと僕は整然として矛盾も無駄もない世界を構築することはできないだろう。でも、その世界を理解するための道具はある。始めから終りまで迷うことなく出力することはできなかったとしても、地道にゆっくりと這い寄ることはできる。大きな手術を施すことになったとしても、正してゆくことはできる。たとえスキルが高くなくても、美しい世界を保守することはできるのだ。

*1:廃墟は破棄された。

Seleniumでテストしたページのキャプチャを撮る+画像を全部保存する

http://d.hatena.ne.jp/wonodas+dev/20121129/1354163496 の続き

前回のでもできるはできるのだが、今回の場合

  1. セッションが変わるとログインページに戻ってしまう
  2. imgのsrcに入っているタグが*.jpgなどの形式ではなくURL

file_get_contentsは新しくセッション作ってページの内容を取得するらしく、やれどもやれどもログイン画面のソースコードしか手に入らなかった。んで、しょうがないのでjavascriptを駆使することに…
あとグラフを描画させているんだが、サーバでグラフ画像作って貼り付ける形式ではなくクライアントサイドで描画させる方式のため、img src="URL"のURLで(jpg|png|gif)が含まれないことが判明(先にわかっとけ)。これを取得する方法はなさげなのでとりあえずURL表示させてキャプチャという強引な方法を取ることにしました。

SeleniumではgetEvalまたはrunScriptでjavascriptコードを走らせることができます。

VerifyTestCase.phpに追加

<?php
class PHPUnit_Extensions_VerifyTestCase extends PHPUnit_Extensions_SeleniumTestCase
{
     //中略
    //指定したURLを開いてスクリーンショットを取る(クライアントサイドで生成した画像)
    public function captureImageFromUrl($url, $dir = null, $fileName = null)
    {
        if($dir == null ){
            $dir = __DIR__;
        }
        
        if($fileName == null){
            $fileName = date("Ymd_His");
        }
        
        $this->open($url);
   //余計なのをうつさないために(twitterとかね)最大化しておく
        $this->windowMaximize();
        $this->captureScreenshotAndWait($dir . "/image" . $fileName . ".png");
    }
    
    //現在のページのスクリーンショットを撮る
    public function captureCurrentPage($dir = null , $fileName = null){
        if($dir == null ){
            $dir = __DIR__;
        }
        
        if($fileName == null){
            $fileName = date("Ymd_His");
        }
   //余計なのをうつさないために最大化しておく
        $this->windowMaximize();
        //file名を指定していない場合はimage-Ymd_Hisというファイル名で保存される
        $this->captureScreenshotAndWait($dir. "/image-". $fileName. ".png");
    }

    //URL指定したページのイメージを取得する(jpg, gif, pngだけ)
    public function saveImageFromUrl($url, $dir = null)
    {
        if($dir == null) {
            $dir = __DIR__;
        }
        
        if (preg_match("/(jpg|gif|png)$/i", $url)) {
            //画像のファイル名で保存する
            file_put_contents($dir . "/" . basename($url), file_get_contents($url));
        }
    }

    //ページ内の画像をすべて保存する
    public function saveImagesOnPage($url, $dir = null)
    {
        $this->open($url);
        //今表示しているページのdocumentを取得
        $this->getEval("doc=this.page().getCurrentWindow().document;");
       //imgタグを全部取得
        $this->getEval("imageArr = Array.slice(doc.getElementsByTagName('img'));");
        //imgタグがいくつあるかをimgCountに格納
        $imgCount = $this->getEval("imageArr.length;");

        //なかったら終わる
        if ($imgCount == 0) {
            return;
        }

        $dir = $dir === null ? __DIR__ : $dir;
        $dirPath = $dir . "/" . date("Ymd_His");
        mkdir($dirPath);

        $imgUrlArr = array();
        for ($i = 0; $i < $imgCount; $i++) {
            //imgのsrcを取得して配列に詰める
     //なぜかgetEval("i=0");getEval("iamgeArr[i].src");getEval("i = i+1");だと最初しか取れないのでこうした
            $imgUrlArr[] = $this->getEval("imageArr[" . $i . "].src;");
        }

        //重複はのぞく
        $imgUrlArr = array_unique($imgUrlArr);

        foreach ($imgUrlArr as $index => $url) {
            //jpg,gif,pngだったら画像を保存、そうじゃなかったらキャプチャ(二重チェックになってるなこれ…)
            if (preg_match("/(jpg|gif|png)$/i", $url)) {
                $this->saveImageFromUrl($url, $dirPath);
            } else {
                $this->captureImageFromUrl($url, $dirPath, $index);
            }
        }
    }
}

Seleniumで画像を取得する(キャプチャを使わない)

captureEntirePageScreenshotがfirefox以外だとうまく動かないが、firefoxnetbeansからだとなぜか実行できないので自前でHTML内にある全ての画像を落とすようにした。どうせエビデンス取らなきゃいけないしページが長い場合はスクロールしなきゃいけないけどようわからんので画像ファイルで落とせればいいや、というかんじ。

HTMLファイル

<html>
<body>
    <img id="smp" src="./sample.png">
</body>
</html>

ひとまず画像だけが置いてあるHTML

ストファイ

なお、phpunit-seleniumを使用しております。本心としてはPHPUnit_Extensions_SeleniumTestCaseクラスを継承したPHPUnit_Extensions_VerifyTestCaseを作成して、そこにsaveImageメソッドを作成してどこでも使えるようにする。
テストケースはやっぱりテストケースで分類すべきだよなとちょっと思ったので。ホントはちゃんと名前空間柄使ったほうがいいんだろうけどめんどいので省略。

SaveImageTest.php
<?php
require_once __DIR__. '/PHPUnit_Extensions_VerifyTestCase';

class indexTest extends PHPUnit_Extensions_VerifyTestCase {
    function setUp() {
        $this->setBrowser("*googlechrome C:\Program Files\Google\Chrome\Application\chrome.exe");
        $this->setBrowserUrl("http://localhost/index.php");
    }

    function testGetImage() {
        $this->open("http://localhost/index.php");
        //assertは一個くらいいれておく
        $this->assertElementPresent("id=smp");
        //指定したページの画像ファイルだけを保存する。第一引数がURL, 第二引数は保存するディレクトリパス
        $this->saveImage("http://localhost/index.php", __DIR__);
        $this->close();
    }
}
PHPUnit_Extensions_VerifyTestCase.php

ついでなのでphpUnit-Seleniumに定義されていないverifyなんとかメソッドとかもここに定義してしまおうと思っている。verifyじゃなくてcustomとかにしたほうがいいのかね。まあいいか。

<?php

require_once 'PHPUnit/Extensions/SeleniumTestCase.php';

class PHPUnit_Extensions_VerifyTestCase extends PHPUnit_Extensions_SeleniumTestCase
{

    public function saveImage($url, $dir = __DIR__){
        //時間がかかる場合にタイムアウトしないように念のため0にセット
        set_time_limit(0);
        //<img ~>タグを検索
        preg_match_all('/(<img+(.*?)+>)/i',file_get_contents($url), $matches);
        //重複を削除する
        $getArr = array_unique($matches[0]);

        foreach($getArr as $tag) {
            //<img ~>タグの中身をとりあえず空白で分割する
            $imgTagArr = explode(" ", $tag);
           //空白で分割したものの中からsrc="~"というのを探す
            foreach($imgTagArr as $item) {
                if(strstr($item, "src") != false){
                    //srcが見つかったらファイル名を取得し抜ける
                    $path = explode("\"", $item);
                    break;
                }
            }
            //画像ファイル拡張子がついているかどうかを調べる
            if(preg_match("/(jpg|gif|png)$/i", $path[1])) {
                //ファイルを取得するURLを作成し直す
                $urlReplacePos = strrpos($url, "/");
                $urlFile = substr($url, 0, $urlReplacePos+1) . $path[1];
                //保存ディレクトリ名を生成し、作る
      $dirPath = $dir. "/". date("Ymd_His");
                mkdir($dirPath);
                //ファイルに書き込み
                file_put_contents($dirPath. "/".  basename($path[1]), file_get_contents($urlFile));

            }
        }
    }
}

こんな感じ。

参考:http://qri.seesaa.net/article/120453031.html