「ソフトウェアテスト技法練習帳」40本ノック(2)/肉屋の特売日

この記事について

ソフトウェアテスト技法練習帳の問題をピックアップしてテストコードを書いています。
※コードは全てGithubにあげる予定です。
指摘・アドバイス大歓迎です!!!

仕様

・特売日には買い物金額が2割引になる
・特売日は1日から5日または28日から30日

テストコード

ButcherTest.php

<?php

use App\Butcher\Order;
use App\Butcher\OrderDate;
use PHPUnit\Framework\TestCase;

class ButcherTest extends TestCase{

    protected function setUp() :void {
        
    }

    /**
     * @test
     * @dataProvider 日付による買い物金額の割引用データ
     */
    public function 日付による買い物金額の割引(
        \DateTimeImmutable $orderAt,
        int $expectedPrice,
    ) {
        $order = new Order(
            1000,
            new OrderDate($orderAt),
        );

        $this->assertEquals($expectedPrice, $order->getTotalPrice());
    }

    public static function 日付による買い物金額の割引用データ() {
        return [
            '1日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-01'),
                800,
            ],
            '3日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-03'),
                800,
            ],
            '5日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-05'),
                800,
            ],
            // error
            '6日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-06'),
                1000,
            ],
            // error
            '20日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-20'),
                1000,
            ],
            // error
            '27日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-27'),
                1000,
            ],
            '28日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-28'),
                800,
            ],
            '29日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-29'),
                800,
            ],
            '30日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-30'),
                800,
            ],
            // error
            '31日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-31'),
                1000,
            ],
        ];
    }
}

Orderクラス

Order.php

<?php
namespace App\Butcher;

use App\Butcher\Interface\DiscountInterface;

class Order {

    private int $totalPrice;

    private DiscountCollection $discounts;

    private OrderDate $orderAt;

    public function __construct(
        int $totalPrice,
        OrderDate $orderAt,
    ) {
        $this->totalPrice = $totalPrice;
        $this->discounts = new DiscountCollection([]);
        $this->orderAt = $orderAt;
        
        if ($orderAt->isBargainDay()) {
            $dateDiscount = new DateDiscount($totalPrice);
            $this->addDiscount($dateDiscount);
        }
    }

    public function addDiscount(DiscountInterface $discount):void {
        $this->discounts->addDiscount($discount);
    }

    public function sumDiscount():int {
        return $this->discounts->sumDiscountValue();
    }

    public function getTotalPrice():int {
        return $this->totalPrice - $this->sumDiscount();
    }
}

成長点・工夫点

・クラスを分けてロジックを整理する
・複数の割引を想定する

クラスを分けてロジックを整理する

最初OrderDateクラスの特売日を判定するロジックをOrderクラスのコンストラクタに書いていました。
特売日かどうかはOrderが知っている必要はないと思い、別のクラスに分けてロジックを整理できました。

OrderDate.php

OrderDateクラス

<?php
namespace App\Butcher;

class OrderDate {

    private \DateTimeImmutable $value;

    public function __construct(
        \DateTimeImmutable $orderAt,
    ) {
        $this->value = $orderAt;
    }

    public function isBargainDay():bool {
        $orderDay = $this->value->format('j');
        if ($orderDay <= 5 || ($orderDay >= 28 && $orderDay <= 30)) {
            return true;
        }

        return false;
    }
}

複数の割引を想定する

今回は特売日による割引だけでしたが、今後様々な割引機能が想定されます。
なので割引クラスにはDiscountInterfaceを実装させてOrderクラスにはDiscountCollectionクラスを持たせました。
Arrayではなく新しくクラスを定義した理由は
・それぞれの割引機能ごとに重複利用できるものが異なるため
です。
例えば特売日割引は1度しか適用できませんが、クーポン割引は複数利用可能(ただし同じコードは1度まで)
のようなロジックがあると思います。
そのようなロジックを管理するクラスが必要だったため、新しく定義しました。
DiscountCollection内でswitch文を使っているところが少し気持ち悪いのですが、妥協してしまっています。

DiscountCollectionクラス

DiscountCollection.php

<?php
namespace App\Butcher;

use App\Butcher\Interface\DiscountInterface;

