このエントリーをはてなブックマークに追加

普段使用しているGitクライアントのTigが便利なので紹介したいと思います。

What is Tig?

Tig is an ncurses-based text-mode interface for git.

https://github.com/jonas/tig に記載された冒頭の一文です。 この文だけ見ても何のことやら分かりませんが、要はgitのログをターミナルでグラフィカルに表示できるツールです。

インストールからTig起動まで

前提

この記事は以下の前提条件で書いています。

  • OS X 10.11 El Capitan
  • Homebrew
  • Tig 2.2.2

インストール

Homebrewからインストールできます。

$ brew install tig

起動

適当なフォルダを作ってTigを試してみましょう。 1コミットもない状態だとTigを起動できないため、ダミーのコミットを追加しておきます。

$ mkdir hoge
$ cd hoge
$ git init
$ git commit -m "initial commit" --allow-empty
$ tig

このような画面が表示されるはずです。

1

単なるログビューアでない

Tigでコミットできちゃいます

適当なファイルを追加してコミットしてみます。

$ touch main.js

Tigの画面で s のキーを押すとこのような画面になります。

2

u のキーを押してみてください。

3

Shift + c を押すとコミットメッセージの入力画面に変わります。

4

この画面は、gitコマンドでコミットする時と同じものです。 私はvimを使っています。

5

コミットが完了してdiffがなくなると、下の青いバーの表示が「Nothing to update」に変わりました。 q のキーを押すと最初の画面に戻ります。

6

s とか u とか何なんだよ

私がTigを使い始めた当初の感想を章のタイトルにしてみました。 Tigはターミナル上で動作するツールですので、GUIツールのようにメニューバーやマウスクリックによる操作は存在しません。

操作は全てキー入力を通してTigに伝わります。 s は「メインビューを表示する」 u は「ファイルをステージング」といった操作を行っています。

ビューを切り替えるためのキーバインド

Tigには多くの「ビュー」が存在します。 私がよく使うものをピックアップして紹介します。

ビュー 用途 ビューを開くためのキー 画面
メインビュー コミットの一覧 m main
ステージビュー ステージ/アンステージ s stage
ログビュー ログの一覧 l log
リファレンスビュー ブランチの一覧 r refs

それぞれのビューには割り当てられたキーで移動し、q のキーを押すと前のビューに戻ります。 一番最初に表示されたビュー(メインビュー)まで戻って q を押すとTigが終了します。

それぞれのビューで共通するキーバインド

以下は多くのビューで共通したキーバインドです。 viに慣れている方はなじみのあるキーバインドで覚えやすいのではないでしょうか。

  • k j で行移動
  • でコミット移動
  • q でビューを閉じる
  • / で検索
  • Enter で決定(詳細ペインを開いたりする)
  • Shift + r で更新
  • Ctrl + c でTigを終了

キーバインドとビューを覚えるのに難航する

私の場合、つまづきポイントはここでした。 ビューとその用途を覚えきれないうちに間違えたキーを押してしまい、目的の操作にたどり着くまでにビューの中で迷子になっていた思い出があります。 最初は慣れないのもあってTigを操作するよりgitコマンドをタイピングするほうがダントツに早かったです。

ただ、普段よく使うgitコマンドの数はそれほど多くなく、それぞれに対応するTigの操作もそれほど多くありませんでした。 Tigを使い始めたきっかけはログを見るのに便利そうだからという理由でしたが、使っていくうちにタイプ量が激減している事に気づきました。

Tigって便利です

ログを見るのが便利

メインビュー(m)で Enter を押してみてください。 選択中のコミットのdiffが表示されます。

続けて矢印キー を押すと、コミットを移動しながら個々のコミットのdiffを表示する事ができます。(左ペイン) k j pageup pagedown を押すとdiffの行を移動できます。(右ペイン)

8

ステージ/アンステージが便利

ステージビュー(s)に移動します。 k j でファイルを選択し u を押すと、ファイル単位でステージ/アンステージができます。

