QA環境で誰でも自由自在に現在時刻を操って検証できるようにした話
by
kalibora
×
Copy
Open
Share
Embed
Copy iframe code
Copy JS code
Copy link
Start on current slide
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 にあります。