class DiscountCollection {

    private Array $discounts;

    public function __construct(
        Array $discounts,
    ) {
        $this->discounts = $discounts;
    }

    public function addDiscount(DiscountInterface $discount) {
        switch (get_class($discount)) {
            case 'App\Butcher\DateDiscount':
                $this->addDateDiscount($discount);
                break;
            default:
                new \Exception('存在しない割引です');
        }
    }

    private function addDateDiscount(DateDiscount $discount):void {
        if($this->checkDateDiscountExist()) {
            throw new \Exception('特売日割引はすでに適応済みです');
        }
        $this->discounts[] = $discount;
    }

    public function checkDateDiscountExist():bool {
        foreach($this->discounts as $item) {
            if(is_a($item, 'App\Butcher\DateDiscount')) {
                return true;
            }
        }

        return false;
    }

    public function sumDiscountValue(): int {
        $sumDiscount = 0;
        foreach($this->discounts as $discount) {
            $sumDiscount += $discount->getValue();
        }

        return $sumDiscount;
    }
}

補足

合計金額について

今回は問題文の内容から判断して、Orderクラスに引数で直接合計金額を渡しましたが
合計金額はOrderクラスが持っている注文詳細(OrderDetailとする)金額の合計だと思っているので
OrderクラスはtotalPrice変数を持たずに関数から取得したほうが金額の整合性が保たれると思います。

割引について

またDiscountCollectionクラスについても
・特売日でも例外商品がある
・クーポンで適応されるのは特定の商品のみ
のような仕様は十分に起こりうるのでOrderDetailクラスに持たせて
・特売日の場合はOrderクラスから全てのOrderDetailにDateDiscountを適応する
・クーポンの場合は特定の商品に対してであればOrderクラスから対象のOrderDetailに適応する
で対応できると思います。
※適応条件が
・注文金額合計
・特定の複数商品を購入時
などであれば結局OrderクラスにもDiscountCollectionのような変数を持たせて
getTotalPrice(各OrderDetailの金額から割引額を引いた金額の合計) - getDiscountSum(Orderに適応された割引の合計金額)
が注文金額になる感じでしょうか?

終わりに

現在9問目にして既に複雑になってきたと感じます。
とてもカロリーの高いことを始めてしまったのかもしれません。

追記(2023/05/03)

Twitterにてテストコードから仕様が抜けてしまっていることをフィードバックしてもらいました。

これを踏まえて、テストメソッドを分けてメソッド名から仕様が把握できるように変更しました。

ButcherTest.php

<?php

use App\Butcher\Order;
use App\Butcher\OrderDate;
use PHPUnit\Framework\TestCase;

class ButcherTest extends TestCase{

    protected function setUp() :void {
        
    }

    /**
     * @test
     * @dataProvider 1日から5日に買い物すると2割引になる用データ
     */
    public function 1日から5日に買い物すると2割引になる(
        \DateTimeImmutable $orderAt,
        int $expectedPrice,
    ) {
        $order = new Order(
            1000,
            new OrderDate($orderAt),
        );

        $this->assertEquals($expectedPrice, $order->getTotalPrice());
    }

    /**
     * @test
     * @dataProvider 28日から30日に買い物すると2割引になる用データ
     */
    public function 28日から30日に買い物すると2割引になる(
        \DateTimeImmutable $orderAt,
        int $expectedPrice,
    ) {
        $order = new Order(
            1000,
            new OrderDate($orderAt),
        );

        $this->assertEquals($expectedPrice, $order->getTotalPrice());
    }

    public static function 1日から5日に買い物すると2割引になる用データ() {
        return [
            '1日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-01'),
                800,
            ],
            '3日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-03'),
                800,
            ],
            '5日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-05'),
                800,
            ],
            '6日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-06'),
                1000,
            ],
            '20日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-20'),
                1000,
            ],
        ];
    }

    public static function 28日から30日に買い物すると2割引になる用データ() {
        return [
            '27日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-27'),
                1000,
            ],
            '28日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-28'),
                800,
            ],
            '29日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-29'),
                800,
            ],
            '30日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-30'),
                800,
            ],
            '31日に購入' => [
                \DateTimeImmutable::createFromFormat('Y-m-d', '2023-01-31'),
                1000,
            ],
        ];
    }
}

