0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Clockを注入したのにテストがフレークした|FixedClockと実time()の非対称

0
Last updated at Posted at 2026-07-24

はじめに

時刻に依存するテストを Clock 注入で固定したはずなのに、なぜかフレークする——そんな経験のある方へ。

「時刻に依存するテストは Clock を注入して固定すればフレークしなくなる」——これはほぼ正しいのですが、片側にしか注入していないと、むしろ時限爆弾を仕込むことになるという話をします。

実際に踏んだのはこういう症状でした。トークン発行のユースケースには FixedClock を注入して時刻を固定していた。にもかかわらず、あるテストが特定の日付を境に、コードを一切変えていないのに落ち始めた。CI のログには毎回こう出ます。

Nene2\Auth\TokenVerificationException: Token has expired.

原因は「発行側は固定時計、検証側は実 time()」という時刻源の非対称でした。本記事は、その非対称がどう時限フレークになるのか、そして最終的に検証側(フレームワーク)で time() 直書きを注入可能な Clock に置き換えて根治するまでの記録です。読者は PHP の実務中級を想定します。

対象は 2 つのリポジトリにまたがります。

  • アプリ側(トークン発行): nene-fieldLoginUseCase。ここは最初から ClockInterface を注入済み。
  • フレームワーク側(トークン検証): NENE2LocalBearerTokenVerifier。ここが time() を直書きしていた。

この記事で分かること:

  • Clock を発行側だけに注入し検証側が実 time() だと、カレンダーが進むだけで落ちる「時限フレーク」になる仕組み
  • 検証クラスに optional な ClockInterface を後方互換で足し、時刻源を対称にする根治
  • now を1検証1回に正規化して、秒境界の「2回読み」競合も同時に潰す設計

注入したのにフレークした

発行側のコードはこうなっています。ログイン成功時に、注入された時計から now を1回読み、そこから 24h の TTL で exp を計算して署名付きトークンを発行します。

// nene-field/src/Auth/LoginUseCase.php
private const TOKEN_TTL_SECONDS = 86_400; // 24h

$now = $this->clock->now()->getTimestamp();

$token = $this->tokenIssuer->issue([
    'sub' => $user->userId,
    'role' => $user->role->value,
    'org' => $user->organizationId,
    'iat' => $now,
    'exp' => $now + self::TOKEN_TTL_SECONDS,
]);

テスト側は、この $this->clock に固定時計を注入していました。決定論を狙った、いかにも正しい書き方です。

// 修正前の useCase() ヘルパー
return new LoginUseCase(
    new InMemoryUserRepository($users),
    $this->verifier,
    new FixedClock(new DateTimeImmutable('2026-06-11T00:00:00Z')),
);

FixedClock(2026-06-11T00:00:00Z) を注入しているので、発行されるトークンの exp は必ず 2026-06-12T00:00:00Z(=発行日 + 24h)に固定されます。ここまでは完全に決定論的です。

問題は、テストが最後にそのトークンを検証するところにありました。

$output  = $useCase->execute(new LoginInput(self::ORG, 'tanaka@example.com', 'secret'));
$claims  = $this->verifier->verify($output->token); // ← ここで例外

$this->verifiersetUp() で素朴に組み立てられていて、時計を注入していません

$this->verifier = new LocalBearerTokenVerifier('test-secret');

つまり構図はこうです。発行側の exp は固定時計由来(2026-06-12 で凍結)なのに、検証側は実時刻でその exp を判定する。実時刻が 2026-06-12T00:00:00Z を越えた瞬間から、このトークンは「発行された直後にすでに失効している」状態になります。

2026-06-11 に書いたときは通っていたテストが、exp2026-06-12)を実時刻が追い越した日から静かに落ち始める。カレンダーが進むだけで壊れる、典型的な時限フレークです。実際、このテストは 2026-07-06 時点ですでに恒常的に赤くなっていました(発行日 2026-06-11 から 25 日後、exp2026-06-12)から見ても 24 日超過)。

