はじめに
この記事では、Defects4Jに収録された実在OSSバグ Mockito-2 を、固定版の実装を先に見ずに調査します。対象はMockitoの待機検証で使う org.mockito.internal.util.Timer です。
Mockito.after(-1000) や Mockito.timeout(-100) は、修正前に負の時間を受け入れていました。Mockito Issue #197 では、after(-1000).atLeastOnce() が呼び出されていないメソッドの検証を不正に成功させる例が報告されています。時間の経過を待つAPIでは、負の時間を通常の値として扱わず、検証を始める前に入力として拒否する必要があります。
| 項目 | 内容 |
|---|---|
| Defects4Jプロジェクト | Mockito |
| Bug ID | 2 |
| 対象OSS | Mockito |
| 対象クラス | org.mockito.internal.util.Timer |
| トリガーテスト | 2クラス・3テストメソッド |
| 修正前の症状 | 負の時間指定が例外にならず、待機検証の意味が崩れる |
| 根本原因 |
Timer のコンストラクタが負の durationMillis を検証しない |
| 修正の中心 | 時刻計算の前に負の値を FriendlyReminderException として拒否する |
待機検証が守る契約
timeout と after は、非同期処理でモックの呼び出しを検証するための機能です。検証時点ですぐに呼び出しが完了しているとは限らないため、timeout は指定時間以内に呼び出されるかを待ちながら確認します。
verify(mock, timeout(1000)).someMethod();
この例は、最大1000ms待って someMethod() が呼ばれるかを確認します。timeout は検証条件を満たした時点で早期に成功します。一方、after は原則として指定時間にわたり検証し、時間経過後に結果を確定します。Mockito Javadoc どちらも時間を待機条件として扱うため、負の時間には意味のある解釈がありません。
負の値を Timer に渡すと、開始直後から待機終了と判断され、待機検証の制御が壊れる可能性があります。今回のトリガーテストは、単に負数で例外を送出する実装詳細を確認するものではありません。待機時間として成立しない値を検証処理へ流さないというAPIの入力契約を守っています。
修正前の再現
Defects4Jは、実在OSSの修正前後の版とトリガーテストを再現可能に提供するベンチマークです。Defects4J README
Defects4Jにおけるトリガーテストとは、対象のバグを含む版では失敗し、修正版では成功する、その不具合を直接再現するテストです。修正前に何が壊れているかと、修正後に何を守るかを同じ条件で確認できます。
defects4j checkout -p Mockito -v 2b -w work/Mockito-2b
cd work/Mockito-2b
defects4j export -p tests.trigger
defects4j test
今回のMockito-2では、負の時間指定を与えたときに FriendlyReminderException が送出されることを確認する3件のテストが、トリガーテストとして登録されています。対象は new Timer(-1)、Mockito.timeout(-1)、Mockito.after(-1) です。
try {
Mockito.timeout(-1);
Assert.fail("It is forbidden to invoke Mockito.timeout() with negative value.");
} catch (FriendlyReminderException e) {
Assert.assertTrue(true);
}
修正前版では3件が失敗しました。
Failing tests: 3
- TimerTest::should_throw_friendly_reminder_exception_when_duration_is_negative
- NegativeDurationTest::should_throw_exception_when_duration_is_negative_for_timeout_method
- NegativeDurationTest::should_throw_exception_when_duration_is_negative_for_after_method
仮説を比較する
| 仮説 | 予測 | 最小観測 | 結果 | 判定 |
|---|---|---|---|---|
timeout と after の公開APIだけが誤る |
new Timer(-1) は例外になる |
TimerTestを確認 |
new Timer(-1) も失敗 |
棄却 |
| 負の値が開始時刻計算で補正される | コンストラクタ後に値が0以上になる |
Timerのフィールド代入を確認 |
値をそのまま保持 | 棄却 |
| 入力検証が存在しない | コンストラクタに境界判定がない |
Timerのコンストラクタを確認 |
無条件で代入 | 採用 |
修正前の Timer は、渡された時間を検証せず保持していました。
public Timer(long durationMillis) {
this.durationMillis = durationMillis;
}
その後の判定は、開始後の経過時間が指定時間以下かだけを比較します。
return System.currentTimeMillis() - startTime <= durationMillis;
durationMillis = -1000 で開始直後を考えます。経過時間は0以上なので、判定は次のようになります。
経過時間 = 0以上
durationMillis = -1000
0 <= -1000
→ false
Timerは開始直後から「もう待機時間は終了した」と判断します。この事実だけでは、どの検証モードでも誤って成功するとは断定できません。
一方、Mockito Issue #197 では、after(-1000).atLeastOnce() が呼び出されていないメソッドの検証を成功させることが確認されています。Timerの即時終了はこの問題の前提ですが、誤成功という現象はIssueの再現例として確認します。
負の時間を比較ロジックへ渡すのではなく、入力が不正であることを明示して拒否する必要があります。
最小修正
Mockitoには、利用者向けの一貫した診断を生成する Reporter.cannotCreateTimerWithNegativeDurationTime がすでにありました。Timer の生成時にこの診断を呼び出せば、公開APIの after と timeout も同じ経路で保護されます。
+import org.mockito.exceptions.Reporter;
+
public Timer(long durationMillis) {
+ if (durationMillis < 0) {
+ new Reporter().cannotCreateTimerWithNegativeDurationTime(durationMillis);
+ }
this.durationMillis = durationMillis;
}
修正箇所を Timer のコンストラクタに置くことが重要です。after と timeout の呼び出し元ごとに検証を重ねず、時間を待機検証の状態へ変換する共通境界で契約を一度だけ強制できます。
固定版との答え合わせ
自力修正ではコンストラクタ内へ直接条件を書きました。トリガーテストと全テストが成功した後に固定版を確認すると、同じ判定とReporter呼び出しを validateInput ヘルパーへ抽出していました。
private void validateInput(long durationMillis) {
if (durationMillis < 0) {
new Reporter().cannotCreateTimerWithNegativeDurationTime(durationMillis);
}
}
| 比較項目 | 自力調査 | 固定版 |
|---|---|---|
| 原因 | 負の時間を無検証で保持する | 同じ |
| 例外の種類 | FriendlyReminderException |
同じ |
| 検証の場所 |
Timer コンストラクタ |
同じ |
| 構造 | コンストラクタ内へ直接記述 |
validateInput ヘルパーへ抽出 |
| 最終状態 | 固定版の対象クラスへ整合 | 差分なし |
回帰確認
defects4j test
| 確認項目 | 結果 |
|---|---|
| 3件のトリガーテスト | すべて Failing tests: 0
|
| Mockito全テスト | Failing tests: 0 |
| 固定版との差分 | 対象クラスで差分なし |
まとめ
- 時間を待機条件に渡すAPIでは、負の値を比較ロジックへ流す前に入力として拒否します。
- 共有する内部境界で検証すれば、
afterとtimeoutのような複数の公開APIを一貫して保護できます。 - Timerの即時終了だけで誤成功を一般化せず、検証モードごとの実際の挙動をトリガーテストやIssueで確認します。