Unit 테스트
개별 클래스와 메서드를 격리하여 테스트하는 Unit 테스트 작성 가이드입니다.
개요
Unit 테스트는 코드의 가장 작은 단위(클래스, 메서드)를 격리하여 테스트합니다. Multi-SaaS Kit에서는 Core 모듈과 비즈니스 로직에 대해 Unit 테스트가 필수입니다.
Unit 테스트 적용 범위
| 영역 | 필수 여부 | 설명 |
|---|---|---|
| Core/Permission | 필수 | 권한 오류 = 보안 사고 |
| Core/Tenant | 필수 | 데이터 격리 = SaaS 핵심 |
| Core/Audit | 필수 | 감사 로그 무결성 |
| Model | 권장 | 비즈니스 로직이 있는 경우 |
| Service | 권장 | 복잡한 로직 검증 |
테스트 파일 구조
tests/
├── Unit/
│ ├── Core/
│ │ ├── Permission/ # 권한 시스템 테스트
│ │ │ ├── PermissionServiceTest.php
│ │ │ ├── HasLevelTraitTest.php
│ │ │ ├── UserLevelEnumTest.php
│ │ │ └── Policies/
│ │ │ ├── UserPolicyTest.php
│ │ │ └── TenantOwnedPolicyTest.php
│ │ ├── Tenant/ # 테넌시 테스트
│ │ │ ├── TenantScopeTest.php
│ │ │ ├── BelongsToTenantTraitTest.php
│ │ │ └── FilamentTenancyTest.php
│ │ ├── Audit/ # 감사 로그 테스트
│ │ │ └── AuditableTraitTest.php
│ │ ├── Guardian/ # Guardian Extension
│ │ └── Impersonate/ # Impersonate Extension
│ ├── Models/ # 모델 테스트
│ │ ├── UserTest.php
│ │ ├── TenantTest.php
│ │ └── OrganizationTest.php
│ └── Services/ # 서비스 테스트
└── Feature/ # Feature 테스트 (별도 문서)
기본 테스트 작성법
테스트 클래스 구조
<?php
namespace Tests\Unit\Core\Permission;
use App\Core\Base\Permission\Services\PermissionService;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class PermissionServiceTest extends TestCase
{
use RefreshDatabase;
private PermissionService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new PermissionService();
}
#[Test]
public function it_checks_panel_access_for_platform_admin(): void
{
// Arrange
$platformAdmin = User::factory()->create(['level' => 0]);
$tenantAdmin = User::factory()->create(['level' => 2]);
// Act & Assert
$this->assertTrue($this->service->canAccessPanel($platformAdmin, 'platform'));
$this->assertFalse($this->service->canAccessPanel($tenantAdmin, 'platform'));
}
}
AAA 패턴 (Arrange-Act-Assert)
모든 테스트는 AAA 패턴을 따릅니다:
#[Test]
public function user_can_check_subscription_status(): void
{
// Arrange - 테스트 데이터 준비
$user = User::factory()->create([
'level' => UserLevel::MEMBER->value,
'tenant_id' => $tenant->id,
]);
// Act - 테스트 대상 실행
$result = $user->hasLevel(UserLevel::MEMBER);
// Assert - 결과 검증
$this->assertTrue($result);
}
Mock과 Fake 사용
기본 Mock 사용법
use Mockery;
#[Test]
public function it_calls_external_service(): void
{
// Mock 생성
$mock = Mockery::mock(ExternalService::class);
$mock->shouldReceive('call')
->once()
->andReturn(['status' => 'success']);
// 컨테이너에 바인딩
$this->app->instance(ExternalService::class, $mock);
// 테스트 실행
$service = app(MyService::class);
$result = $service->process();
$this->assertEquals('success', $result['status']);
}
Laravel Fake 사용
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
#[Test]
public function it_dispatches_event_when_user_created(): void
{
Event::fake();
$user = User::factory()->create();
Event::assertDispatched(UserCreated::class, function ($event) use ($user) {
return $event->user->id === $user->id;
});
}
#[Test]
public function it_queues_welcome_email(): void
{
Mail::fake();
$user = User::factory()->create();
dispatch(new SendWelcomeEmail($user));
Mail::assertQueued(WelcomeMail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
}
Factory 활용
기본 Factory 사용
// 단일 생성
$user = User::factory()->create();
// 특정 속성 지정
$user = User::factory()->create([
'level' => UserLevel::PLATFORM_ADMIN->value,
'email' => 'admin@example.com',
]);
// 여러 개 생성
$users = User::factory()->count(5)->create();
// 관계와 함께 생성
$tenant = Tenant::factory()
->has(User::factory()->count(3), 'users')
->create();
Factory State 활용
// UserFactory.php
public function platformAdmin(): static
{
return $this->state(fn () => [
'level' => UserLevel::PLATFORM_ADMIN->value,
]);
}
public function tenantAdmin(): static
{
return $this->state(fn () => [
'level' => UserLevel::TENANT_ADMIN->value,
]);
}
// 테스트에서 사용
$admin = User::factory()->platformAdmin()->create();
$tenantAdmin = User::factory()->tenantAdmin()->create();
Factory Sequence
// 순차적으로 다른 데이터 생성
$users = User::factory()
->count(3)
->sequence(
['level' => 0],
['level' => 2],
['level' => 6],
)
->create();
테넌트 격리 테스트
TenantScope 테스트
#[Test]
public function it_filters_by_tenant_when_set_in_session(): void
{
$tenant1 = Tenant::factory()->create();
$tenant2 = Tenant::factory()->create();
// 스코프 없이 데이터 삽입
TestModel::withoutGlobalScope(TenantScope::class)->insert([
['name' => 'Item 1', 'tenant_id' => $tenant1->id, 'created_at' => now(), 'updated_at' => now()],
['name' => 'Item 2', 'tenant_id' => $tenant2->id, 'created_at' => now(), 'updated_at' => now()],
]);
// 세션에 테넌트 설정
session(['tenant_id' => $tenant1->id]);
// 스코프가 적용된 쿼리
$items = TestModel::all();
$this->assertCount(1, $items);
$this->assertEquals($tenant1->id, $items->first()->tenant_id);
}
#[Test]
public function platform_admin_bypasses_tenant_scope(): void
{
$tenant1 = Tenant::factory()->create();
$tenant2 = Tenant::factory()->create();
// 두 테넌트의 데이터 생성
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()],
]);
// Platform Admin으로 로그인
$platformAdmin = User::factory()->create(['level' => 0]);
$this->actingAs($platformAdmin);
// Platform Admin은 모든 데이터 접근 가능
$items = TestModel::all();
$this->assertCount(2, $items);
}
권한 테스트
Permission Service 테스트
#[Test]
public function it_checks_downward_access(): void
{
$platformAdmin = User::factory()->create(['level' => 0]);
$tenantAdmin = User::factory()->create(['level' => 2]);
$member = User::factory()->create(['level' => 6]);
// 상위 레벨은 하위 접근 가능
$this->assertTrue($this->service->canAccessUser($platformAdmin, $tenantAdmin));
$this->assertTrue($this->service->canAccessUser($platformAdmin, $member));
// 하위 레벨은 상위 접근 불가
$this->assertFalse($this->service->canAccessUser($tenantAdmin, $platformAdmin));
$this->assertFalse($this->service->canAccessUser($member, $tenantAdmin));
}
#[Test]
public function it_validates_level_range(): void
{
$this->assertTrue($this->service->isValidLevel(0)); // 최소값
$this->assertTrue($this->service->isValidLevel(6)); // 최대값
$this->assertFalse($this->service->isValidLevel(-1)); // 범위 밖
$this->assertFalse($this->service->isValidLevel(7)); // 범위 밖
}
Policy 테스트
#[Test]
public function tenant_admin_can_view_own_tenant(): void
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'level' => UserLevel::TENANT_ADMIN->value,
'tenant_id' => $tenant->id,
]);
$policy = new TenantPolicy();
$this->assertTrue($policy->view($user, $tenant));
}
#[Test]
public function tenant_admin_cannot_view_other_tenant(): void
{
$tenant1 = Tenant::factory()->create();
$tenant2 = Tenant::factory()->create();
$user = User::factory()->create([
'level' => UserLevel::TENANT_ADMIN->value,
'tenant_id' => $tenant1->id,
]);
$policy = new TenantPolicy();
$this->assertFalse($policy->view($user, $tenant2));
}
Data Provider 활용
기본 Data Provider
use PHPUnit\Framework\Attributes\DataProvider;
#[Test]
#[DataProvider('panelAccessProvider')]
public function it_checks_panel_access(int $level, string $panel, bool $expected): void
{
$user = User::factory()->create(['level' => $level]);
$result = $this->service->canAccessPanel($user, $panel);
$this->assertEquals($expected, $result);
}
public static function panelAccessProvider(): array
{
return [
'platform admin can access platform' => [0, 'platform', true],
'saas admin cannot access platform' => [1, 'platform', false],
'tenant admin can access tenant' => [2, 'tenant', true],
'member can access app' => [6, 'app', true],
'member cannot access tenant' => [6, 'tenant', false],
];
}
복잡한 Data Provider
#[Test]
#[DataProvider('levelHierarchyProvider')]
public function it_respects_level_hierarchy(int $actorLevel, int $targetLevel, bool $canAccess): void
{
$actor = User::factory()->create(['level' => $actorLevel]);
$target = User::factory()->create(['level' => $targetLevel]);
$result = $this->service->canAccessUser($actor, $target);
$this->assertEquals($canAccess, $result);
}
public static function levelHierarchyProvider(): array
{
$cases = [];
// 상위는 하위 접근 가능
for ($actor = 0; $actor <= 6; $actor++) {
for ($target = $actor; $target <= 6; $target++) {
$cases["level {$actor} can access level {$target}"] = [$actor, $target, true];
}
}
// 하위는 상위 접근 불가
for ($actor = 1; $actor <= 6; $actor++) {
for ($target = 0; $target < $actor; $target++) {
$cases["level {$actor} cannot access level {$target}"] = [$actor, $target, false];
}
}
return $cases;
}
테스트 헬퍼 Trait
CreatesTestTenant Trait
// tests/Traits/CreatesTestTenant.php
namespace Tests\Traits;
use App\Core\Base\Permission\Enums\UserLevel;
use App\Models\Tenant;
use App\Models\User;
trait CreatesTestTenant
{
protected function createTenant(array $attributes = []): Tenant
{
return Tenant::factory()->create($attributes);
}
protected function actAsTenantAdmin(?Tenant $tenant = null): User
{
$tenant ??= $this->createTenant();
$user = User::factory()->create([
'level' => UserLevel::TENANT_ADMIN->value,
'tenant_id' => $tenant->id,
]);
$this->actingAs($user);
session(['tenant_id' => $tenant->id]);
return $user;
}
protected function actAsPlatformAdmin(): User
{
$user = User::factory()->create([
'level' => UserLevel::PLATFORM_ADMIN->value,
]);
$this->actingAs($user);
return $user;
}
}
테스트에서 사용
class MyFeatureTest extends TestCase
{
use RefreshDatabase, CreatesTestTenant;
#[Test]
public function tenant_admin_can_manage_own_data(): void
{
$user = $this->actAsTenantAdmin();
// 테넌트 컨텍스트가 설정된 상태에서 테스트
$response = $this->get('/api/data');
$response->assertOk();
}
}
테스트 실행
# 전체 Unit 테스트 실행
php artisan test tests/Unit
# 특정 파일 실행
php artisan test tests/Unit/Core/Permission/PermissionServiceTest.php
# 특정 테스트 메서드 실행
php artisan test --filter=it_checks_panel_access
# 커버리지 포함
php artisan test tests/Unit --coverage
# 병렬 실행
php artisan test tests/Unit --parallel
명명 규칙
| 패턴 | 예시 |
|---|---|
it_{does_something} | it_checks_panel_access |
test_{feature}_{scenario}_{expected} | test_user_cannot_access_other_tenant_data |
{subject}_{action}_{result} | platform_admin_bypasses_tenant_scope |
// 권장하는 명명 예시
#[Test]
public function it_validates_level_range(): void { }
#[Test]
public function tenant_user_can_only_see_own_data(): void { }
#[Test]
public function platform_admin_can_access_all_tenants(): void { }