「ソフトウェアテスト技法練習帳」40本ノック(3)/1杯目のビールの価格

この記事について

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

仕様

・通常1杯490円で提供される
・16:00から17:59まではハッピーアワーのため、1杯290円で提供される
・クーポンを使うと利用時間に関わらず、100円で提供される
・ハッピーアワーでもクーポンは利用可能
・最も安い価格で提供される
※今回は問題の仕様とは別に一般的なクーポンの仕様も加えています
・クーポンの効果は店舗側が自由に設定できる
・クーポンは複数種類使用可能

テストコード

BeerTest.php

<?php

use App\Beer\Domain\Coupon\AvailablePeriod;
use App\Beer\Domain\Coupon\CouponConditions;
use App\Beer\Domain\Coupons;
use App\Beer\Domain\Customer;
use App\Beer\Domain\DeliveryFee;
use App\Beer\DomainService\OrderDomainService;
use App\Beer\Domain\Coupon\CouponInterface;
use App\Beer\Domain\Coupon\CouponSpecifications;
use App\Beer\Domain\Order;
use App\Beer\Domain\OrderDetail;
use App\Beer\Domain\OrderDetails;
use App\Beer\Domain\Product;
use App\Beer\Domain\Coupon\Coupon;
use App\Beer\Domain\CouponCondition\SpecificProductExistCondition;
use App\Beer\Domain\CouponSpecification\SpecificProductDiscountSpecification;
use App\Beer\DomainService\OrderDetailDomainService;
use PHPUnit\Framework\TestCase;

class BeerTest extends TestCase{

    protected function setUp() :void {
        
    }

    /**
     * @test
     * @dataProvider クーポンを使うまたはハッピーアワーだと割引になる用データ
     */
    public function クーポンを使うまたはハッピーアワーだと割引になる(
        \DateTimeImmutable $orderAt,
        bool $useCoupon,
        int $expectedPrice,
    ) {
        $orderDomainService = new OrderDomainService();
        $orderDetailDomainService = new OrderDetailDomainService();

        $beer = new Product('Beer',490,'beer-S',290);
        $happyHourPrice = $orderDetailDomainService->getHappyHourPrice($beer, $orderAt);
        $orderDetail = new OrderDetail(null,$happyHourPrice,$beer);
        $orderDetails = new OrderDetails([$orderDetail]);
        $customer = new Customer('Bob',new \DateTimeImmutable());

        $order = new Order(
            null,
            $orderDetails->getTotalPrice(),
            $customer,
            $orderDetails,
            new Coupons([]),
            new DeliveryFee(0),
        );

        if ($useCoupon) {
            $beerCoupon = $this->createBeerCoupon();
            if (!$orderDomainService->couponIsApplicable($order, $beerCoupon)) {
                throw new \Exception('Error');
            }

            $order = $orderDomainService->applyCoupon($order, $beerCoupon);
        }

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

    private function createBeerCoupon():CouponInterface {
        $beerCondition = new SpecificProductExistCondition("beer-S");
        $couponConditions = new CouponConditions([$beerCondition]);

        $beerSpecification = new SpecificProductDiscountSpecification('beer-S', 100);
        $couponSpecifications = new CouponSpecifications([$beerSpecification]);

        $beerCoupon = new Coupon(
            1,
            'beer',
            $couponConditions,
            $couponSpecifications,
            null,
            null,
            new AvailablePeriod(
                \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2000-01-01 00:00:00'),
                \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2100-01-31 23:59:59'),
            )
        );

        return $beerCoupon;
    }

    public static function クーポンを使うまたはハッピーアワーだと割引になる用データ() {
        return [
            'ハッピーアワーではなくクーポンは使わない' => [
                \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', date('Y-m-d').' 20:00:00'),
                false,
                490
            ],
            'ハッピーアワーではなくクーポンを使用する' => [
                \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', date('Y-m-d').' 20:00:00'),
                true,
                100
            ],
            'ハッピーアワーかつクーポンは使わない' => [
                \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', date('Y-m-d').' 17:00:00'),
                false,
                290
            ],
            'ハッピーアワーかつクーポンを使用する' => [
                \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', date('Y-m-d').' 17:00:00'),
                true,
                100
            ],
        ];
    }
}

Couponクラス

Coupon.php

<?php
namespace App\Beer\Domain\Coupon;

use App\Beer\Domain\Order;

class Coupon implements CouponInterface {

    private ?int $id;

    private String $couponCode;

    private CouponConditions $conditions;

    private CouponSpecifications $specifications;

    private ?int $useLimit;

    private ?int $useLimitByCustomer;

    private AvailablePeriod $availablePeriod;

    public function __construct(
        ?int $id,
        String $couponCode,
        CouponConditions $conditions,
        CouponSpecifications $specifications,
        ?int $useLimit,
        ?int $useLimitByCustomer,
        AvailablePeriod $availablePeriod,
    ) {
        $this->id = $id;
        $this->couponCode = $couponCode;
        $this->conditions = $conditions;
        $this->specifications = $specifications;
        $this->useLimit = $useLimit;
        $this->useLimitByCustomer = $useLimitByCustomer;
        $this->availablePeriod = $availablePeriod;
    }

