Slide 1

Slide 1 text

QA 環境で 誰でも自由自在に 現在時刻を操って 検証できるようにした話________ Toshiyuki Fujita (@kalibora)

Slide 2

Slide 2 text

自己紹介 Toshiyuki Fujita (@kalibora) PHP 歴は20 年くらい Yahoo! JAPAN -> (Crocos) -> OTOBANK -> RABO 基本的にはずっとPHP を書いてメシを食 ってます(ありがたい) 今回の件は OTOBANK 時代にやった話 Symfony, Doctrine が大好きです

Slide 3

Slide 3 text

背景 ユニットテストでは現在時刻を自由自在に操ってテストしていますよね? でもQA 環境で未来の日時に始まるキャンペーンをテストしたい場合、どうす ればよいでしょう? DB のキャンペーン開始日時のデータをいじる? DB じゃなくてソースコードにハードコーディングされていたら? ソースコードに書かれている開始日時を修正しちゃう? それもいいけど、間違えてデプロイしない?それに何度もやるのはめ んどくさい もっとスマートに解決したい!!

Slide 4

Slide 4 text

今日話すこと どのようにスマートに解決したか? 前半は現在時刻に関する一般的な設計テーマ 現在時刻に依存するテストのアプローチ 本当に毎回現在時刻が必要? 外部からの現在時刻の設定方法 後半はSymfony を使った実装アプローチ

Slide 5

Slide 5 text

現在時刻に依存するテスト のアプローチ

Slide 6

Slide 6 text

現在時刻に依存するテストのアプローチ そもそも現在時刻に依存するテストの話題って大昔からありますよ ね? 現在時刻が関わるユニットテストから、テスト容易性設計を学ぶ - t- wada のブログ(アプローチ1 〜7 までの7 通りのやり方が詳細に書いてあります) ↑↑これを読めば万事OK!! ↑↑ だけどちょっとだけ紹介します。

Slide 7

Slide 7 text

アプローチ1: シンプルに対象メソッドの引数に渡す 例えばキャンペーンが開催中かどうかを返す Campaign::isActive() メ ソッドがあったとすると、 class Campaign { public function isActive(\DateTimeInterface $now): bool { return $this->beginAt <= $now && $now < $this->endAt; } }

Slide 8

Slide 8 text

アプローチ2: 組み込みクラス/ メソッド/ 関数に介入する 古くは php-timecop PHP 拡張でPHP 標準の時刻系関数の値を固定する 最近だと多く使われてそうなのは Carbon:setTestNow() プロジェクト全体で Carbon に依存している場合はこの方法が取れる class Campaign { public function isActive(): bool { $now = Carbon::now() return $this->beginAt <= $now && $now < $this->endAt; } }

Slide 9

Slide 9 text

アプローチ7: 現在時刻へのアクセスを行うインターフ ェイスを抽出 PSR-20: Clock - symfony/clock, lcobucci/clock readonly class CampaignService { public function __construct( private ClockInterface $clock, ) {} public function isActive(Campaign $campaign): bool { $now = $this->clock->now(); return $campaign->getBeginAt() <= $now && $now < $campaign->getEndAt(); } }

Slide 10

Slide 10 text

どのようなアプローチをとってもいいが、シス テム全体の設計として現在時刻を動的に変更し てテストができる設計となっている事が重要

Slide 11

Slide 11 text

本当に毎回現在時刻が必要?

Slide 12

Slide 12 text

本当に毎回現在時刻が必要? 現在時刻とカジュアルに言っているが、毎回現在時刻が欲しい? 例えば、終了が2024 年いっぱいまでのキャンペーンにギリギリに応募してき た人がいたとして、データベースには応募日時を保存するカラムがあり、応 募日時を現在時刻から取得していた場合、 1. 2024/12/31 23:59:59 コントローラーの最初の方で現在時刻を元にして キャンペーン開催中かチェック(ギリギリセーフ!) 2. 何かしらの重い処理で数秒経過 3. 2025/01/01 00:00:02 応募日時を現在時刻から取得し、DB に保存 なぜかキャンペーン終了後の応募日時があることに

Slide 13

Slide 13 text