9

続けて Shift + c を押すとコミットメッセージを入力する画面に切り替わります。 間違えてこの画面に入ってしまった時は :q で抜けられます。

10

ファイルの一部だけステージ/アンステージが便利

例えば以下のようなファイルがあり、コミットされているとします。

// service.js

var a = 1;

var service = function () {
  //
};
var foo = [1,2];
var service1 = service(foo);


var b = {
  name: 'test',
};

複数の行に変更を加えてみます。

// service.js

- var a = 1;
+ var a = 111;

var service = function () {
  //
};
var foo = [1,2];
var service1 = service(foo);


var b = {
-  name: 'test',
+  name: 'test123',
};

Tigでステージビュー(s)を見てみましょう。 k j でファイルを選択して Enter キーを押すと右ペインにdiffが表示されます。

11

ファイルの中に複数箇所の変更があった場合、Tigはそれぞれをコミットの候補としてグループ分けします。(@@ から始まる行がグループの先頭です) このグループ単位でコミットを行う事ができます。

j のキーを押して1番目のグループの中に移動します。

12

u のキーを押すと1番目のグループのみステージングされました。 あとは Shift + c でコミットすればOKですね。

13

ブランチ移動が便利

メインビュー(m)からリファレンスビュー(r)に移動します。 k j でブランチを選択して Shift + c キーを押すとブランチをチェックアウトできます。

14

チェリーピックが便利

メインビュー(m)からリファレンスビュー(r)に移動します。 矢印キー でブランチを選んで Enter キーを押します。

15

j kでコミットを選んで(右ペイン) Shift + c を押します。

17

画面の下のほうに「cherry-pickを実行しますか?」とメッセージが表示されるので、y キーを押します。

18

gitコマンドを登録できるのが便利

Tigの設定は .tigrc というテキストファイルに記述します。 https://github.com/jonas/tig/blob/tig-2.2.2/tigrc の内容をコピーしてホームディレクトリの直下に配置してみましょう。

このファイルを編集するとTigの設定のカスタマイズが可能です。 私は以下のようなオリジナルのgitコマンドを登録しています。

bind main       @ri     !git rebase -i %(commit)~
bind main       @rc     !git rebase --continue
bind main       @ra     !git rebase --abort
bind main       @rs     !git rebase --skip
bind main       @fc     !git commit -m "[ci skip]" --allow-empty

見ると何となく分かると思いますが、メインビューでのキーバインドを設定しています。 @ri を打つと git rebase -i が実行されます。 キーの先頭に @ を付けているのはデフォルトのバインドと重複しないキーを探すのが面倒だからです。

リモートにブランチをプッシュする前にコミットログをざっと眺める習慣があるのですが、時々コミットメッセージのタイポに気づいたりする事があります。 その時にTigで以下のような操作をしています。

  • @ri で編集開始
  • タイポを修正
  • @ad で修正ファイルをステージング
  • @rc でリベースを続行

Tigの操作に必要なのは @ri@ad@rc たった9回のキー入力です。

おわりに

Tigはとても多機能で、ここで紹介したのはほんの一部です。 gitコマンドを忘れてしまうくらい便利なので、ぜひ一度試してみてはどうでしょうか。


このエントリーをはてなブックマークに追加

長らくブランクが空きましたが当エントリはcfn-initはUbuntuのどの起動システムにも対応しているのか?(其の一) の続きです。今回はservicesキーの調査であることがより明確であるエントリ名に変更いたしました。

前回のおさらいと今回の目的

CloudFormationには便利なツールcfn-initが用意されています。これを利用することでservicesキーに指定したサービスの停止などの制御を行うことができます。ドキュメント によるとこれは起動システムsysvinitを前提としたものとのこと。しかし、Ubuntu14でもUbuntu16でもcronデーモンを停止することができました。起動システムとして、Ubuntu14はUpstart、Ubuntu16はsystemdが採用されているにも関わらず、です。前回はその仕組みについては棚上げにしていましたので今回はそこを調査したいと思います。

