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' => '[email protected]',
'level' => 6,
]);
$response->assertCreated()
->assertJsonStructure([
'data' => ['id', 'name', 'email', 'level']
]);
$this->assertDatabaseHas('users', [
'email' => '[email protected]',
]);
}
#[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());
}
}