本当に毎回現在時刻が必要? このように一連の処理(1 トランザクション)の中で2 回以上現在時 刻が欲しいケースというのはあまりないはず 先程のケースだと最初に1 回だけ現在時刻を取って引き回せばい いはず そして、PHP のようなWeb アプリケーション前提ではそれはリクエ スト時刻が適している

Slide 14

Slide 14 text

\ $_SERVER['REQUEST_TIME'] /

Slide 15

Slide 15 text

$_SERVER['REQUEST_TIME'] PHP: $_SERVER - Manual によると PHP のWeb アプリケーションで現在時刻ではなく処理の開始時刻が欲 しいのであれば、この値が使える。 'REQUEST_TIME' リクエストの開始時のタイムスタンプ “ “

Slide 16

Slide 16 text

外部からの現在時刻の設定方法

Slide 17

Slide 17 text

外部からの現在時刻の設定方法 QA 環境でWeb ブラウザを通して行うテストでどのように現在時刻を 設定するか? クエリパラメーター? ️ アプリケーションの通常の用途で使う事が多いし、URL に露 出するのでコピペ時に邪魔になる HTTP ヘッダー? URL に露出しないし、ブラウザの拡張機能で誰でも簡単に付 与できる

Slide 18

Slide 18 text

Symfony を使った実装の具体例

Slide 19

Slide 19 text

実装するにあたっての前提条件 システム全体として、現在時刻を動的に変更してテストができる設 計となっている 今回の私のケースだと アプローチ1: シンプルに対象メソッドの引数に渡す をシステム全体を通して使っている 処理の開始時刻としてリクエスト時刻を使うことにする 任意の現在時刻(リクエスト時刻)の設定(偽装)にはHTTP ヘッ ダーを使う

Slide 20

Slide 20 text

リクエスト時刻を表すクラスの作成 リクエスト時刻を表す RequestTime クラスを作成。単に DateTimeImmutable を継承。 debug; } public function setDebug(bool $debug): self { $clone = clone $this; $clone->debug = $debug; return $clone; } }

Slide 21

Slide 21 text

リクエスト時刻を抽出するクラスの作成 先ほど作成した RequestTime を Request から抽出するクラスの Interface を定義。

Slide 22

Slide 22 text

本番用のリクエスト時刻を抽出するクラスを作成 まずは通常の $_SERVER['REQUEST_TIME'] から RequestTime を抽出するクラスを作成。 これは本番環境用。 server->getInt('REQUEST_TIME'); // $_SERVER['REQUEST_TIME'] と等価 $timezone = new \DateTimeZone(date_default_timezone_get()); return (new RequestTime("@{$timestamp}"))->setTimezone($timezone); } }

Slide 23

Slide 23 text