調査方法

今回は Ubuntu14.04 および Ubuntu16.04 の各々において以下の流れで調査を行いたいと思います。

  1. EC2インスタンスを起動
  2. 自作のデーモンを作成&設定
  3. このインスタンスのAMIを作成
  4. 3.で作成したAMIを指定してCloudFormationでインスタンス作成。この際にサービスの停止を定義する。
  5. 検証

Ubuntu14.04

1. EC2インスタンスを起動

ami-db7311bd を起動します。

2. 自作のデーモンを作成&設定

起動サービスで実行されるデーモンスクリプトを作成します。

#!/usr/bin/env bash

filename=$1
while (true); do
  sleep 1
  date >> /opt/daemon_test/${filename}.log
done

実行結果は以下となります。

$ sudo ./hello.sh foo
$ cat /opt/daemon_test/foo.log
Tue Jan  9 10:25:59 UTC 2018
Tue Jan  9 10:26:00 UTC 2018
Tue Jan  9 10:26:01 UTC 2018
Tue Jan  9 10:26:02 UTC 2018

Upstart準拠の起動設定を行います。/etc/init/hello-upstart.conf として以下のようなファイルを設置します。

description "hello-upstart"

start on runlevel [2345]
stop on runlevel [016]

chdir /opt/daemon_test
exec ./hello.sh upstart
respawn

次にデーモンの設定を反映します。

$ sudo initctl reload-configuration
$ sudo initctl list | grep hello
hello-upstart stop/waiting

インスタンスを再起動してデーモンが起動していることを確認します。

$ sudo initctl list | grep hello
hello-upstart start/running, process 958

$ tail -f /opt/daemon_test/upstart.log
Wed Jan 10 06:29:12 UTC 2018
Wed Jan 10 06:29:13 UTC 2018
Wed Jan 10 06:29:14 UTC 2018
Wed Jan 10 06:29:15 UTC 2018
Wed Jan 10 06:29:16 UTC 2018

3. このインスタンスを基にAMIを作成

Webコンソールから行いました。

4. 3.で作成したAMIを基にインスタンスをCloudFormationで作成。この際にcronデーモンと自作デーモンを停止するように定義。

実行したところ、cronデーモンは停止しましたが、自作デーモンは停止しませんでした。原因を調査してみます。

5. 検証

/var/log/cfn-init.logを見ると/etc/init.d/hello-upstartが無いという理由で落ちているようです。

 :
2018-01-26 08:30:03,817 [DEBUG] Setting service hello-upstart to disabled
2018-01-26 08:30:03,824 [ERROR] update-rc.d failed with error 1. Output: update-rc.d: /etc/init.d/hello-upstart: file does not exist

2018-01-26 08:30:03,824 [ERROR] Error encountered during build of config: Could not disable service hello-upstart (return code 1)
 :
ToolError: Could not disable service hello-upstart (return code 1)

参考にcronデーモンを見てみます。
/etc/init/cron.confが存在しますし、以下の実行結果からもUpstartで起動管理されているように見えます。

$ initctl list | grep cron
cron start/running, process 1073

しかし、sysvinitスタイルの定義の置き場/etc/init.dにも設定は存在していました。というかシンボリックリンクが設置されているようですね。

$ ls -la /etc/init.d/cron
lrwxrwxrwx 1 root root 21 Feb  9  2013 /etc/init.d/cron -> /lib/init/upstart-job

/lib/init/upstart-jobのコメントにはSymlink target for initscripts that have been converted to Upstart.と記してあります。どうやらこれがキモのようですね。自作デーモンにおいてもこれを真似して設置してみました。

$ cd /etc/init.d
$ sudo ln -s /lib/init/upstart-job hello-upstart
$ ls -la hello-upstart
lrwxrwxrwx 1 root root 21 Jan 28 10:05 hello-upstart -> /lib/init/upstart-job