「ソフトウェアテスト技法練習帳」40本ノック(1)/RPGゲーム

きっかけ

現在テストを書く業務がないため、練習・実践できるものを探していた矢先
こちらを見つけ、実際にプログラムを書いてみようと思いました。
全てを記事にするのは大変なので、いくつかピックアップした問題について記事を書けたらと思います。
※コードは全てGithubにあげる予定です。
指摘・アドバイス大歓迎です!!!

問題1:勇者に装備できる武器の組み合わせ

仕様

・武器には「片手持ち」と「両手持ち」の武器がある
・勇者は常に盾を装備している
・盾は「片手持ち」である
・盾は1つまで装備できる
・盾を装備している状態で「両手持ち」の武器を装備しようとすると「装備できません」と出力する

武器 攻撃力 タイプ 強化
けやきのぼう 5 片手持ち 不可能
ニンジンの短剣 20 片手持ち 不可能
きこりの大斧 25 両手持ち 不可能
龍の槍 50 両手持ち 可能
草薙の剣 90 片手持ち 可能

テストコード

rpgTest.php

<?php

use App\Rpg\Hero;
use App\Rpg\Weapons\LumberjackAxe;
use App\Rpg\Weapons\Shield;
use App\Rpg\Weapons\ZelkovaRod;
use PHPUnit\Framework\TestCase;

class rpgTest extends TestCase{

    protected function setUp() :void {
        
    }

    /**
     * @test
     * @dataProvider 盾を装備している時の装備可能テスト用データ
     */
    public function 盾を装備している時の装備可能テスト(
        Array $weapons,
        Shield $shield,
        String $expectedMessage,
    ) {
        $message = '';

        try {
            new Hero(
                $weapons,
                $shield
            );
        }catch(\Exception $e) {
            $message = $e->getMessage();
        }

        $this->assertEquals($expectedMessage, $message);
    }

    public static function 盾を装備している時の装備可能テスト用データ() {
        return [
            '武器を装備しなくても良い' => [
                [],
                new Shield(),
                '',
            ],
            '片手持ち武器は装備することができる' => [
                [new ZelkovaRod(enhanced: false),],
                new Shield(),
                '',
            ],
            '両手持ちは装備することができない' => [
                [new LumberjackAxe(enhanced: false),],
                new Shield(),
                '装備できません',
            ],
            '武器でないものは装備できない' => [
                [new Shield(),],
                new Shield(),
                '武器ではないものを装備しようとしています。',
            ],
        ];
    }
}

武器オブジェクトにはWeaponインターフェースを実装させることで
勇者オブジェクトに武器の配列を渡した時に、内部でバリデーションができるようにしています。

勇者クラス

Hero.php

<?php
namespace App\Rpg;

use App\Rpg\Weapons\Shield;

class Hero {

    const handNum = 2;

    private Array $weapons;

    private ?Shield $shield;

    public function __construct(
        Array $weapons,
        ?Shield $shield,
    ) {
        $useHandNum = 0;

        foreach($weapons as $weapon) {
            $interfaces = class_implements($weapon);
            $include = false;
            foreach ($interfaces as $key => $value) {
                if (str_contains($value, 'WeaponInterface')) {
                    $include = true;
                    break;
                }
            }
            if (!$include) {
                throw new \Exception('武器ではないものを装備しようとしています。');
            }

            $useHandNum+=$weapon->getHandedType()->value;
        }

        if (!is_null($shield)) {
            $useHandNum++;
        }

        if (self::handNum < $useHandNum) {
            throw new \Exception('装備できません');
        }

        $this->weapons = $weapons;
    }
}

答え合わせ

上のテストコードはすでに修正済みで
実際には全ての武器オブジェクトの数だけテストデータを渡していました。
同値分割法的には出力結果が異なるグループごとに1つのテストデータで十分なため
解答ではテストデータは2つで十分でした。
また、解答とは別に個人的に
・何も装備しなかった場合
・盾を武器として装備させようとした場合
のテストデータを書いていました。

問題2:武器の強化可能判定

仕様