    public function getId(): ?int {
        return $this->id;
    }

    public function isApplicable(Order $order): bool {
        return $this->conditions->appliable($order);
    }

    public function apply(Order $order):Order {
        $appliedOrder = $this->specifications->apply($order);

        return $appliedOrder;
    }

    public function getUseLimit(): ?int
    {
        return $this->useLimit;
    }

    public function getUseLimitByCustomer(): ?int
    {
        return $this->useLimitByCustomer;
    }

    public function isAvailable(): bool
    {
        return $this->availablePeriod->isAvailable(new \DateTimeImmutable());
    }
}

OrderDomainServiceクラス

OrderDomainService.php

<?php
namespace App\Beer\DomainService;

use App\Beer\Domain\Coupons;
use App\Beer\Domain\Order;
use App\Beer\Domain\Coupon\CouponInterface;

class OrderDomainService {

    public function __construct(
        
    ) {
        
    }

    public function couponIsApplicable(Order $order, CouponInterface $coupon):bool {
        if (!$coupon->isAvailable()) {
            return false;
        }

        // Couponの使用回数が決まっている場合
        if (!is_null($coupon->getUseLimit())) {
            return false;
        }

        // ユーザごとにCouponの使用回数が決まっている場合
        if (!is_null($coupon->getUseLimitByCustomer())) {
            return false;
        }
    
        return $coupon->isApplicable($order);
    }

    public function applyCoupon(Order $order, CouponInterface $coupon): Order {
        $order->addCoupon($coupon);

        $appliedOrder = $this->calculateCouponsOrder($order);

        return $appliedOrder;
    }

    private function calculateCouponsOrder(Order $order):Order {
        $orders = [];
        $coupons = $order->getCoupons()->asArray();

        $combinations = $this->permutation($coupons, count($coupons));
        // クーポンの適用順で最も最小金額になる組み合わせを探す
        foreach ($combinations as $coupons) {
            $couponCollection = new Coupons($coupons);
            $appliedOrder = $couponCollection->apply($order);
            $orders[] = $appliedOrder;
        }
        // 最小注文金額のOrderを返す
        $minTotalPriceOrder = $this->getMinTotalPriceOrder($orders);

        return $minTotalPriceOrder;
    }

    private function getMinTotalPriceOrder(Array $orders):Order {
        $totalPrices = [];
        foreach($orders as $order) {
            $totalPrices[] = $order->getTotalPrice();
        }

        $minTotalPriceIndex = array_keys($totalPrices, min($totalPrices))[0];

        return $orders[$minTotalPriceIndex];
    }

    // クーポンの順列を返す
    private function permutation(array $arr, int $r): ?array {
        $n = count($arr);
        $result = []; // 最終的に二次元配列にして返す

        // nPr の条件に一致していなければ null を返す
        if($n < 1 || $n < $r){ return null; }

        if($r === 1){
            foreach($arr as $item){
                $result[] = [$item];
            }
        }

        if($r > 1){
            // $item が先頭になる順列を算出する
            foreach($arr as $key => $item){
                // $item を除いた配列を作成
                $newArr = array_filter($arr, function($k) use($key) {
                    return $k !== $key;
                }, ARRAY_FILTER_USE_KEY);
                // 再帰処理 二次元配列が返ってくる
                $recursion = self::permutation($newArr, $r - 1);
                foreach($recursion as $one_set){
                    array_unshift($one_set, $item);
                    $result[] = $one_set;
                }
            }
        }

        return $result;
    }
}

ポイント

・注文へのクーポンの適用はOrderDomainServiceで行う
・Couponの適用可能条件と適用効果を分けて実装する
・クーポンの適用順によって金額が異なるため、順列を計算して最安値を探す

注文へのクーポンの適用はOrderDomainServiceで行う

Orderクラスにクーポンの適用処理関数を書くと
OrderオブジェクトがOrderオブジェクト(自分自身)を返すようになり動作が不自然に感じたためOrderDomainServiceに処理を書いています。

Couponの適用可能条件と適用効果を分けて実装する

今回の問題は居酒屋のビールクーポンについてですが
一般的にECサイトのクーポンなどでは、店舗がある程度自由にクーポンの内容を決めることができるはずです。
その内容はさまざまで
・一定金額以上購入すると注文全体への割引
・注文に特定の商品が含まれていれば特定の商品が500円になる
・注文に特定のカテゴリ商品が含まれていれば特定のカテゴリ商品が2割引
・送料無料
などがあります。

これらの適用可能条件と適用効果の組み合わせはとても多いため
それぞれInterfaceを分けてCouponクラスに持たせることで、機能が整理されて分かりやすくなりました。

クーポンの適用順によって金額が異なるため、順列を計算して最安値を探す

実装をしているとクーポンの適用順で注文の合計金額が変わってしまうことがわかりました。
なので、順列を計算してクーポン配列の組み合わせを全て試して最小金額の組み合わせを採用するようにしています。

終わりに

問題自体は複雑ではなかったですが、クーポンのモデリングがとても難しく
非常に勉強になりました。

参考資料

ドメイン駆動設計で保守性をあげたリニューアル事例 〜 ショッピングクーポンの設計紹介