これを基にAMIを作成しました。
そして、このAMIを基にインスタンスをCloudFormationで作成。この際にcronデーモンと自作デーモンを停止するように定義しました。
結果、cronデーモンも自作デーモンも停止しました。

Ubuntu16.04

1. EC2インスタンスを起動

ami-a07012c6 を起動します。

2. 自作のデーモンを作成&設定

起動サービスで実行されるデーモンスクリプトを作成します。Ubuntu14.04 の場合と同じですね。

#!/usr/bin/env bash

filename=$1
while (true); do
  sleep 1
  date >> /opt/daemon_test/${filename}.log
done

systemd準拠の起動設定を行います。/etc/systemd/system/hello.service として以下のようなファイルを設置します。

[Unit]
Description = hello systemd

[Service]
ExecStart = /opt/daemon_test/hello.sh systemd
Restart = always
Type = simple

[Install]
WantedBy = multi-user.target

次にデーモンの設定反映します。

# 状態確認
$ systemctl list-unit-files --type=service | grep hello
hello.service                              disabled

# 有効化
$ sudo systemctl enable hello
Created symlink from /etc/systemd/system/multi-user.target.wants/hello.service to /etc/systemd/system/hello.service.
$ sudo systemctl status hello
● hello.service - hello systemd
   Loaded: loaded (/etc/systemd/system/hello.service; enabled; vendor preset: enabled)
   Active: inactive (dead)
$ systemctl list-unit-files --type=service | grep hello
hello.service                              enabled

# 起動
$ sudo systemctl start hello
$ sudo systemctl status hello
● hello.service - hello systemd
   Loaded: loaded (/etc/systemd/system/hello.service; enabled; vendor preset: enabled)
   Active: active (running) since Tue 2018-01-16 09:22:38 UTC; 3s ago
 Main PID: 6463 (bash)
    Tasks: 2
   Memory: 348.0K
      CPU: 8ms
   CGroup: /system.slice/hello.service
           ├─6463 bash /opt/daemon_test/hello.sh
           └─6475 sleep 1

Jan 16 09:22:38 ubuntu-xenial systemd[1]: Started hello systemd.

インスタンスを再起動してデーモンが起動していることを確認します。

$ sudo systemctl status hello
● hello.service - hello systemd
   Loaded: loaded (/etc/systemd/system/hello.service; enabled; vendor preset: enabled)
   Active: active (running) since Tue 2018-01-16 09:22:38 UTC; 3s ago
 Main PID: 6463 (bash)
    Tasks: 2
   Memory: 348.0K
      CPU: 8ms
   CGroup: /system.slice/hello.service
           ├─6463 bash /opt/daemon_test/hello.sh
           └─6475 sleep 1

Jan 16 09:22:38 ubuntu-xenial systemd[1]: Started hello systemd.

$ tail -f /opt/daemon_test/systemd.log
Wed Jan 10 06:31:12 UTC 2018
Wed Jan 10 06:31:13 UTC 2018
Wed Jan 10 06:31:14 UTC 2018
Wed Jan 10 06:31:15 UTC 2018
Wed Jan 10 06:31:16 UTC 2018

3. このインスタンスを基にAMIを作成

Webコンソールから行いました。

4. 3.で作成したAMIを基にインスタンスをCloudFormationで作成。この際にcronデーモンと自作デーモンを停止するように定義。

実行したところ、cronデーモンは停止しましたが、自作デーモンは停止しませんでした。原因を調査してみます。

5. 検証

/var/log/cfn-init.logを見るとcannot find a LSB script for helloという理由で落ちているようです。

2018-01-28 10:41:34,511 [DEBUG] Setting service hello to disabled
2018-01-28 10:41:34,520 [ERROR] update-rc.d failed with error 1. Output: update-rc.d: error: cannot find a LSB script for hello