・武器には強化できるものとできない武器がある
・強化は一度しかできない
・強化可能武器を強化すると攻撃力が10増え「武器の攻撃力が上がりました」と出力する
・強化不可能な武器を強化しようとすると「武器の強化に失敗しました」と出力する
・強化済みの武器を強化しようとすると「これ以上この武器の強化はできません」と出力する

テストコード

rpgTest.php

<?php

use App\Rpg\Weapons\DragonSpear;
use App\Rpg\Weapons\Interface\WeaponInterface;
use App\Rpg\Weapons\ZelkovaRod;
use PHPUnit\Framework\TestCase;

class rpgTest extends TestCase{

    protected function setUp() :void {
        
    }

    /**
     * @test
     * @dataProvider 強化可能テスト用データ
     */
    public function 強化可能テスト(
        WeaponInterface $weapon,
        String $expectedMessage,
        int $expectedAttack,
    ) {
        $message = null;
        $attack = $weapon->getAttackPower();
        try {
            $enhancedWeapon = $weapon->enhance();
            $message = '武器の攻撃力が上がりました';
            $attack = $enhancedWeapon->getAttackPower();
        } catch(\Exception $e) {
            $message = $e->getMessage();
        }

        $this->assertEquals($expectedMessage, $message);
        $this->assertEquals($expectedAttack, $attack);
    }

    public static function 強化可能テスト用データ() {
        return [
            '強化可能な武器を強化する' => [
                new DragonSpear(enhanced: false),
                '武器の攻撃力が上がりました',
                DragonSpear::BaseAttack+10,
            ],
            '強化済みの武器を強化する' => [
                new DragonSpear(enhanced: true),
                'これ以上この武器の強化はできません',
                DragonSpear::BaseAttack+10,
            ],
            '強化不可能な武器を強化する' => [
                new ZelkovaRod(enhanced: false),
                '武器の強化に失敗しました',
                ZelkovaRod::BaseAttack,
            ],
        ];
    }
}

答え合わせ

この問題については解答と同じ数のテストパターンを網羅できていました。

pt-query-digestによるSQLパフォーマンスチューニング

きっかけ

「達人が教えるWebパフォーマンスチューニング」を読んでSQLのパフォーマンスチューニングを実践してみました。

対象の処理(仕様)

商品CSVアップロード機能
・商品を1000件登録する。
・商品登録時にそれぞれ「カテゴリ」4つ「タグ」3つ「商品画像」2つを同時に登録する。
・商品ごとに設定する「商品コード」はユニークで他商品と重複しない。

処理時間の計測

まず基準になる改善前の実行時間を計測します。
スロークエリログをそのまま確認することもできますが
pt-query-digestというライブラリを使うことでよりログが見やすくなります。
その前に、MySQLにスロークエリログを出力するよう設定をします。

// my.cnf
[mysqld]
slow_query_log = 1 (スロークエリログ出力の有無)  
slow_query_log_file = /var/log/mysql/mysql-slow.log(ログの出力先)  
long_query_time = 0(指定した処理時間以上かかったクエリのみ出力する)

long_query_time=0にすることで全てのクエリのログを出力するようにしています。
サーバ(コンテナ)を再起動させて設定を反映してください。

Percona Toolkitの導入

apt install percona-toolkit

インストール後、

pt-query-digest [スロークエリログのパス]

でスロークエリログの解析結果が表示されますが
出力設定を行ってから全てのクエリログが記録されているため、計測したいクエリが見つけにくい場合があります。
なので私は計測したい処理の直前にログファイルを一度削除し
対象の処理のみを実行した後、クエリログをpt-query-digestで分析しています。

スロークエリログの解析

上の画像は実際に出力された分析結果ですが
・Response time:実行時間の合計と全体に占める割合
・Calls:実行された回数
・R/Call:1回あたりの実行時間
・V/M:標準偏差
・Item:クエリのサマリー
を表しています。

またクエリごとに
・読み込んだレコード数(Rows examine)
・取得したレコード数(Rows sent)
がわかるため、インデックスを使用する指標にもなります。

SQLのみを速くできても代わりにプログラム側が遅くなってしまっては意味がないため、対象の処理の開始と終了時のマイクロ時刻の差を取得して処理全体の時間を計測しています。

