販売管理アプリを題材に学ぶ:単体テスト設計とサンプルコード

単体テストは「壊れない変更」を可能にする最強の保険です。
本記事では、販売管理アプリ(商品・顧客・売上の3テーブル、ユーザと管理者の2ロール)を題材に、実務ですぐ流用できるテストケース表Laravel/PHPUnitのテストコード雛形をまとめて紹介します。
最低限押さえるべき「正常系・異常系・境界値」を軸に、認証/権限・検索・購入(売上作成)・CRUD・集計まで一気通貫で設計。テスト対象をサービス層へ寄せ、コントローラのテストは最小限にする方針で、保守性と速度を両立します。


想定アプリの仕様(ざっくり把握)

  • テーブル
    • 商品(id, name, price
    • 顧客(id, name
    • 売上(id, product_id, customer_id, sold_at, quantity, amount
  • 画面/機能
    • 一般ユーザ:ログイン → 商品検索 → 購入(売上作成)
    • 管理者:商品登録/削除/編集、顧客登録/削除/編集、期間別売上集計
  • 設計ポリシー
    • サービス層中心にビジネスロジックを配置(例:OrderService, SalesReportService
    • コントローラは薄く(入力バリデ&結果返却に集中)
    • 単体テストは正常+異常+境界を最小セットで

テストケース表(そのままExcelに貼れる粒度)

表の列は ID / 対象 / 前提 / 入力(操作)/ 期待結果 / 備考 を採用。小規模〜中規模で十分使えます。

1) 認証・権限

ID対象前提入力/操作期待結果備考
AUTH-001ログインユーザ存在(u@example.com/secret)正しい資格情報成功しダッシュボードへ正常
AUTH-002ログイン同上パスワード誤り失敗、エラーメッセージ異常
AUTH-003権限一般ユーザ/admin/products へGET403 Forbidden権限制御
AUTH-004権限UI管理者ログインメニュー描画管理メニューが表示UIロジック
AUTH-005連続失敗ユーザ存在同一IPで5回失敗レートリミット/ロックセキュリティ

2) 商品検索

ID対象前提入力/操作期待結果備考
PROD-001キーワード「りんご」「みかん」登録済「ん」両方ヒット部分一致
PROD-002価格帯100, 1000の商品あり最小100 最大500100のみヒット範囲
PROD-0030件なし「zzz」0件空結果
PROD-004ページング25件登録perPage=10 page=35件取得境界

3) 購入(売上作成)

仕様amount = product.price × quantity。数量は1以上の整数。価格はサーバ側で再計算し、クライアント値を信用しない。

ID対象前提入力/操作期待結果備考
ORDER-001売上作成商品:1000円/顧客Aproduct=1 customer=1 qty=3amount=3000で作成正常
ORDER-002数量境界同上qty=0バリデーションエラー境界
ORDER-003数量異常同上qty=-1バリデーションエラー異常
ORDER-004外部キー同上product=999外部キー不正→エラー参照整合
ORDER-005売上日境界同上sold_at=1970-01-01仕様に合わせ許容/エラールール合意
ORDER-006改ざん耐性同上クライアントでamount=1サーバ再計算で正値保存信頼境界

4) 商品CRUD(管理者)

ID対象前提入力/操作期待結果備考
PROD-CRUD-001登録name=桃 price=500成功正常
PROD-CRUD-002価格検証price=-10価格エラー下限
PROD-CRUD-003更新既存:リンゴ(100)price=120DB反映正常
PROD-CRUD-004削除売上参照なし削除成功物理/論理は要件次第
PROD-CRUD-005参照制約売上参照あり削除失敗 or 論理削除業務ルール
PROD-CRUD-006一意性同名許容?同名登録仕様に従い結果仕様決め

5) 顧客CRUD(管理者)

ID対象前提入力/操作期待結果備考
CUST-CRUD-001登録name=株式会社サンプル成功正常
CUST-CRUD-002必須検証name=空エラー必須
CUST-CRUD-003更新既存顧客名称変更成功正常
CUST-CRUD-004削除売上参照なし削除成功正常
CUST-CRUD-005参照制約売上参照あり削除失敗 or 論理削除業務ルール

6) 売上集計(期間ごと)

仕様提案:開始日・終了日を含む(inclusive)。アプリのタイムゾーン基準で日付判定。

ID対象前提入力/操作期待結果備考
AGG-001日別合計10/01:1000×2, 10/02:500×110/01〜10/02日別{01:2000, 02:500} 合計2500正常
AGG-002空期間売上0件1/01〜1/31合計0・日別空正常
AGG-003端点含む10/01に1件10/01〜10/01含まれる境界
AGG-004月跨ぎ9/30, 10/019/30〜10/01両日計上跨ぎ
AGG-005性能1万件同期間集計正・高速性能/丸め