2018-01-28 10:41:34,520 [ERROR] Error encountered during build of config: Could not disable service hello (return code 1)
Traceback (most recent call last):
  File "/usr/local/lib/python2.7/dist-packages/cfnbootstrap/construction.py", line 542, in run_config
    CloudFormationCarpenter(config, self._auth_config).build(worklog)
  File "/usr/local/lib/python2.7/dist-packages/cfnbootstrap/construction.py", line 270, in build
    CloudFormationCarpenter._serviceTools[manager]().apply(services, changes)
  File "/usr/local/lib/python2.7/dist-packages/cfnbootstrap/service_tools.py", line 155, in apply

参考にcronデーモンを見てみます。
以下の実行結果からもsystemdで起動管理されているように見えます。

$ sudo systemctl status cron
● cron.service - Regular background program processing daemon
   Loaded: loaded (/lib/systemd/system/cron.service; disabled; vendor preset: enabled)
   Active: inactive (dead)
     Docs: man:cron(8)

しかし、sysvinitスタイルの定義の置き場/etc/init.dにも設定は存在していました。ただ、Ubuntu14.04とは違ってシンボリックリンクではなさそうです。

$ ls -la /etc/init.d | grep cron
-rwxr-xr-x  1 root root 3049 Apr  5  2016 cron

内容を見るといわゆる「普通の」sysvinit用の設定ファイルのように見えます。
自作デーモンにおいてもこれを真似して設置してみました。

スケルトンを基にコピーして編集。実行権限を付与します。

$ sudo cp -iv /etc/init.d/skeleton /etc/init.d/hello-sysvinit
'/etc/init.d/skeleton' -> '/etc/init.d/hello-sysvinit'
$ sudo chmod +x hello-sysvinit

スケルトンとの差分はこんな感じ。

### BEGIN INIT INFO
-# Provides:          skeleton
+# Provides:          hello-sysvinit
# Required-Start:    $remote_fs $syslog
# Required-Stop:     $remote_fs $syslog
# Default-Start:     2 3 4 5

DESC=“Description of the service”
-DAEMON=/usr/sbin/daemonexecutablename
+NAME=hello-sysvinit
+DAEMON=/opt/daemon_test/hello.sh
+DAEMON_ARGS=“sysvinit”

sysvinitの起動設定と同様の作業をすることで

$ sudo update-rc.d hello-sysvinit defaults

以下のようにsystemdの起動設定が作られました。

$ ls -la /run/systemd/generator.late/hello-sysvinit.service
-rw-r--r-- 1 root root 592 Jan 29 15:45 /run/systemd/generator.late/hello-sysvinit.service

これを基にAMIを作成しました。
そして、このAMIを基にインスタンスをCloudFormationで作成。この際にcronデーモンと自作デーモンを停止するように定義しました。
結果、cronデーモンは無事に停止しましたが自作デーモンは停止しませんでした。状態は以下の通り。Activefailedとなっています。

$ systemctl status hello-sysvinit
● hello-sysvinit.service - LSB: Example initscript
  Loaded: loaded (/etc/init.d/hello-sysvinit; bad; vendor preset: enabled)
  Active: failed (Result: timeout) since Tue 2018-01-30 08:45:22 UTC; 4min 6s ago
    Docs: man:systemd-sysv-generator(8)
  CGroup: /system.slice/hello-sysvinit.service
          ├─ 1266 bash /opt/daemon_test/hello.sh sysvinit
          └─11182 sleep 1

Jan 30 08:40:22 ip-172-31-20-228 systemd[1]: Starting LSB: Example initscript...
Jan 30 08:45:22 ip-172-31-20-228 systemd[1]: hello-sysvinit.service: Start operation timed out. Terminating.
Jan 30 08:45:22 ip-172-31-20-228 systemd[1]: Failed to start LSB: Example initscript.
Jan 30 08:45:22 ip-172-31-20-228 systemd[1]: hello-sysvinit.service: Unit entered failed state.
Jan 30 08:45:22 ip-172-31-20-228 systemd[1]: hello-sysvinit.service: Failed with result ‘timeout’.

