単体テストは「壊れない変更」を可能にする最強の保険です。
本記事では、販売管理アプリ(商品・顧客・売上の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 へGET | 403 Forbidden | 権限制御 |
AUTH-004 | 権限UI | 管理者ログイン | メニュー描画 | 管理メニューが表示 | UIロジック |
AUTH-005 | 連続失敗 | ユーザ存在 | 同一IPで5回失敗 | レートリミット/ロック | セキュリティ |
2) 商品検索
ID | 対象 | 前提 | 入力/操作 | 期待結果 | 備考 |
---|
PROD-001 | キーワード | 「りんご」「みかん」登録済 | 「ん」 | 両方ヒット | 部分一致 |
PROD-002 | 価格帯 | 100, 1000の商品あり | 最小100 最大500 | 100のみヒット | 範囲 |
PROD-003 | 0件 | なし | 「zzz」 | 0件 | 空結果 |
PROD-004 | ページング | 25件登録 | perPage=10 page=3 | 5件取得 | 境界 |
3) 購入(売上作成)
仕様:amount = product.price × quantity
。数量は1以上の整数。価格はサーバ側で再計算し、クライアント値を信用しない。
ID | 対象 | 前提 | 入力/操作 | 期待結果 | 備考 |
---|
ORDER-001 | 売上作成 | 商品:1000円/顧客A | product=1 customer=1 qty=3 | amount=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=120 | DB反映 | 正常 |
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×1 | 10/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/01 | 9/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 | 参照ID | – | product=不存在 | 404 or 外部キー違反 | 参照整合 |
8) 画面表示ロジック
ID | 対象 | 前提 | 入力/操作 | 期待結果 | 備考 |
---|
UI-001 | メニュー | 一般ユーザ | ログイン後 | 購入メニューのみ/管理非表示 | 権限表示 |
UI-002 | メニュー | 管理者 | ログイン後 | 商品/顧客/売上集計表示 | 権限表示 |
UI-003 | 検索0件 | – | キーワード=zzz | 「該当なし」表示 | UX |
UI-004 | フィールドエラー | – | price=-1 | price欄にエラー表示 | 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雛形をベースに、プロジェクト事情(要件・規模・工数)に合わせてテスト密度を調整してください。テストを“書ける設計”(サービス層中心)にしておくと、仕様変更にも強く、長期運用で真価を発揮します。