7) 横断バリデーション

ID対象前提入力/操作期待結果備考
VAL-001価格下限price=0許容/禁止(仕様)要件次第
VAL-002価格上限price=999,999,999許容かエラー型/上限
VAL-003数量型qty=“2.5”/“abc”エラー整数
VAL-004参照IDproduct=不存在404 or 外部キー違反参照整合

8) 画面表示ロジック

ID対象前提入力/操作期待結果備考
UI-001メニュー一般ユーザログイン後購入メニューのみ/管理非表示権限表示
UI-002メニュー管理者ログイン後商品/顧客/売上集計表示権限表示
UI-003検索0件キーワード=zzz「該当なし」表示UX
UI-004フィールドエラーprice=-1price欄にエラー表示UX

テストの置き場所と層分け戦略

  • サービス層(ビジネスロジックの中心)
    • OrderService:売上作成(価格×数量の再計算、数量境界、参照整合)
    • SalesReportService:期間集計(端点含む/空期間/月跨ぎ)
  • コントローラ層
    • 入力バリデーションとレスポンスの最小確認(Featureテスト少数)
  • 権限/Gate/Policy
    • 一般ユーザ vs 管理者のAPI/画面アクセスの成否を分離テスト

PHPUnit:売上作成(OrderService)の単体テスト

// tests/Unit/OrderServiceTest.php
namespace Tests\Unit;

use Tests\TestCase;
use App\Models\Product;
use App\Models\Customer;
use App\Services\OrderService;
use Illuminate\Foundation\Testing\RefreshDatabase;

class OrderServiceTest extends TestCase
{
    use RefreshDatabase;

    public function test_create_sale_success()
    {
        $product = Product::factory()->create(['price' => 1000]);
        $customer = Customer::factory()->create();

        $svc = app(OrderService::class);
        $sale = $svc->createSale([
            'product_id' => $product->id,
            'customer_id' => $customer->id,
            'sold_at' => '2025-10-01',
            'quantity' => 3,
        ]);

        $this->assertDatabaseHas('sales', [
            'id' => $sale->id,
            'product_id' => $product->id,
            'customer_id' => $customer->id,
            'sold_at' => '2025-10-01',
            'quantity' => 3,
            'amount' => 3000, // 1000 * 3
        ]);
    }

    public function test_reject_zero_or_negative_quantity()
    {
        $product = Product::factory()->create(['price' => 1000]);
        $customer = Customer::factory()->create();
        $svc = app(OrderService::class);

        $this->expectException(\DomainException::class);
        $svc->createSale([
            'product_id' => $product->id,
            'customer_id' => $customer->id,
            'sold_at' => '2025-10-01',
            'quantity' => 0,
        ]);
    }

    public function test_server_recalculates_amount()
    {
        $product = Product::factory()->create(['price' => 1000]);
        $customer = Customer::factory()->create();
        $svc = app(OrderService::class);

        $sale = $svc->createSale([
            'product_id' => $product->id,
            'customer_id' => $customer->id,
            'sold_at' => '2025-10-01',
            'quantity' => 2,
            'amount' => 1, // 改ざん想定。無視されるべき
        ]);

        $this->assertEquals(2000, $sale->amount);
    }
}

OrderService の骨子

// app/Services/OrderService.php
namespace App\Services;

use App\Models\Product;
use App\Models\Customer;
use App\Models\Sale;
use DomainException;
use Illuminate\Support\Facades\DB;

class OrderService
{
    public function createSale(array $payload): Sale
    {
        $product  = Product::findOrFail($payload['product_id']);
        $customer = Customer::findOrFail($payload['customer_id']);
        $qty = (int)($payload['quantity'] ?? 0);
        if ($qty < 1) {
            throw new DomainException('Quantity must be >= 1');
        }

        return DB::transaction(function () use ($product, $customer, $qty, $payload) {
            $amount = $product->price * $qty;
            return Sale::create([
                'product_id' => $product->id,
                'customer_id'=> $customer->id,
                'sold_at'    => $payload['sold_at'] ?? now()->toDateString(),
                'quantity'   => $qty,
                'amount'     => $amount,
            ]);
        });
    }
}

PHPUnit:期間集計(SalesReportService)の単体テスト

// tests/Unit/SalesReportServiceTest.php
namespace Tests\Unit;

use Tests\TestCase;
use App\Models\Product;
use App\Models\Customer;
use App\Models\Sale;
use App\Services\SalesReportService;
use Illuminate\Foundation\Testing\RefreshDatabase;

class SalesReportServiceTest extends TestCase
{
    use RefreshDatabase;