クエリの改善

上記の結果を参考にして以下の改善を行いました。
・「カテゴリ」「タグ」「画像」のINSERT処理をまとめて行う。
・インデックスを貼る

「カテゴリ」「タグ」「画像」のINSERT処理をまとめて行う

分析結果の1位のクエリは商品カテゴリテーブルへのINSERT処理でした。
このクエリに着目すると、商品1つの登録に4回のINSERTクエリが発行されているため
1回のBULK INSERTにすることで全体4000回の呼び出しを1000回に削減しました。
同様に「タグ」「画像」についても呼び出し回数を1000件に削減しました。

インデックスを貼る

上記の分析結果後
商品が持つ「商品コード」項目は重複してはいけない
という仕様が追加されたため、重複チェックのためのクエリを追加しましたが
取得したレコードに対して読み込んだレコード数が多すぎたため
インデックスを貼ることで時間を削減しました。

プログラムの改善

全体の処理時間を短くするため、プログラム側の改善も行いました。
・ORMを使用せず、クエリビルダもしくはRawSQLを使用する。
・ORMのキャッシュ機能をプログラム側で代替する。

ORMを使用せず、クエリビルダもしくはRawSQLを使用する

確認できていませんが、世間的にORMはRawSQLよりも比較的遅いという話を聞きますし
自分でどのようなSQLを発行しているのか把握したいので、ORMを使用せずRawSQLでクエリを作成しました。

ORMのキャッシュ機能をプログラム側で代替する

今回改善対象のCMSで使われていたORMは過去のクエリを元に
DBにアクセスせずキャッシュから結果を返す処理を自動で行なっていたようで
この機能をプログラム側に配列を持たせて再現することでクエリの回数を減らしました。

まとめ

pt-query-digestを使用することで各クエリの性能を数値で取得できるようになったため
どのクエリを改善すべきかがわかり、とてもチューニングに有用なツールでした。
インデックスによるチューニングの実践は内容が深くなると思うので
別の記事でできたらと思います。

複数Transactionでの同一レコードEntityの更新処理について

きっかけ

「セキュア・バイ・デザイン」の第7章で複数のスレッドでEntityが共有された時に起こりうるデータ不整合について書かれており
正直これを読んで不整合の対策として挙げられていた「エンティティ・スナップショット」はあまり理解できず、代わりに
複数Transactionでほぼ同時に特定のレコードに対してEntityを通して更新処理を行った場合の挙動がどうなるのか気になり調べてみました。

前提

今回、例として50万円の残高がある銀行口座に対してほぼ同時に
1. 口座の持ち主が30万円引き出す。
2. 銀行が40万円の自動振替を行う
を行うとします。

口座クラス

Account.php

<?php
class Account {
    
    private ?Int $id;

    private Balance $balance;

    public function __construct(
        ?Int $id,
        Balance $balance,
    ){
        $this->id = $id;
        $this->balance = $balance;
    }

    public function withdraw(Money $money): Account{
        if (!$this->balance->moreThan($money)) {
            throw new \Exception('残高以上に引き出すことはできない。');
        }
        $balance = $this->balance->subtract($money);

        return new Account(
            $this->id,
            $balance,
        );
    }
}

残高クラス

Balance.php

<?php
class Balance {

    private Int $value;

    public function __construct(
        Int $value,
    ){
        if ($value < 0) {
            throw new \Exception('残高金額は0円以上でなければならない。');
        }
        $this->value = $value;
    }

    public function moreThan(Money $money): bool {
        return $this->value < $money->getValue();
    }

    public function subtract(Money $money): Balance {
        return new Balance(
            $this->value - $money->getValue()
        );
    }

    private function getValue():Int {
        return $this->value;
    }
}

金額クラス

Money.php

<?php
class Money {

    private Int $value;

    public function __construct(
        Int $value,
    ){
        if ($value < 0) {
            throw new \Exception('金額は0円以上でなければならない。');
        }
        $this->value = $value;
    }

    public function getValue():Int {
        return $this->value;
    }
}

口座の持ち主が30万円引き出す(疑似コード)

AccountController.php