Clock を注入したのにフレークする」のは、注入が片側だけだったからです。発行側の時刻は凍らせたが、検証側の時刻は実 time() のまま流れていた。両者の時刻源がズレた瞬間に、境界(+24h)が「未来の固定点」から「すでに過ぎた過去」に変わる——これが非対称の正体です。

発行/検証の時刻源が別だった

では検証側の実装を見ます。フレームワーク NENE2LocalBearerTokenVerifier::verify() は、こうなっていました(修正前)。

// 修正前: nbf / exp をそれぞれ time() で判定
if (isset($claims['nbf']) && is_int($claims['nbf']) && $claims['nbf'] > time()) {
    throw new TokenVerificationException('Token is not yet valid.');
}

if (!isset($claims['exp']) || !is_int($claims['exp'])) {
    throw new TokenVerificationException('Token must contain a numeric exp claim.');
}

if ($claims['exp'] < time()) {
    throw new TokenVerificationException('Token has expired.');
}

time() が直書きされていて、時計を外から注入する口がありません。したがって「発行側だけ FixedClock、検証側は実 time()」という非対称は、テストコードの書き方の問題である以前に、検証クラスが時刻源を固定できない構造だったことに起因します。

ここで対処は 2 段階になりました。

① アプリ側の即時回避(nene-field PR #76)。フレームワークの verify() に手を入れられない状況では、テスト側でできることは「発行時刻を検証時刻に揃える」ことだけです。固定の過去時刻をやめ、FixedClock を実 now(UTC)から seed します。

// 修正後: 実 now(UTC) から固定時計を seed する
return new LoginUseCase(
    new InMemoryUserRepository($users),
    $this->verifier,
    new FixedClock(new DateTimeImmutable('now', new DateTimeZone('UTC'))),
);

こうすると exp = now + 24h となり、有効期間の窓が常に検証時刻(実 time())をまたぐので、カレンダーに依存せず決定論的に緑になります。あわせて回帰ガードのアサーションも足しました。

// 検証時点で失効していないこと=過去固定クロックへの回帰を防ぐ
self::assertGreaterThan(time(), $claims['exp']);

これは実 time() に依存しない「同じ時計を通す」理想形ではなく、**「検証側の時刻源に発行側を合わせる」**という現実的な折衷です。フレームワークの verify()time() 直書きである以上、テスト単体でできるのはここまででした。

② フレームワーク側の根治(NENE2 PR #1507)。本丸は検証クラスに時計を注入できるようにすることです。コンストラクタ末尾に optional な ClockInterface を加えました(PHP 8.1+ の new-in-initializer で既定値を持たせる)。

public function __construct(
    private string $secret,
    private ClockInterface $clock = new UtcClock(),
) {
}

既定は UtcClock(=実 UTC 時刻)で、UtcClock::now()->getTimestamp()time() と同値です。よって既存の呼び出し側 new LocalBearerTokenVerifier($secret) は無改修のまま挙動不変——非破壊の加算(optional 引数の追加)です。そのうえで、発行側と検証側の両方に同一インスタンスの Clock を渡せば、時刻源が対称になり、固定時計で exp/nbf 境界を決定論的にテストできるようになります。これで①の「実時刻に合わせる折衷」ではなく、任意の過去・未来の固定時刻で境界をテストできる本来の形が手に入りました。

now を2回読む競合の根治

time() 直書きにはもう一つ、非対称とは別の落とし穴がありました。修正前の verify() は、nbf の判定で time()exp の判定でもう一度 time() と、同一検証内で now を2回読んでいます

この2回の読み取りは同じ瞬間ではありません。間に処理が挟まる以上、秒境界をまたぐ可能性があります。極端な例では、「nbf チェック時点ではまだ有効前、exp チェック時点では別の秒」という、1回の検証の中で時刻の一貫性が崩れる状態が起こり得ます。境界ちょうどのトークンでは、これ自体がフレークの種になります。

PR #1507 は、非対称の解消と同時にこの2回読みも潰しました。now を1回だけ読み、nbfexp を同一 instant で判定します。

// 修正後: now を1回だけ読み、nbf/exp を同じ instant で判定
$now = $this->clock->now()->getTimestamp();

if (isset($claims['nbf']) && is_int($claims['nbf']) && $claims['nbf'] > $now) {
    throw new TokenVerificationException('Token is not yet valid.');
}

if (!isset($claims['exp']) || !is_int($claims['exp'])) {
    throw new TokenVerificationException('Token must contain a numeric exp claim.');
}

if ($claims['exp'] < $now) {
    throw new TokenVerificationException('Token has expired.');
}

コメントにもそのまま「単一 instant で評価して2回読みの秒境界競合を避ける」意図が書かれています。時計を注入可能にする改修と、now を1回読みに正規化する改修が、同じ1コミットで両立している点がポイントです——時刻源を注入可能にするという設計変更は、副産物として時刻を1点に固定することを自然に強制します。

この根治は既存挙動を壊さないことを担保したうえで入りました。フレームワーク側では、固定時計を注入して過去/未来の固定時刻で exp/nbf 判定が決定論的に動くこと、発行↔検証が対称になること、境界が決定論であることを確認するテストが新規追加され、既定引数を省略した従来挙動の不変は既存テストが担保しています。

学び

  • Clock 注入は「片側だけ」だと逆に危険。 発行側だけ固定して検証側が実時刻だと、境界(この例では発行日 +24h)を実時刻が越えた瞬間から時限で落ちる。凍らせた exp を、流れ続ける now で判定する非対称そのものがフレークの原因になる。
  • 時刻源はシステムの両端で対称にする。 発行と検証で同じ Clock インスタンスを通せば、任意の固定時刻で境界を決定論的にテストできる。片側にしか注入口がないなら、まずそこを注入可能にするのが根治(PR #1507)。それが間に合わない層では「発行時刻を検証時刻に揃える」折衷でフレークだけは止められる(PR #76)が、それは対症療法だと自覚しておく。
  • now は1検証につき1回だけ読む。 time() を複数箇所で直に読むと、秒境界をまたいで判定の一貫性が崩れる。Clock から1回読んで使い回すだけで、注入可能性と一貫性の両方が手に入る。
  • 時刻直書きの time()/date() は「注入できない」というだけで設計上の負債。 テストが書けない・境界が再現できない・上の非対称を招く、の三重苦になる。見つけたら optional な ClockInterface 引数で非破壊に注入口を開けておくのが安い。

一次資料

  • nene-field PR #76「LoginUseCaseTest の時限テストフレークを解消する」(PR は #19 を参照) — 発行時刻を検証時刻に揃える即時回避。commit f73b1f2tests/Auth/LoginUseCaseTest.php。TTL 定義は src/Auth/LoginUseCase.php:18TOKEN_TTL_SECONDS = 86_400)、exp 計算は同 :37-44
  • NENE2 PR #1507「LocalBearerTokenVerifierClockInterface を後方互換で導入する」(元 #1506) — 検証側の time() 直書き2箇所を注入 Clock の単一 now 読みに置換する根治。commit 9645b65src/Auth/LocalBearerTokenVerifier.php(現行 :65now を1回読み、:67/:75nbf/exp 判定)。

関連記事「時間とテスト」

時刻とテストの決定論をめぐる連作の一本です。

  1. time() と裸の date() を独自リンタで狩る(公開済)— 時刻直読みを挙動不変で注入に置き換える実録
  2. 共有レンタルサーバーで作成1秒のorgが即消える(公開済)— JST×UTC created_at の素パース障害
  3. 本記事 — 注入したのにフレークする「時刻源の非対称」

主役は「注入したのにフレークする」——時刻源の非対称という、Clock 注入を一歩間違えたときの落とし穴です。


── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?