「ソフトウェアテスト技法練習帳」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,
            ],
        ];
    }
}