/var/log/cfn-init.logを見ると当該サービスを停止する必要がないとみなされたようです。

2018-01-30 08:45:54,198 [DEBUG] Setting service hello-sysvinit to disabled
2018-01-30 08:45:54,295 [INFO] disabled service hello-sysvinit
2018-01-30 08:45:54,311 [DEBUG] No need to modify running state of service hello-sysvinit

これはデーモン稼働時のステータスがactivating (start)になっていることに起因しているのではないかと考えられます。

$ systemctl status hello-sysvinit
● hello-sysvinit.service - LSB: Example initscript
   Loaded: loaded (/etc/init.d/hello-sysvinit; bad; vendor preset: enabled)
   Active: activating (start) since Tue 2018-01-30 05:09:55 UTC; 1min 6s ago
     Docs: man:systemd-sysv-generator(8)
  Control: 1210 (hello-sysvinit)
    Tasks: 3
   Memory: 560.0K
      CPU: 118ms
   CGroup: /system.slice/hello-sysvinit.service
           ├─1210 /bin/sh /etc/init.d/hello-sysvinit start
           ├─1252 bash /opt/daemon_test/hello.sh sysvinit
           └─1762 sleep 1

Jan 30 05:09:55 ip-172-31-29-245 systemd[1]: Starting LSB: Example initscript...

ちなみにcronデーモンだと以下のように active (running) になっています。/etc/init.d/hello-sysvinitの書き方に一工夫必要なように感じられます。

$ systemctl status cron
● cron.service - Regular background program processing daemon
   Loaded: loaded (/lib/systemd/system/cron.service; enabled; vendor preset: enabled)
   Active: active (running) since Tue 2018-01-30 05:09:55 UTC; 57s ago
     Docs: man:cron(8)
 Main PID: 1099 (cron)
    Tasks: 1
   Memory: 356.0K
      CPU: 1ms
   CGroup: /system.slice/cron.service
           └─1099 /usr/sbin/cron -f

Jan 30 05:09:55 ip-172-31-29-245 cron[1099]: (CRON) INFO (pidfile fd = 3)
Jan 30 05:09:55 ip-172-31-29-245 cron[1099]: (CRON) INFO (Running @reboot jobs)
Jan 30 05:09:55 ip-172-31-29-245 systemd[1]: Started Regular background program processing daemon.

systemdと正しくコンバーチブルなsysvinit設定ができていなかったようですね…
今回はこれ以上は深追いしないでおきます。

まとめ

今回の結果から確実に言えるのは、プレーンなUpstartおよびsystemdの設定のみから起動されるデーモンはcfn-initservicesキーでは制御できないということです。cronデーモンはsysvinitとのコンバーチブルな設定が配置されていたためcfn-initservicesキーで制御できていたのですね。もし制御したいサービスがsysvinitコンバーチブルな設定が配置されていない場合は自前で準備してやる必要がありそうです。少々面倒ですね。

くわえて、今回の実験においてcfn-initのインストールに非常に時間がかかるという事実がありました。インストール完了までは各種デーモンが稼働してしまっているのが確認できました。リソースのスペックによるとは思うのですが、この点は要注意です。

これらを総合して考えると、cfn-initservicesキーを使ってのサービスの停止が有用な場面は限られてくると思われます。「sysvinit設定が配置されていて、稼働時間が完全にゼロにならなくても実害がないサービス」のみで使えそうですね。cfn-initの使用を前提とした場合は、OSの起動システムでの起動設定は行わずcfn-initにて所望のデーモンを起動するのが良さそうです。 何ごとも適材適所ですね。


このエントリーをはてなブックマークに追加

いきなりですが、PHPでオブジェクトの比較をするのって面倒ではないですか?
かといってバグ対応の方が面倒なのでテストをサボらずにしぶしぶ書く、そんな毎日でした。