    public function test_sum_by_period_inclusive()
    {
        $p = Product::factory()->create(['price' => 1000]);
        $c = Customer::factory()->create();

        Sale::factory()->create(['product_id'=>$p->id,'customer_id'=>$c->id,'sold_at'=>'2025-10-01','quantity'=>2,'amount'=>2000]);
        Sale::factory()->create(['product_id'=>$p->id,'customer_id'=>$c->id,'sold_at'=>'2025-10-02','quantity'=>1,'amount'=>1000]);

        $svc = app(SalesReportService::class);
        $r = $svc->sumByDate('2025-10-01','2025-10-02');

        $this->assertSame(3000, $r['total']);
        $this->assertSame(2000, $r['daily']['2025-10-01']);
        $this->assertSame(1000, $r['daily']['2025-10-02']);
    }

    public function test_sum_returns_zero_when_empty()
    {
        $svc = app(SalesReportService::class);
        $r = $svc->sumByDate('2025-01-01','2025-01-31');
        $this->assertSame(0, $r['total']);
        $this->assertEmpty($r['daily']);
    }
}

SalesReportService の骨子

// app/Services/SalesReportService.php
namespace App\Services;

use App\Models\Sale;

class SalesReportService
{
    public function sumByDate(string $from, string $to): array
    {
        $rows = Sale::query()
            ->whereDate('sold_at', '>=', $from)
            ->whereDate('sold_at', '<=', $to) // 端点含む
            ->selectRaw('DATE(sold_at) as d, SUM(amount) as sum')
            ->groupBy('d')
            ->orderBy('d')
            ->get();

        $daily = [];
        $total = 0;
        foreach ($rows as $r) {
            $daily[$r->d] = (int)$r->sum;
            $total += (int)$r->sum;
        }
        return ['total' => $total, 'daily' => $daily];
    }
}

Featureテスト最小例:商品登録のバリデーション

// tests/Feature/ProductValidationTest.php
namespace Tests\Feature;

use Tests\TestCase;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

class ProductValidationTest extends TestCase
{
    use RefreshDatabase;

    public function test_admin_can_create_product()
    {
        $admin = User::factory()->admin()->create();

        $res = $this->actingAs($admin)->post('/admin/products', [
            'name' => '桃',
            'price' => 500,
        ]);

        $res->assertRedirect();
        $this->assertDatabaseHas('products', ['name' => '桃', 'price' => 500]);
    }

    public function test_price_must_be_non_negative_integer()
    {
        $admin = User::factory()->admin()->create();

        $res = $this->actingAs($admin)->post('/admin/products', [
            'name' => '桃',
            'price' => -1,
        ]);

        $res->assertSessionHasErrors(['price']);
    }
}

Factory(テストデータ生成)の雛形

// database/factories/ProductFactory.php
use Illuminate\Database\Eloquent\Factories\Factory;

class ProductFactory extends Factory {
    public function definition(): array {
        return [
            'name' => $this->faker->word(),
            'price' => $this->faker->numberBetween(1, 5000),
        ];
    }
}
// database/factories/CustomerFactory.php
class CustomerFactory extends Factory {
    public function definition(): array {
        return ['name' => $this->faker->company()];
    }
}
// database/factories/SaleFactory.php
class SaleFactory extends Factory {
    public function definition(): array {
        return [
            'product_id' => \App\Models\Product::factory(),
            'customer_id'=> \App\Models\Customer::factory(),
            'sold_at'    => $this->faker->date(),
            'quantity'   => $this->faker->numberBetween(1, 10),
            'amount'     => 1000, // 実際はテスト内で上書き推奨
        ];
    }
}

運用Tips:テスト量と品質を両立するコツ

  • 最小セット:機能ごとに「正常1+異常2〜3+境界1」をまず担保
  • 厚めに張る箇所:認証・決済/計算・期間集計・参照制約(致命度が高い)
  • カバレッジ目安:まずは 70〜80%。100%至上主義はコスパ悪
  • 独立性RefreshDatabase と Factory で再現性を担保
  • 明文化:端点含む/含まない、TZ、丸め、論理/物理削除など境界仕様を先に合意
  • 表示×APIの両輪:UIの表示制御(見せない)+API/URL保護(入れない)を両方テスト

まとめ

販売管理アプリという身近な題材でも、単体テストは業務インパクトが大きい箇所に厚く、低リスクは最小限という原則で設計できます。
本記事のケース表とPHPUnit雛形をベースに、プロジェクト事情(要件・規模・工数)に合わせてテスト密度を調整してください。テストを“書ける設計”(サービス層中心)にしておくと、仕様変更にも強く、長期運用で真価を発揮します。

タイトルとURLをコピーしました