Feature 테스트
여러 컴포넌트를 통합하여 실제 사용 시나리오를 검증하는 Feature 테스트 가이드입니다.
개요
Feature 테스트는 HTTP 요청, 미들웨어, 컨트롤러, 데이터베이스 등 여러 레이어를 통합하여 전체 기능을 테스트합니다. Multi-SaaS Kit에서는 테넌트 격리, 패널 접근 제어, 보안 검증에 Feature 테스트가 필수입니다.
Feature 테스트 적용 범위
| 영역 | 필수 여부 | 설명 |
|---|---|---|
| 테넌트 격리 | 필수 | Cross-tenant 데이터 접근 방지 |
| 패널 접근 제어 | 필수 | Level 0~6 계층 권한별 패널 접근 |
| API 엔드포인트 | 권장 | HTTP 요청/응답 검증 |
| 인증 플로우 | 권장 | 로그인, 로그아웃, 토큰 |
| Filament 액션 | 선택 | UI 액션 검증 |
테스트 파일 구조
tests/
├── Feature/
│ ├── Core/
│ │ ├── TenantIsolationTest.php # 테넌트 데이터 격리
│ │ ├── PanelAccessTest.php # 패널 접근 제어
│ │ ├── EnsureUserLevelMiddlewareTest.php
│ │ └── Security/
│ │ └── CrossTenantSecurityTest.php # 보안 테스트
│ ├── Http/
│ │ ├── Auth/
│ │ │ ├── LoginTest.php
│ │ │ └── LogoutTest.php
│ │ └── Api/
│ │ ├── UserControllerTest.php
│ │ └── TenantControllerTest.php
│ ├── Filament/
│ │ ├── Guardian/
│ │ │ └── GuardianRelationshipActionsTest.php
│ │ └── Impersonate/
│ │ └── ImpersonateActionTest.php
│ └── Seeder/
│ └── CoreSeederTest.php
└── Unit/ # Unit 테스트 (별도 문서)
HTTP 테스트 기본
GET 요청 테스트
<?php
namespace Tests\Feature\Http\Api;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class UserControllerTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function authenticated_user_can_get_profile(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->getJson('/api/user/profile');
$response->assertOk()
->assertJson([
'data' => [
'id' => $user->id,
'email' => $user->email,
]
]);
}
#[Test]
public function unauthenticated_user_cannot_access_profile(): void
{
$response = $this->getJson('/api/user/profile');
$response->assertUnauthorized();
}
}
POST/PUT/DELETE 요청 테스트
#[Test]
public function admin_can_create_user(): void
{
$admin = User::factory()->create(['level' => 0]);
$response = $this->actingAs($admin)
->postJson('/api/users', [
'name' => 'New User',
'email' => 'new@example.com',
'level' => 6,
]);
$response->assertCreated()
->assertJsonStructure([
'data' => ['id', 'name', 'email', 'level']
]);
$this->assertDatabaseHas('users', [
'email' => 'new@example.com',
]);
}
#[Test]
public function user_can_update_own_profile(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->putJson("/api/users/{$user->id}", [
'name' => 'Updated Name',
]);
$response->assertOk();
$this->assertDatabaseHas('users', [
'id' => $user->id,
'name' => 'Updated Name',
]);
}
#[Test]
public function admin_can_delete_user(): void
{
$admin = User::factory()->create(['level' => 0]);
$user = User::factory()->create();
$response = $this->actingAs($admin)
->deleteJson("/api/users/{$user->id}");
$response->assertNoContent();
$this->assertSoftDeleted('users', ['id' => $user->id]);
}
테넌트 격리 테스트
기본 격리 테스트
<?php
namespace Tests\Feature\Core;
use App\Core\Base\Permission\Enums\UserLevel;
use App\Core\Base\Tenant\Scopes\TenantScope;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class TenantIsolationTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function tenant_user_can_only_see_own_tenant_data(): void
{
$tenant1 = Tenant::factory()->create();
$tenant2 = Tenant::factory()->create();
$user = User::factory()->create([
'level' => UserLevel::TENANT_ADMIN->value,
'tenant_id' => $tenant1->id,
]);
// 두 테넌트의 데이터 생성
TestModel::withoutGlobalScope(TenantScope::class)->insert([
['name' => 'Tenant1 Item', 'tenant_id' => $tenant1->id, 'created_at' => now(), 'updated_at' => now()],
['name' => 'Tenant2 Item', 'tenant_id' => $tenant2->id, 'created_at' => now(), 'updated_at' => now()],
]);
$this->actingAs($user);
session(['tenant_id' => $tenant1->id]);
$items = TestModel::all();
$this->assertCount(1, $items);
$this->assertEquals('Tenant1 Item', $items->first()->name);
}
#[Test]
public function platform_admin_can_see_all_tenant_data(): void
{
$tenant1 = Tenant::factory()->create();
$tenant2 = Tenant::factory()->create();
$platformAdmin = User::factory()->create([
'level' => UserLevel::PLATFORM_ADMIN->value,
]);
// 두 테넌트의 데이터 생성
TestModel::withoutGlobalScope(TenantScope::class)->insert([
['name' => 'Tenant1 Item', 'tenant_id' => $tenant1->id, 'created_at' => now(), 'updated_at' => now()],
['name' => 'Tenant2 Item', 'tenant_id' => $tenant2->id, 'created_at' => now(), 'updated_at' => now()],
]);
$this->actingAs($platformAdmin);
$items = TestModel::all();
$this->assertCount(2, $items);
}
#[Test]
public function new_records_automatically_get_tenant_id(): void
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'level' => UserLevel::TENANT_ADMIN->value,
'tenant_id' => $tenant->id,
]);
$this->actingAs($user);
session(['tenant_id' => $tenant->id]);
$item = TestModel::create(['name' => 'New Item']);
$this->assertEquals($tenant->id, $item->tenant_id);
}
}
Cross-Tenant 보안 테스트
<?php
namespace Tests\Feature\Core\Security;
use App\Core\Base\Permission\Enums\UserLevel;
use App\Core\Base\Tenant\Scopes\TenantScope;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class CrossTenantSecurityTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenantA;
private Tenant $tenantB;
private int $secretItemId;
protected function setUp(): void
{
parent::setUp();
$this->tenantA = Tenant::factory()->create(['name' => 'Tenant A']);
$this->tenantB = Tenant::factory()->create(['name' => 'Tenant B']);
// Tenant B의 비밀 데이터 생성
$item = SecurityTestModel::withoutGlobalScope(TenantScope::class)->create([
'name' => 'Tenant B Secret',
'secret_data' => 'CONFIDENTIAL: Tenant B data',
'tenant_id' => $this->tenantB->id,
]);
$this->secretItemId = $item->id;
}
#[Test]
public function tenant_admin_cannot_access_other_tenant_data_by_direct_id(): void
{
$attacker = User::factory()->create([
'level' => UserLevel::TENANT_ADMIN->value,
'tenant_id' => $this->tenantA->id,
]);
$this->actingAs($attacker);
session(['tenant_id' => $this->tenantA->id]);
// 직접 ID로 다른 테넌트 데이터 접근 시도
$result = SecurityTestModel::find($this->secretItemId);
$this->assertNull($result, 'Tenant A user should NOT access Tenant B data');
}
#[Test]
public function tenant_user_cannot_update_other_tenant_data(): void
{
$attacker = User::factory()->create([
'level' => UserLevel::TENANT_ADMIN->value,
'tenant_id' => $this->tenantA->id,
]);
$this->actingAs($attacker);
session(['tenant_id' => $this->tenantA->id]);
// 업데이트 시도
$affected = SecurityTestModel::where('id', $this->secretItemId)
->update(['secret_data' => 'HACKED']);
$this->assertEquals(0, $affected, 'Should not update other tenant data');
// 원본 데이터 확인
$original = SecurityTestModel::withoutGlobalScope(TenantScope::class)
->find($this->secretItemId);
$this->assertEquals('CONFIDENTIAL: Tenant B data', $original->secret_data);
}
#[Test]
public function tenant_user_cannot_delete_other_tenant_data(): void
{
$attacker = User::factory()->create([
'level' => UserLevel::TENANT_ADMIN->value,
'tenant_id' => $this->tenantA->id,
]);
$this->actingAs($attacker);
session(['tenant_id' => $this->tenantA->id]);
// 삭제 시도
$deleted = SecurityTestModel::where('id', $this->secretItemId)->delete();
$this->assertEquals(0, $deleted, 'Should not delete other tenant data');
// 데이터 존재 확인
$original = SecurityTestModel::withoutGlobalScope(TenantScope::class)
->find($this->secretItemId);
$this->assertNotNull($original);
}
#[Test]
#[DataProvider('nonAdminUserLevels')]
public function non_platform_admin_cannot_access_other_tenant_data(int $level): void
{
$user = User::factory()->create([
'level' => $level,
'tenant_id' => $this->tenantA->id,
]);
$this->actingAs($user);
session(['tenant_id' => $this->tenantA->id]);
$result = SecurityTestModel::find($this->secretItemId);
$this->assertNull($result, "Level {$level} should NOT access other tenant data");
}
public static function nonAdminUserLevels(): array
{
return [
'Tenant Admin (2)' => [UserLevel::TENANT_ADMIN->value],
'Organization Admin (3)' => [UserLevel::ORGANIZATION_ADMIN->value],
'Workspace Admin (4)' => [UserLevel::WORKSPACE_ADMIN->value],
'Group Leader (5)' => [UserLevel::GROUP_LEADER->value],
'Member (6)' => [UserLevel::MEMBER->value],
];
}
#[Test]
public function cannot_mass_assign_different_tenant_id(): void
{
$user = User::factory()->create([
'level' => UserLevel::TENANT_ADMIN->value,
'tenant_id' => $this->tenantA->id,
]);
$this->actingAs($user);
session(['tenant_id' => $this->tenantA->id]);
// 다른 테넌트로 할당 시도
$item = SecurityTestModel::create([
'name' => 'New Item',
'secret_data' => 'My Secret',
'tenant_id' => $this->tenantB->id, // 다른 테넌트로 시도
]);
// BelongsToTenant trait가 세션의 tenant_id로 자동 설정
$this->assertEquals($this->tenantA->id, $item->tenant_id);
}
}
패널 접근 제어 테스트
Middleware 테스트
<?php
namespace Tests\Feature\Core;
use App\Core\Base\Permission\Enums\UserLevel;
use App\Core\Base\Permission\Middleware\EnsureUserLevel;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class PanelAccessTest extends TestCase
{
use RefreshDatabase;
private EnsureUserLevel $middleware;
protected function setUp(): void
{
parent::setUp();
$this->middleware = new EnsureUserLevel();
}
#[Test]
public function platform_admin_can_access_platform_panel(): void
{
$user = User::factory()->create([
'level' => UserLevel::PLATFORM_ADMIN->value,
]);
$request = Request::create('/platform', 'GET');
$request->setUserResolver(fn () => $user);
$response = $this->middleware->handle(
$request,
fn () => new Response('OK'),
'0' // Platform Admin만 허용
);
$this->assertEquals('OK', $response->getContent());
}
#[Test]
public function saas_admin_cannot_access_platform_panel(): void
{
$user = User::factory()->create([
'level' => UserLevel::SAAS_ADMIN->value,
]);
$request = Request::create('/platform', 'GET');
$request->setUserResolver(fn () => $user);
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
$this->middleware->handle(
$request,
fn () => new Response('OK'),
'0'
);
}
#[Test]
public function tenant_admin_can_access_tenant_panel(): void
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'level' => UserLevel::TENANT_ADMIN->value,
'tenant_id' => $tenant->id,
]);
$request = Request::create('/tenant', 'GET');
$request->setUserResolver(fn () => $user);
$response = $this->middleware->handle(
$request,
fn () => new Response('OK'),
'0,1,2' // Level 0-2 허용
);
$this->assertEquals('OK', $response->getContent());
}
#[Test]
public function all_levels_can_access_app_panel(): void
{
$tenant = Tenant::factory()->create();
foreach (UserLevel::cases() as $level) {
$user = User::factory()->create([
'level' => $level->value,
'tenant_id' => $level->isTenantLevel() ? $tenant->id : null,
]);
$request = Request::create('/app', 'GET');
$request->setUserResolver(fn () => $user);
$response = $this->middleware->handle(
$request,
fn () => new Response('OK'),
'0,1,2,3,4,5,6' // 모든 레벨 허용
);
$this->assertEquals('OK', $response->getContent());
}
}
#[Test]
public function guest_is_redirected_to_login(): void
{
$request = Request::create('/platform', 'GET');
$request->setUserResolver(fn () => null);
$response = $this->middleware->handle(
$request,
fn () => new Response('OK'),
'0,1'
);
$this->assertEquals(302, $response->getStatusCode());
}
}
계층별 패널 접근 테스트
#[Test]
public function panel_access_respects_level_hierarchy(): void
{
$tenant = Tenant::factory()->create();
// 패널별 허용 레벨 정의
$panelAccess = [
'platform' => '0', // Platform Admin만
'saas' => '0,1', // Platform, SaaS Admin
'tenant' => '0,1,2', // Level 0-2
'org' => '0,1,2,3', // Level 0-3
'workspace' => '0,1,2,3,4', // Level 0-4
'team' => '0,1,2,3,4,5', // Level 0-5
'app' => '0,1,2,3,4,5,6', // 전체
];
foreach ($panelAccess as $panel => $allowedLevelsStr) {
$allowedLevels = array_map('intval', explode(',', $allowedLevelsStr));
foreach (UserLevel::cases() as $level) {
$user = User::factory()->create([
'level' => $level->value,
'tenant_id' => $level->isTenantLevel() ? $tenant->id : null,
]);
$request = Request::create("/{$panel}", 'GET');
$request->setUserResolver(fn () => $user);
if (in_array($level->value, $allowedLevels)) {
$response = $this->middleware->handle(
$request,
fn () => new Response('OK'),
$allowedLevelsStr
);
$this->assertEquals('OK', $response->getContent());
} else {
try {
$this->middleware->handle(
$request,
fn () => new Response('OK'),
$allowedLevelsStr
);
$this->fail("Level {$level->value} should NOT access /{$panel}");
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
$this->assertEquals(403, $e->getStatusCode());
}
}
}
}
}
인증 테스트
로그인/로그아웃 테스트
<?php
namespace Tests\Feature\Http\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class LoginTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function user_can_login_with_correct_credentials(): void
{
$user = User::factory()->create([
'email' => 'test@example.com',
'password' => bcrypt('password'),
]);
$response = $this->postJson('/api/login', [
'email' => 'test@example.com',
'password' => 'password',
]);
$response->assertOk()
->assertJsonStructure([
'data' => ['token', 'user']
]);
}
#[Test]
public function user_cannot_login_with_wrong_password(): void
{
$user = User::factory()->create([
'email' => 'test@example.com',
'password' => bcrypt('password'),
]);
$response = $this->postJson('/api/login', [
'email' => 'test@example.com',
'password' => 'wrong-password',
]);
$response->assertUnauthorized();
}
#[Test]
public function login_sets_tenant_context_from_user(): void
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'email' => 'tenant@example.com',
'password' => bcrypt('password'),
'tenant_id' => $tenant->id,
]);
$response = $this->postJson('/api/login', [
'email' => 'tenant@example.com',
'password' => 'password',
]);
$response->assertOk();
// 세션에 테넌트 컨텍스트 설정 확인
$this->assertEquals($tenant->id, session('tenant_id'));
}
}
class LogoutTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function authenticated_user_can_logout(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/logout');
$response->assertOk();
}
#[Test]
public function logout_invalidates_token(): void
{
$user = User::factory()->create();
$token = $user->createToken('test')->plainTextToken;
// 토큰으로 인증
$this->withHeader('Authorization', "Bearer {$token}")
->postJson('/api/logout')
->assertOk();
// 같은 토큰으로 재요청 시 실패
$this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/user/profile')
->assertUnauthorized();
}
}
API 응답 테스트
JSON 구조 검증
#[Test]
public function api_returns_paginated_users(): void
{
User::factory()->count(25)->create();
$admin = User::factory()->create(['level' => 0]);
$response = $this->actingAs($admin)
->getJson('/api/users?page=1&per_page=10');
$response->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'name', 'email', 'level', 'created_at']
],
'meta' => [
'current_page',
'last_page',
'per_page',
'total',
],
'links' => ['first', 'last', 'prev', 'next'],
])
->assertJsonCount(10, 'data');
}
#[Test]
public function api_validates_request_data(): void
{
$admin = User::factory()->create(['level' => 0]);
$response = $this->actingAs($admin)
->postJson('/api/users', [
// email 누락
'name' => 'Test',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['email']);
}
에러 응답 테스트
#[Test]
public function api_returns_404_for_nonexistent_resource(): void
{
$admin = User::factory()->create(['level' => 0]);
$response = $this->actingAs($admin)
->getJson('/api/users/99999');
$response->assertNotFound()
->assertJson([
'message' => 'Resource not found',
]);
}
#[Test]
public function api_returns_403_for_unauthorized_access(): void
{
$user = User::factory()->create(['level' => 6]);
$response = $this->actingAs($user)
->getJson('/api/admin/settings');
$response->assertForbidden();
}
Seeder 테스트
<?php
namespace Tests\Feature\Seeder;
use App\Core\Base\Permission\Enums\UserLevel;
use App\Models\Tenant;
use App\Models\User;
use Database\Seeders\CoreSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class CoreSeederTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function core_seeder_creates_platform_admin(): void
{
$this->seed(CoreSeeder::class);
$this->assertDatabaseHas('users', [
'level' => UserLevel::PLATFORM_ADMIN->value,
'email' => config('core.platform_admin.email'),
]);
}
#[Test]
public function core_seeder_creates_demo_tenants(): void
{
$this->seed(CoreSeeder::class);
$this->assertDatabaseCount('tenants', 2); // 데모 테넌트 2개
}
#[Test]
public function core_seeder_creates_users_for_each_level(): void
{
$this->seed(CoreSeeder::class);
foreach (UserLevel::cases() as $level) {
$this->assertDatabaseHas('users', [
'level' => $level->value,
]);
}
}
#[Test]
public function seeded_users_have_random_passwords_except_platform_admin(): void
{
$this->seed(CoreSeeder::class);
$platformAdmin = User::where('level', 0)->first();
$tenantAdmin = User::where('level', 2)->first();
// Platform Admin은 .env 비밀번호 사용 가능
$this->assertTrue(
\Hash::check(config('core.platform_admin.password'), $platformAdmin->password)
);
// 다른 레벨은 랜덤 비밀번호 (직접 로그인 불가)
$this->assertFalse(
\Hash::check('password', $tenantAdmin->password)
);
}
}
테스트 헬퍼 활용
CreatesTestTenant Trait
use Tests\Traits\CreatesTestTenant;
class MyFeatureTest extends TestCase
{
use RefreshDatabase, CreatesTestTenant;
#[Test]
public function tenant_admin_can_access_tenant_resources(): void
{
$user = $this->actAsTenantAdmin();
$response = $this->getJson('/api/tenant/resources');
$response->assertOk();
}
#[Test]
public function platform_admin_can_access_all_resources(): void
{
$this->actAsPlatformAdmin();
$response = $this->getJson('/api/admin/all-tenants');
$response->assertOk();
}
}
테스트 실행
# 전체 Feature 테스트
php artisan test tests/Feature
# 특정 디렉토리
php artisan test tests/Feature/Core
# 특정 파일
php artisan test tests/Feature/Core/Security/CrossTenantSecurityTest.php
# 특정 메서드
php artisan test --filter=tenant_user_cannot_access_other_tenant
# 병렬 실행 (대규모 테스트에 권장)
php artisan test tests/Feature --parallel
# 상세 출력
php artisan test tests/Feature --verbose
명명 규칙
| 패턴 | 예시 |
|---|---|
{actor}_can_{action} | tenant_admin_can_access_tenant_panel |
{actor}_cannot_{action} | member_cannot_access_platform_panel |
{feature}_{scenario}_{result} | login_with_wrong_password_fails |
// 권장하는 명명 예시
#[Test]
public function platform_admin_can_access_all_tenant_data(): void { }
#[Test]
public function tenant_user_cannot_update_other_tenant_data(): void { }
#[Test]
public function login_sets_tenant_context_from_user(): void { }