そこで、厳密なオブジェクトの比較を簡単にできないかと考え解決策に至るまでの道のりの話です。

面倒な例

<?php

class Foo {
  private $bar;
  private $baz;

  public function __construct($bar, $baz)
  {
    $this->bar = $bar;
    $this->baz = $baz;
  }

  public function getBar()
  {
    return $this->bar;
  }

  public function getBaz()
  {
    return $this->baz;
  }
}

戻り値のチェック

タイプ量が多いのでまあまあ面倒です。
PHPのテストは本当に手が疲れます。

<?php

$fooList = $service->doSomething();
self::assertCount(3, $fooList);

// プロパティが多いほどタイプ量が増える
self::assertEquals('bar1', $fooList[0]->getBar());
self::assertEquals('baz1', $fooList[0]->getBaz());
self::assertEquals('bar2', $fooList[1]->getBar());
self::assertEquals('baz2', $fooList[1]->getBaz());

assertメソッドを定義するのは機能としてイマイチ

誰もがメソッドにまとめた事があるかと思いますが、あまり便利じゃありませんよね?
値の種類(?)が増えると引数を増やすしかなく、早々にやめました。

<?php

$fooOrNull = $service->doSomething();

// 例えばnullableになったとした場合、フラグを足さない限り`assert`メソッドにまとめられない
self::assertNotNull($fooOrNull);
self::assertFoo('bar1', 'bar2', $fooOrNull);

// それともフラグを追加する?
self::assertFoo('bar1', 'bar2', $allowNull = false, $fooOrNull);

引数のチェック

複数回コールされる場合はもはやテストなのかすら怪しいですね。

<?php

$service
  ->expect(self::exactly(2))
  ->method('doSomething')
  ->with(self::callback(function (Foo $foo) {
    if ('bar1' === $foo->getBar()) {
      self::assertEquals('baz1', $foo->getBaz());
    } else {
      self::assertEquals('baz2', $foo->getBaz());
    }
  }));

$service->doSomething(new Foo('bar1', 'baz1'));
$service->doSomething(new Foo('bar2', 'baz2'));

労力の割にメリットがないので、妥協できる範囲でサボりますよね・・・

面倒くさがってサボる

結局、最低限しかテストできない状態に。

<?php

// 間違ってはいないが・・・
$service
  ->expect(self::exactly(3))
  ->method('doSomething')
  ->with(self::isInstanceOf(Foo::class))

解決策(ボツ): 制約(Constraint)を定義する

特定のオブジェクトに対するPHPUnitの制約を定義しておく事で、手軽さと厳密さを両立する事が出来ました。
比較ロジック自体がPHPUnitの資産をあまり有効活用できていない点や、比較処理の実装が必要になる点はイマイチですね。

<?php

pubic function test()
{
  // 比較が複数回必要でもタイプ数は少なめ
  self::assertThat($fooList[0], isFoo('bar1', 'baz1'));
  self::assertThat($fooList[1], isFoo('bar2', 'baz2'));

  // 引数の比較がそれらしい方法で実現できる
  $this->service
    ->expect(self::exactly(2))
    ->method('doSomething')
    ->with(self::logicalOr(
      self::isFoo('bar1', 'baz1'),
      self::isFoo('bar2', 'baz2')
    ));

  // 比較の柔軟性が高い
  $fooOrNull = $this->doSomething();
  self::assertThat(self::isNullableFoo('bar', 'baz'), $fooOrNull);
}

private static function isFoo($bar, $baz)
{
  return self::logicalAnd(
    self::isInstanceOf(Foo::class),
    self::callback(function (Foo $foo) use ($bar, $baz) {
      // 比較方法は自前実装なので、テストとしてはPHPUnitの比較には劣る
      return $bar === $foo->getBar() && $baz === $foo->getBaz();
    })
  );
}

private static function isNullableFoo($bar, $baz)
{
  return self::logicalOr(
    self::identialTo(null),
    self::isFoo($bar, $baz)
  );
}