<?php
// LaravelのControllerの処理を記述しています。

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class AccountController extends Controller {

    public function withdraw(
        Request $request,
        AccountServiceInterface $accountService,
    ) {
        // ログイン情報からユーザを取得
        $user = Auth::user();
        // 残高50万円で取得
        $account = $accountService->findByUser($user->id);
        if (is_null($account)) {
            session()->flash('error_message','ユーザの口座が存在しない。');
            return back();
        }

        $withdrawAmount = $request->input('amount');
        $money = new Money($withdrawAmount);
        // 残高から30万円引き出し、保存
        $accountService->withdraw($account, $money);

        session()->flash('success_message','引き出し成功');
        return redirect()->route('account.success');
    }
}

銀行が40万円の自動振替を行う(疑似コード)

BankController.php

<?php

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class BankController extends Controller {

    public function transfer(
        Int $fromAccountId,
        Int $toAccountId,
        Request $request,
        AccountServiceInterface $accountService,
    ) {
        // 残高50万円で取得
        $fromAccount = $accountService->find($fromAccountId);
        if (is_null($fromAccount)) {
            session()->flash('error_message','送り元の口座が存在しない。');
            return back();
        }
        $toAccount = $accountService->find($toAccountId);
        if (is_null($toAccount)) {
            session()->flash('error_message','送り先の口座が存在しない。');
            return back();
        }

        $transferAmount = $request->input('amount');
        $money = new Money($transferAmount);
        // 残高から40万円振替を行い、保存。
        $accountService->transfer($fromAccount, $toAccount, $money);

        session()->flash('success_message','振替成功');
        return redirect()->route('account.success');
    }
}

問題点

どちらの処理も1度DBから残高50万円のデータをEntityに格納しているため
1.30万円を引き出すためにEntityに格納(残高50万円)
2.40万円を振替するためにEntityに格納(残高50万円)
3.引き出し処理が完了し、残高が20万円で保存される。
4.残高20万円-40万円=−20万円でエラーが起きてロールバックされる?
→本来は振替処理が完了し、残高が10万で保存されてしまう!!!(ロストアップデート)

※DBの想定としてはMySQLでデフォルトのRepeatable Readです。

候補1:排他ロックを使う

・該当のレコードに行ロックがかかり、操作1のトランザクション中は他のトランザクションは同じレコードを参照できず待機状態になるため不整合が起きない

SELECT * from accounts where id=1 for update;

候補2:SQLのUPDATE文をデクリメントするように記述する

この方法はTeratailの質問に回答していただいた方の方法なのですが
・UPDATE文を

UPDATE accounts set balance = 200000 where id = 1;

ではなく

UPDATE accounts set balance = balance - 300000 where id = 1;

という方法です。
これはロックを使用しないため、使いやすくて良いかもと思ったのですが
今回はRepeatable Readであるため
トランザクション中は他のトランザクションの更新情報は反映されません。
(ファジーリード)
よってこれは解決策にはなりませんでした。

まとめ:明示的ロックを理解して使いましょう

現在時刻が関わるユニットテストを考える(Laravel)

きっかけ

単体テストの考え方/使い方」の第11章「単体テストのアンチ・パターン」で取り上げられていて参考になったので、Laravelで自分なりに実装してみました。

TL;DR

・現在時刻は引数で渡す(私の判断)
・現在時刻を取得するためのDateTimeServiceクラスを作成する。
・DateTimeServiceクラスはインターフェースに依存させて、Production/Test環境でDIするServiceクラスを分ける。

フォルダ構成

フォルダ構成はLaravelプロジェクト作成時の構成とほとんど変わりません。
※記事に必要なフォルダのみ記載しています。
違う箇所は
・DDDを採用しているため、packages/以下にドメイン・インフラ・サービスをまとめている
のみだと思います。

フォルダ構成

.
├── app
│   ├── Http
│   │   ├── Controllers
│   ├── Providers
│   │   ├── AppServiceProvider.php
│   │   └── ServiceProviders
│   │       ├── DevelopServiceProvider.php
│   │       ├── ProductionServiceProvider.php
│   │       ├── Provider.php
│   │       └── TestServiceProvider.php
├── packages
│   ├── Basic
│   │   ├── DateTime
│   │   │   └── DateTimeInterface.php
│   └── Main
│       ├── Domain
│       │   └── Models
│       ├── Infrastructure
│       │   ├── Eloquent
│       │   │   ├── Factory
│       │   │   └── Repository
│       │   └── Test
│       │       └── Repository
│       └── Service
│           ├── DateTimeService.php
│           ├── Test
│           │   └── DateTimeService.php
├── tests

