Skip to main content

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 { }

관련 문서