他にも、テストに失敗した場合のメッセージが親切でない(何と比較したか表示できない)ので、テストに失敗した時はまあまあ最悪です。

# テストが失敗した結果

1) SomeTest::test
Failed asserting that Foo Object &000000005b85f7750000000027609f23 (
    'bar' => 'bar'
    'baz' => 'baz'
) is instance of class "Foo" and is accepted by specified callback.

解決策: プロパティベースの制約を定義する

オブジェクトの比較ロジックを自前で実装せずに、プロパティベースの制約を組み合わせて制約を生成するといい感じになりました。
素の方法と比べると以下のようなメリットがあります。

  • テストに失敗した時に何が原因なのかが分かりやすい
  • 比較ロジックにPHPUnitの資産をそのまま使える
  • 柔軟性が高い
  • 定義が楽

制約の組み立て方のイメージは以下の通り。

<?php

class SomeTest extends \PHPUnit_Framework_TestCase
{
  private static function isFoo($bar, $baz)
  {
    return self::logicalAnd(
      // 型が `Foo` である
      self::isInstanceOf(Foo::class),
      // プロパティ `bar` が `$bar`と一致する
      self::property(self::equalTo($bar), 'bar'),
      // プロパティ `baz` が `$baz`と一致する
      self::property(self::equalTo($baz), 'baz')
    );
  }
}

テストに失敗した時に何が原因なのかが分かりやすい

何と比較したのかが表示されるため、原因が分かりやすい。

<?php

self::assertThat(new Foo('bar', 'baz'), self::isFoo('a', 'b'));
1) SomeTest::test
Failed asserting that Foo Object &000000003c51c718000000004dc4af2d (
    'bar' => 'bar'
    'baz' => 'baz'
) is instance of class "Foo" and property "bar" is equal to <string:a> and property "baz" is equal to <string:b>.

比較ロジックにPHPUnitの資産をそのまま使える

PHPUnitの制約をプロパティ$barに適用させるだけなので、再実装はありません。

<?php
// プロパティ `bar` が `$bar`と一致する
self::property(self::equalTo($bar), 'bar'),

柔軟性が高い

値の種類(?)が増えても、好きな粒度で制約を合成でき柔軟に対応できる。

<?php

public function test()
{
  $constraint = self::isFoo('bar', self::isBaz());
}

// 定義
private function isFoo(string $bar, \PHPUnit_Framework_Constraint $baz = null)
{
  // `Foo#baz` の比較を切り替えた後に、制約を合成すればnullableも簡単に実現できる
  $baz = $baz ?: self::isNull();

  return self::logicalAnd(
    self::isInstanceOf(Foo::class),
    self::property($bar, 'bar'),
    self::property($baz, 'baz')
  );
}

定義が楽

楽ですね

self::property() なんてメソッドないんだけど・・・?

素のPHPUnitにはこの制約の実装がないので実装が必要です。
何度も実装するのは面倒なので、制約の実装ついでに便利メソッドを生やしOSSとして公開しました。

https://packagist.org/packages/hshn/phpunit-object-constraint

概念は今回紹介した内容と同じですが、APIがよりシンプルになっています。

<?php

// この制約は以下のような意味を持ちます
//   - \stdClassインスタンスである かつ
//   - プロパティ `foo` が 'a' で始まり、'e'で終わる かつ
//   - プロパティ `bar` が true である
$constraint = $this->constraintFor(\stdClass::class)
    ->property('foo')
        ->stringStartsWith('a')
        ->stringEndsWith('e')
    ->property('bar')
        ->isTrue()
    ->getConstraint();

制約を作ったらあとはassertするだけです。

<?php

self::assertThat($value, $constraint);

まとめ

2つの面倒くささに板挟みにされても、トレードオフだと言い聞かせて片方の面倒くささを受け入れずに済むようになりました。
まあまあいいと思いますよ〜