開発用のリクエスト時刻を抽出するクラスを作成 次に独自のHTTP ヘッダーの値から任意の時刻の RequestTime を抽出するクラスを作成。 こ れは dev や test 環境用。 headers->get('X-Debug-Request-Time'); $requestTime = null !== $value ? $this->extractFromDebugHeader($value) : null; return $requestTime ?? parent::extract($request); } private function extractFromDebugHeader(string $value): ?RequestTime { try { $requestTime = ctype_digit($value) ? new RequestTime("@{$value}") : new RequestTime($value); } catch (DateMalformedStringException $e) { return null; // 入力が不正でもエラーにせず無視する } // 独自のHTTPヘッダーから取得した場合は debug を true にしておく return $requestTime->setDebug(true)->setTimezone(new DateTimeZone(date_default_timezone_get())); } }

Slide 24

Slide 24 text

DI で環境ごとにリクエスト抽出クラスを使い分ける Symfony では環境ごとに実際にInject するクラスを使い分けることはできる。 config/services.yaml では下記の様に、 ExtractorInterface として Extractor を使うよ うに設定。 App\Service\RequestTime\ExtractorInterface: class: App\Service\RequestTime\Extractor config/services_dev.yaml と config/services_test.yaml では下記の様に ExtractorInterface として DebugExtractor を使うように設定。 App\Service\RequestTime\ExtractorInterface: class: App\Service\RequestTime\DebugExtractor これで dev, test 環境の時のみ、独自のHTTP ヘッダーを用いて、リクエスト時刻を任意 の時間に偽装する事が出来るようになる。 (本番では $_SERVER['REQUEST_TIME'] からし か抽出しない。実行時のif 文分岐ではないのでパフォーマンスにも影響なし)

Slide 25

Slide 25 text

イベントリスナーにて抽出したリクエスト時刻を設定する Symfony の イベントリスナー を使ってリクエストの attributes に、先程までに作った Extractor で抽出した RequestTime クラスを設定。 Request に情報を追加するので、サブスクライブするイベントは kernel.request 。 ['onKernelRequest', 10]]; // Security Firewall よりは優先させる } public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); $requestTime = $this->extractor->extract($request); $request->attributes->set(RequestTime::REQUEST_ATTR_NAME, $requestTime); // リクエストの attributes に設定 $this->twig->addGlobal('request_time', $requestTime); // Twig にグローバル変数として設定 } }

Slide 26

Slide 26 text

これで各所でリクエスト時刻が使える ここまでの実装で $request->attributes->get(RequestTime::REQUEST_ATTR_NAME); と書けばコントローラ等で任意のリクエスト時刻を取得できるので、現在時刻 (リクエスト時刻)が必要なサービスなどのクラスに渡して使うことができる し、 Twig テンプレート内で {{ request_time|date('Y/m/d H:i:s') }} と書けばTwig 内でも使用できる。

Slide 27

Slide 27 text

例えばこんな風に使う( コントローラー内) '\d+'])] public function detail(Campaign $campaign, Request $request): Response { $requestTime = $request->attributes->get(RequestTime::REQUEST_ATTR_NAME); if (!$campaign->isActive($requestTime)) { throw $this->createNotFoundException(); } return $this->render('campaign/detail.html.twig', [ 'campaign' => $campaign, ]); } }

Slide 28

Slide 28 text

例えばこんな風に使う(Twig テンプレート内) {% extends 'base.html.twig' %} {% block title %}{{ campaign.title }}{% endblock %} {% block body %}

{{ campaign.title }}

現在時刻は {{ request_time|date('Y/m/d H:i:s') }} です。 {% if campaign.isActive(request_time) %}

キャンペーン開催中です。

{% else %} {% if request_time < campaign.beginAt %}

このキャンペーンはまだ開始していません。

{% else %}

このキャンペーンは終了しました。

{% endif %} {% endif %} {% endblock %}

Slide 29

Slide 29 text

さらに便利な使い方

Slide 30

Slide 30 text

Controller の引数にリクエスト時刻を渡せるようにする Symfony ではコントローラーのメソッドの引数に独自の引数を渡す事が出来る機能 が あるので、下記のようなクラスを作成すれば getType(); if (!$argumentType || !is_a($argumentType, RequestTime::class, true)) { return []; } yield $request->attributes->get(RequestTime::REQUEST_ATTR_NAME); } }

Slide 31

Slide 31 text

Controller の引数にリクエスト時刻を渡せるようにする 下記のようなコントローラーのメソッドの引数で直接 RequestTime を受け取れる。 '\d+'])] public function detail(Campaign $campaign, RequestTime $requestTime): Response { if (!$campaign->isActive($requestTime)) { throw $this->createNotFoundException(); } return $this->render('campaign/detail.html.twig', [ 'campaign' => $campaign, ]); } }

Slide 32

Slide 32 text

デバッグバーにリクエスト時刻を出す Symfony には dev 環境などでページ下部に表示されるデバッグバーがあって便 利。ここにリクエスト時刻を表示し、かつ任意の時刻に偽装している(デバッグ モードの)場合は、分かりやすく赤い表示にする。 こうしておけばQA 環境で確認する人が、時間を変更できているのかひと目でわか って便利。 src/DataCollector/RequestTimeCollector.php templates/data_collector/template.html.twig

Slide 33

Slide 33 text

まとめ QA 環境でPdM などのエンジニア以外の人も、ブラウザの拡張機能を 使って独自のHTTP ヘッダーを設定することで、任意の時刻のテストを エンジニアの手を借りずに出来るようになった。 キャンペーン系のテストは捗るし、フレームワーク(Symfony )の機 能を上手く使うとことで無理なく便利に実装出来た。 Symfony 最高! あ、あとコードは全部 https://github.com/kalibora/symfony-debug-request-time にあります。