現在時刻は引数で渡す

修正前はEntityのメソッド内で現在時刻を取得していたため、テストを実行する日時で結果が変わってしまっていたのですが
引数を渡すことで同じ引数に対して、毎回同じ結果を返すことができるようになりました。(Repetable)
こちらの記事では組み込みクラスの動作を変更するような方法もありました。

現在時刻を取得するためのDateTimeServiceクラスを作成する。

サービス層でProduction用とTest用に分けてDateTimeServiceクラスを作成します。
※私の場合はテストごとに取得したい日時が違うので、ユースケースごとにメソッドを作成しました。

Production

<?php
namespace Main\Service;

use Basic\DateTime\DateTimeInterface;

class DateTimeService implements DateTimeInterface {

    public function usecase1(): \DateTimeImmutable {
        return new \DateTimeImmutable();
    }

    public function usecase2(): \DateTimeImmutable {
        return new \DateTimeImmutable();
    }

}

Test

<?php
namespace Main\Service\Test;

use Basic\DateTime\DateTimeInterface;

class DateTimeService implements DateTimeInterface {
    public function usecase1(): \DateTimeImmutable {
        return \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2000-01-01 10:00:00');
    }

    public function usecase2(): \DateTimeImmutable {
        return \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2000-01-10 10:00:00');
    }
}

DateTimeServiceクラスはインターフェースに依存させて、Production/Test環境でDIするServiceクラスを分ける。

システムの環境に応じてDIの設定を分岐させます。

AppServiceProvider

<?php

namespace App\Providers;

use App\Providers\ServiceProviders\DevelopServiceProvider;
use App\Providers\ServiceProviders\ProductionServiceProvider;
use App\Providers\ServiceProviders\TestServiceProvider;
use Illuminate\Support\ServiceProvider;
use Illuminate\Pagination\Paginator;

class AppServiceProvider extends ServiceProvider
{
    public function register()
    {
        $provider = $this->provider();
        $provider->register();
    }

    public function boot()
    {
        $provider = $this->provider();
        $provider->boot();
        Paginator::defaultView('vendor.pagination.bootstrap-4');
    }

    private function provider()
    {
        $env = config("app.env");
        switch ($env) {
            case "local":
                return new DevelopServiceProvider($this->app);
            case "production":
                return new ProductionServiceProvider($this->app);
            case "testing":
                return new TestServiceProvider($this->app);
            default:
                throw new \OutOfBoundsException();
        }
    }
}

ProductionServiceProvider

<?php
namespace App\Providers\ServiceProviders;

use Illuminate\Contracts\Foundation\Application;
use Basic\DateTime\DateTimeInterface;
use Main\Service\DateTimeService;

class ProductionServiceProvider implements Provider {

    private $app;

    public function __construct(Application $app) {
        $this->app = $app;
    }

    public function register() {
        $this->app->bind(DateTimeInterface::class, DateTimeService::class);        
    }

    public function boot() {

    }
}

まとめ

これで
Entityのメソッドの引数にサービスクラスから取得したDateTimeImmutableを渡すことで
・Production環境では現在日時
・Test環境では定数の日時
を取得するようにできたため、ユニットテストの再現性を実現できたかなと思います。

About

yuk (@yuk_3011)

4月1日をもって前職のWebエンジニア職を退職しました。
転職先を決めずに退職したため、無職になります。
なので求職活動と並行して、ブログを書いてみようと思います。
in Fukuoka

興味のあること

・DDD,TDD
・テスト
・Flutter

スキルセット

言語・ライブラリ・フレームワーク

PHP: FuelPHP/Symfony/Laravel
・Kotlin: Coroutine,Realm
・Swift: SwiftUI

DB

MySQL

クラウド(Saas,Paas)

AWS(EC2,S3,CloudFront)
・Stripe

その他

・Git(Gitlab)
・Docker