Files
Gnuboard7/docs/testing-guide.md
T
HeuJung 71abdeaf60 fix(security): KVE-2026-1914/1915/1919 remediation 전건
위임 관리자(부관리자)가 권한·역할·표현식·비밀 콘텐츠 경계를 우회하던
결함군을 계층 대칭성 원칙으로 전건 차단한다. 약한 경로가 정상 응답을
내보내는 것이 유일한 증상이라, 게이트를 생산 지점 한 곳(SSoT)에 두고
같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유하도록 맞췄다.

- 등급 상한(rank ceiling): 슈퍼관리자 보호·역할/사용자 역할 배정(추가·제거
 대칭)·일괄 상태변경·순서변경을 상세 경로와 동일 강도로 재적용. 가드는
 DB 쓰기에 선행하여 거부 시 상태 불변.
- 레이아웃 표현식: new Function/with 실행을 AST 화이트리스트 평가기로 교체.
 비-문자열 computed 키 정규화(normalizeKey)·Object facade(리플렉션 static
 제거)·legacy 접근자 차단. 저장측 검증·정적 검사와 3계층 동형.
- secret 게이트: 비밀글의 댓글·첨부·문의 독립 경로 재적용, hash 파일서빙
 소유권·비밀·발행 상태 검사 통일.
- 신뢰 스크립트 호스트: 확장 선언 기반 + same-origin 브라우저 정규화를
 런타임·저장측·정적검사 3층 동형화.
- 회귀 감지: 단위·Feature·E2E·시나리오 매니페스트 전축 + audit 룰 4종 신설.
2026-08-17 01:45:09 +09:00

1528 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 그누보드7 테스트 가이드
> 이 문서는 그누보드7의 테스트 작성 및 실행 규칙을 상세히 설명합니다.
---
## TL;DR (5초 요약)
```text
1. 테스트 통과 = 작업 완료 (작성만으로 불충분!)
2. 신규 기능 = 시나리오 매니페스트(`tests/scenarios/<feature>.yaml`) 작성 + 입력 axis cross product + 후속 효과 체인 전수 검증
3. 도메인 분류(§도메인별 테스트 전략)는 파일 패턴별 위치/형식 가이드 — 단, 매니페스트 의무는 모든 신규 기능에 적용
4. 버그 수정은 "먼저 실패하는 회귀 테스트 작성 → fail 확인 → 수정 → green" 4단계 필수
5. 테스트 실행 중 발견한 무관 에러도 같은 세션에서 함께 처리 (stale test 수정 or 로직 수정)
6. 릴리스 전 composer test-smoke 통과 필수 (Installation 스위트)
7. 백엔드: php artisan test --filter=TestName / _bundled 확장은 _bundled에서 직접 실행
8. settings 디스크는 TestCase 가 전역 페이크 — 실제 설정 파일 오염 차단. 저장 직후 값 검증은 g7_core_settings() 말고 ConfigRepository::getCategory()
```
---
## 목차
- [실제 환경 파일 보호 (settings 디스크 격리)](#실제-환경-파일-보호-settings-디스크-격리)
- [기능 단위 시나리오 매트릭스](#기능-단위-시나리오-매트릭스)
- [도메인별 테스트 전략 매트릭스](#도메인별-테스트-전략-매트릭스)
- [Pre-release Smoke Suite](#pre-release-smoke-suite)
- [버그 수정 시 회귀 테스트 의무](#버그-수정-시-회귀-테스트-의무)
- [테스트 실행 중 발견한 무관 에러 처리](#테스트-실행-중-발견한-무관-에러-처리)
- [필수 원칙](#필수-원칙)
- [모듈/플러그인 프론트엔드 테스트 독립성](#모듈플러그인-프론트엔드-테스트-독립성)
- [DDL 트랜잭션 격리 문제](#ddl-트랜잭션-격리-문제)
- [테스트 작성 패턴](#테스트-작성-패턴)
- [체크리스트](#체크리스트)
---
## _bundled 확장 직접 테스트
```
✅ _bundled 디렉토리에서 PHPUnit/Vitest 직접 실행 가능
✅ 활성 디렉토리(modules/vendor-module/)로 복사 불필요
✅ tests/bootstrap.php가 _bundled를 자동으로 우선 오토로드
프로덕션 반영은 별도로 module:update 필요 (테스트만 직접 가능)
```
### 백엔드 (PHPUnit)
```bash
# _bundled 모듈 전체 테스트
php vendor/bin/phpunit modules/_bundled/sirsoft-ecommerce/tests
# _bundled 모듈 특정 테스트
php vendor/bin/phpunit --filter=ShippingPolicyControllerTest modules/_bundled/sirsoft-ecommerce/tests
# _bundled 플러그인 테스트
php vendor/bin/phpunit plugins/_bundled/sirsoft-tosspayments/tests
```
### 프론트엔드 (Vitest)
```bash
# _bundled 모듈 디렉토리에서 직접 실행
cd modules/_bundled/sirsoft-ecommerce
powershell -Command "npm run test:run"
```
### 동작 원리
`tests/bootstrap.php`가 `_bundled` 디렉토리의 코드를 활성 디렉토리보다 먼저 등록(prepend)합니다:
1. `_bundled` module.php/plugin.php → classmap 선점 (require_once)
2. `_bundled` src/ → PSR-4 prepend 등록 (활성 디렉토리보다 우선 검색)
3. `autoload-extensions.php` → 이미 로드된 _bundled 항목은 스킵
4. Manager/RouteServiceProvider → `class_exists` 가드로 중복 선언 방지
### 주의사항
```
_bundled 테스트 통과 ≠ 프로덕션 반영 완료
✅ 프로덕션 반영: module:update / plugin:update 커맨드 필수
_bundled 코드 변경 시 module.json 버전 올리기 필수 (업데이트 감지용)
```
---
## 확장 테스트 베이스 클래스
```
필수: 모듈 → ModuleTestCase, 플러그인 → PluginTestCase 상속 (Tests\TestCase 직접 상속 금지)
```
모듈/플러그인의 모든 테스트 클래스는 해당 확장이 제공하는 `ModuleTestCase` 또는 `PluginTestCase`를 상속해야 합니다. `Tests\TestCase`를 직접 상속하면 확장의 마이그레이션이 실행되지 않아 DB 테이블 부재 에러가 발생합니다.
### 올바른 패턴
```php
// ✅ 모듈 테스트
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
class OrderActivityLogListenerTest extends ModuleTestCase
{
// ModuleTestCase가 마이그레이션, 오토로드, ServiceProvider 등록을 자동 처리
}
// ✅ 플러그인 테스트
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
class PaymentServiceTest extends PluginTestCase
{
// PluginTestCase가 마이그레이션, 오토로드, ServiceProvider 등록을 자동 처리
}
```
### 잘못된 패턴
```php
// ❌ Tests\TestCase 직접 상속 → 확장 마이그레이션 미실행 → DB 테이블 부재 에러
use Tests\TestCase;
class OrderActivityLogListenerTest extends TestCase
{
// Order::whereIn() 호출 시 "Base table or view not found" 에러 발생
}
```
### ModuleTestCase/PluginTestCase가 제공하는 기능
| 기능 | 설명 |
|------|------|
| 마이그레이션 | 코어 + 확장 마이그레이션 자동 실행 |
| 오토로드 | 확장 네임스페이스 PSR-4 등록 |
| ServiceProvider | Repository 바인딩 등 확장 서비스 등록 |
| 라우트 | 확장 API 라우트 등록 |
| 기본 역할 | admin/user 역할 자동 생성 |
### 위치
| 확장 | 베이스 클래스 위치 |
|------|-------------------|
| 모듈 | `modules/_bundled/{identifier}/tests/ModuleTestCase.php` |
| 플러그인 | `plugins/_bundled/{identifier}/tests/PluginTestCase.php` |
---
## 확장 격리 (requiredExtensions)
코어 테스트는 기본적으로 확장(모듈/플러그인)이 격리된 상태로 실행됩니다.
테스트가 특정 확장을 필요로 하면 `$requiredExtensions` 프로퍼티로 명시합니다.
이 선언은 두 가지를 동시에 수행합니다:
1. **마이그레이션 로드** — `RefreshDatabase`의 `migrate:fresh` 실행 시 해당 확장의 마이그레이션도 함께 실행
2. **확장 로딩 allowlist** — testing 환경에서 명시한 확장의 ServiceProvider / route 만 등록 허용
```php
class ResourceAbilitiesTest extends TestCase
{
use RefreshDatabase;
/**
* sirsoft-marketing 플러그인이 User 리소스 훅에 개입하므로 명시
*/
protected array $requiredExtensions = [
'plugins/sirsoft-marketing',
];
}
```
### 격리 동작 (allowlist)
`$requiredExtensions` 미선언 코어 테스트는 어떤 모듈/플러그인의 ServiceProvider 도
등록되지 않습니다. 이로써 한 확장의 전역 미들웨어(예: GDPR `CookieConsentMiddleware`)가
무관한 코어 테스트에 개입하는 것을 차단합니다.
- 확장의 ServiceProvider 바인딩 / route / 전역 미들웨어가 필요한 테스트는
그 확장을 `$requiredExtensions` 에 명시해야 합니다.
- 확장 자체 테스트(`ModuleTestCase` / `PluginTestCase` 상속)는 테스트 파일 경로로
자기 확장을 자동 탐지(`selfExtension()`)하므로 별도 선언이 불필요합니다.
다른 확장에 의존하는 경우(예: 이커머스 테스트가 결제 플러그인 필요)만 명시합니다.
- 격리는 testing 환경에서만 동작합니다 — 운영/개발 환경의 확장 로딩은 영향받지 않습니다.
- `ModuleManager::loadModules` / `PluginManager::loadPlugins`(hook listener / config /
채널 등록 경로)에는 격리가 적용되지 않습니다.
### 경로 형식
프로젝트 루트 기준 상대 경로로, `database/migrations` 디렉토리가 자동 추가됩니다:
| 선언값 | 실제 로드 경로 |
|--------|---------------|
| `'plugins/sirsoft-marketing'` | `plugins/sirsoft-marketing/database/migrations` |
| `'modules/sirsoft-ecommerce'` | `modules/sirsoft-ecommerce/database/migrations` |
| `'plugins/_bundled/sirsoft-marketing'` | `plugins/_bundled/sirsoft-marketing/database/migrations` |
### 동작 원리
마이그레이션 등록과 allowlist 주입은 서로 다른 훅 시점에 일어납니다:
- **allowlist 주입** — `TestCase::setUp()` 최상단에서 `ExtensionTestAllowlist::set()` 호출.
`parent::setUp()`의 `createApplication()`(= 모든 ServiceProvider register/boot)보다
먼저 실행되어야 provider 가드가 올바른 시점에 적용됩니다.
- **마이그레이션 등록** — `TestCase::setUpTraits()`에서 `RefreshDatabase::refreshDatabase()`
호출 전에 확장 마이그레이션 경로를 `migrator`에 등록:
1. `setUpTraits()` → `loadExtensionMigrations()` (확장 경로 등록)
2. `parent::setUpTraits()` → `RefreshDatabase::refreshDatabase()` → `migrate:fresh` (코어 + 확장 함께 실행)
### 주의사항
```
afterApplicationCreated()는 setUpTraits() 이후 실행되므로 마이그레이션 등록에 사용 불가
beforeRefreshingDatabase()는 RefreshDatabase 트레이트가 부모 클래스 메서드를 가리므로 사용 불가
✅ setUpTraits() 오버라이드가 유일하게 작동하는 훅 포인트
```
---
## 실제 환경 파일 보호 (settings 디스크 격리)
`settings` 디스크의 root 는 `storage/app/settings` — 개발/운영이 실제로 사용하는 설정 파일 그 자체다. 설정 저장 경로를 타는 테스트(설정 API 호출, `ConfigRepository::saveCategory()`)는 이 파일을 그대로 덮어쓰며, `RefreshDatabase` 는 DB 만 되돌리므로 오염이 잔류한다.
`Tests\TestCase` 가 앱 부팅 직후 `Storage::fake('settings')` 로 전역 격리한다. 테스트에서 별도 조치는 필요 없다.
```text
✅ 설정 저장 테스트 = 추가 조치 불필요 (TestCase 가 이미 격리)
❌ Storage::fake('settings') 를 테스트마다 중복 호출 — 불필요
❌ 격리를 우회해 storage/app/settings 를 직접 읽고/쓰기 — 실제 환경 오염
```
### 저장 직후 값 검증
`g7_core_settings()` / `config('g7_settings.*')` 는 **부팅 시점에 적재된 값**이라 같은 프로세스에서 저장 직후에는 갱신되지 않는다. 저장 결과를 검증하려면 저장소를 직접 읽는다.
```php
// ❌ 저장했는데 부팅 시점 값이 나온다
$this->assertSame(20000, g7_core_settings('seo.sitemap_urls_per_file'));
// ✅ 저장소 실제 값 조회
$seo = app(ConfigRepositoryInterface::class)->getCategory('seo');
$this->assertSame(20000, $seo['sitemap_urls_per_file']);
```
### 배경 (회귀 사례)
격리 도입 전에는 `Storage::fake('settings')` 를 쓰는 테스트가 1개뿐이라, 설정 저장 테스트를 돌릴 때마다 개발 환경의 실제 설정이 테스트 값으로 대체됐다. 실측 피해: `general.json` 의 사이트명이 `"Test"`, 언어가 `ja` 로 바뀌어 **개발 사이트 언어가 일본어로 전환**되어 있었고, SEO/캐시 TTL 도 테스트 값으로 덮여 운영자가 지정한 값이 사라졌다. 테스트는 계속 green 이라 조용히 누적됐다.
회귀 가드: `tests/Feature/Settings/SettingsDiskIsolationTest.php` (격리 해제 시 FAIL).
`Storage::fake()` 는 **해석된 디스크 인스턴스만 교체**하고 `config('filesystems.disks.settings.root')` 는 실경로로 남는다. 격리 여부를 판정할 때는 `Storage::disk('settings')->path('')` 를 봐야 한다.
---
## 기능 단위 시나리오 매트릭스
```text
필수: 신규 기능 또는 도메인 표면 변경 시 시나리오 매니페스트 작성/갱신 (tests/scenarios/<feature>.yaml)
필수: 매니페스트의 입력 axis cross product 와 후속 효과 체인을 모두 테스트로 커버
의의: OrderCalc 수준 = 단순 분기 망라가 아니라 "기능에 영향을 주는 모든 도메인 변수 조합 + 모든 부수효과" 검증
```
### 매니페스트 의무 트리거
다음 변경 중 하나라도 있으면 매니페스트 작성/갱신:
1. 새 라우트 등록 (`routes/**`, 모듈/플러그인 `routes/`)
2. 새 Service public 메서드 (도메인 행위)
3. 새 Event 발행 / Hook 등록 / Listener 등록
4. 새 Controller action
5. 결제/배송/쿠폰/마일리지/주문/회원 등 핵심 도메인 표면 변경
6. 새 모듈/플러그인의 도메인 기능 추가
매니페스트 면제(트리거 외): 단순 리팩토링, 번역 키 추가, 렌더링/스타일 수정, DTO 필드 추가만, 테스트 코드 자체 수정.
### 매니페스트 위치
| 범위 | 위치 |
|------|------|
| 코어 기능 | `tests/scenarios/<feature>.yaml` |
| 모듈 기능 | `modules/_bundled/<id>/tests/scenarios/<feature>.yaml` |
| 플러그인 기능 | `plugins/_bundled/<id>/tests/scenarios/<feature>.yaml` |
### 매니페스트 양식
```yaml
feature: order_payment_pg_toss
description: 토스 PG 를 통한 주문 결제 흐름
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Payment/TossPgPaymentTest.php
- plugins/_bundled/sirsoft-tosspayments/tests/Feature/WebhookTest.php
# 입력 axis — 모든 조합이 테스트로 커버되어야 함
axes:
user_type: [member, guest]
mileage_enabled: [true, false]
product_options: [single, multi_option, bundle]
shipping_region: [domestic, jeju, remote_island]
shipping_policy: [free, fixed, range_based, conditional_free]
coupon_applied: [none, product, category, all]
currency: [krw, usd_with_fx]
rounding_unit: [1, 10, 100]
# cross product 에서 의미 없는 조합 — 명시적 컷
exclusions:
- { user_type: guest, mileage_enabled: true, reason: "비회원은 마일리지 사용 불가" }
# 후속 효과 체인 — 각 단계가 테스트에서 검증되어야 함
effects:
- webhook_received
- payment_approved
- mileage_deducted
- notification_sent_to_user
- notification_amount_matches_total
- vat_calculated
- tax_free_amount_split
- coupon_marked_used
- inventory_decremented
# 보조 흐름 (취소/환불 등)
sub_flows:
- id: partial_cancel
effects: [shipping_policy_recalculated, discount_recalculated, mileage_partial_refunded]
- id: full_cancel
effects: [payment_voided, mileage_fully_refunded, coupon_unused, inventory_restored]
```
### 테스트 ↔ 매니페스트 매핑 규약
테스트 메서드 docblock 에 axis 값과 검증한 effects 를 표기:
```php
/**
* @scenario user_type=member, mileage_enabled=true, product_options=single,
* shipping_region=jeju, shipping_policy=fixed, coupon_applied=product,
* currency=krw, rounding_unit=10
* @effects payment_approved, mileage_deducted, notification_sent_to_user,
* notification_amount_matches_total, vat_calculated, coupon_marked_used,
* inventory_decremented
*/
public function test_pg_payment_member_with_mileage_to_jeju(): void { ... }
```
TypeScript 테스트는 `// @scenario` / `// @effects` 주석 동일 사용.
정적 검사가 매니페스트 cross product 와 effects 항목을 테스트 docblock 마킹과 대조하여 누락을 검출한다 (자동 차단).
#### 항목 구분자는 쉼표뿐이다
마커 파서는 축과 effects 를 **쉼표로만** 분리한다. 요약을 적을 때 흔히 쓰는 `×`(곱)나 `+`(더하기)를 구분자로 두면 여러 항목이 **한 문자열로 뭉쳐** 등록된다.
| ❌ 금지 | 실제 등록 결과 |
| --- | --- |
| `@scenario feat a=1 × b=2` | 축 1개 `{a: "1 × b=2"}` — 실재하지 않는 조합 |
| `@effects x + y + z` | 효과 1개 `"x + y + z"` — x·y·z 는 미등록 |
| `@scenario a + b + c` (`=` 없음) | 빈 조합 `{}` — 아무것도 커버하지 않음 |
그렇게 만들어진 마커는 **어떤 조합도 커버하지 못하는 죽은 마커**다. 게다가 cross product 대조는 "매니페스트가 요구하는 항목이 마커에 있는가" 만 보고 마커 쪽에 생긴 쓰레기 항목은 무시하므로, 구분자를 틀려도 전 게이트가 조용히 통과한다 — 실제로 36건이 그렇게 쌓였다.
항목이 여럿이면 `, ` 로 적고, 파일/클래스 레벨의 **요약**이라면 마커 토큰을 걷어내 평문으로 내린다. 파일 레벨에 effects 목록을 몰아 적으면 그 메서드가 하나도 없어도 "언급됨" 으로 집계되어 커버리지가 부풀고, 메서드 삭제가 무증상 green 이 된다 — 마커는 test 에만 둔다.
```php
/**
* 축 요약(마커 아님 — 평문): actor, operation, outcome.
*/
```
구분자 형식은 정적 검사가 강제한다 (자동 차단).
### cross product 폭발 관리
축이 많아 cross product 가 비현실적으로 커질 때:
1. `exclusions` 로 의미 없는 조합 제외 (이유 명시 필수)
2. **Pairwise (all-pairs)** 전략 허용: 매니페스트에 `coverage_strategy: pairwise` 명시 시 모든 axis 쌍의 모든 조합만 커버 (n축 → O(n²) 케이스). 단순 분기형 axis(true/false) 가 많을 때 유효
3. 그래도 폭발하면 **상위 axis 분리**: 매니페스트를 도메인별로 분할 (예: `order_payment_pg_toss_member.yaml`, `order_payment_pg_toss_guest.yaml`)
### 대규모 시나리오 축(scale)
큐/스케줄러/배치 생성처럼 데이터가 누적되면 붕괴할 수 있는 기능은 대용량(scale) 축을 매니페스트에 선언하고, 규모 의존 결함(유계 메모리·분할 정확성·전량 적재 회피 등)을 회귀 가드로 고정한다. 배경: 사이트맵 생성 잡이 1.4M 게시글을 전량 in-memory 적재해 반복 OOM 붕괴한 사례(#79).
**스키마** (`scale` = block-array of flow objects — 내장 YAML fallback 파서가 flow 객체만 중첩 파싱하므로 `- { ... }` 형태):
```yaml
scale:
- { n: 1500000, dataset: posts+products, assert: [bounded_peak_memory, child_count_correct, no_full_table_load] }
```
- `n`: 데이터셋 규모(건수). 10만 이상이어야 scale 축의 의미가 있다.
- `dataset`: 대용량 데이터셋 설명(선택).
- `assert`: 이 규모에서 검증할 단언(snake_case). 실 대용량 DB 대신 합성 iterator·계측(예: `memory_get_peak_usage`, `DB::listen`, 방송 스로틀 카운트)으로 성질을 고정해도 된다.
**테스트 마킹** — `effects` 처럼 테스트 docblock `@scale` 마킹으로 각 assert 를 커버:
```php
/**
* @scale n=1500000 asserts=bounded_peak_memory, child_count_correct
*/
public function test_large_volume_splits_and_keeps_memory_bounded(): void { ... }
```
TypeScript 테스트는 `// @scale ...` 라인 주석 동일 사용.
**강제 룰 2종** (자동 차단):
| 룰 | 대상 | 검출 |
|----|------|------|
| `scenario-scale-axis-required` | `tests/scenarios/**/*.yaml` | batch/generation 매니페스트(test_files 에 `/Jobs/`·`Generator` 또는 tags `[batch]`/`[generation]`)인데 scale 축 없음 / n<100000 / assert 미커버 |
| `job-generator-needs-scale-test` | `app/Jobs/**`·`**/*Generator.php` | 소스 변경 시 그 클래스를 언급하고 scale 축을 가진 매니페스트가 없음 |
- `*Command.php` 는 흔하고 볼륨 비의존이라 `job-generator-needs-scale-test` 대상에서 제외한다. 배치 커맨드는 매니페스트 `tags: [batch]` + `scenario-scale-axis-required` 로 커버한다.
- 면제: 매니페스트 헤더 `# audit:allow scenario-scale-axis-required reason: ...` / 소스 헤더 `// audit:allow job-generator-needs-scale-test <사유>`.
### 작성 절차
1. 영향 변수 카탈로그 식별
2. 후속 효과 체인 매핑 (Event::dispatch / Notification::send / DB 변이 / 외부 호출 전수 열거)
3. 의미 없는 조합 컷 (`exclusions`)
4. 매니페스트 YAML 작성
5. 매니페스트의 cross product 에 대해 테스트 메서드 작성 → `php artisan test --filter=...` green
### grandfathered
본 정책 도입 이전 작성된 테스트는 매니페스트 사후 작성 의무 없음. 단, 해당 기능을 다시 수정/확장할 때는 본 정책에 따라 매니페스트 신규 작성 의무 발생.
---
## 도메인별 테스트 전략 매트릭스
```text
필수: 테스트 작성 전 대상 파일의 도메인을 먼저 분류할 것 (테스트 위치/형식 가이드)
참고: 본 매트릭스는 테스트 파일 위치·작성 형식 가이드. 입력 조합 망라 의무는 §기능 단위 시나리오 매트릭스 참조
```
### 4개 도메인 분류
| 도메인 | 파일명 패턴 | 성격 | 필수 테스트 유형 | 참고 모범사례 |
|--------|-------------|------|------------------|---------------|
| **Pure Logic** | `*Calculator`, `*Policy`, `*Rule`, `*PricingService`, `*CalculationService` | 입력→출력 결정론 | 테이블 드리븐 + 경계값 + axis cross product (매니페스트 의무) | `modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCalculationServiceTest.php` |
| **CRUD/Repository** | `*Repository`, `*Service` (CRUD 위주) | DB 상태 변이 | 마이그레이션 실행 후 Feature 테스트 1회전 (golden path + 권한 경계) | `modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardManagementTest.php` |
| **Hook/Event** | `*Listener`, `*Subscriber`, Hook 발행 Service | 사이드이펙트 | Event 발행 → Listener 실행 → 관찰 가능한 상태 변화 검증 (mock 금지, 실제 훅 체인) | `modules/_bundled/sirsoft-board/tests/Unit/Listeners/CountSyncListenerTest.php` |
| **Migration** | `database/migrations/*`, 모듈/플러그인 migrations | 스키마 변이 | up → down → up 왕복 + 영향받는 Repository/Service 호출처 1회전 | `tests/Feature/Installation/BoardFreshInstallTest.php` |
### 분류 결정 순서
1. **파일명 패턴 매칭** — 위 표의 패턴과 일치하면 그 도메인으로 분류
2. **불명확한 경우** — "테스트로 검증할 불변식(invariant)"을 기준으로 판단:
- 입력→출력 결정론 ⇒ Pure Logic
- DB/파일 상태 변이 ⇒ CRUD/Repository
- 이벤트/훅 기반 사이드이펙트 ⇒ Hook/Event
3. **하나의 서비스가 여러 도메인에 걸칠 때** — 메서드 단위로 분류해서 각각에 맞는 테스트 유형 적용
### 도메인별 작성 지침
**Pure Logic**:
- 모든 분기/경계값을 DataProvider로 전수 커버
- 외부 의존성 0 (DB, HTTP, 파일시스템 호출 금지)
- assert는 반환값 + 예외 타입까지 명시
**CRUD/Repository**:
- `RefreshDatabase` 트레이트 + 실제 마이그레이션 실행
- golden path 1개 + 권한 실패 1개 + 유효성 실패 1개가 최소 세트
- DB 스키마 가정을 assertion에 포함 (`assertDatabaseHas` 등)
**Hook/Event**:
- mock이 아닌 실제 이벤트 발행(`event(new XxxEvent(...))`)
- Listener 동작 후 DB/캐시/상태 변화를 직접 조회하여 검증
- 훅 체인이 끊어지면 회귀하는 카운터, 권한, 정합성 지표를 검증 대상으로
**Migration**:
- `migrate` → `migrate:rollback` → `migrate` 왕복 안전성 테스트
- 기존 스키마를 변경(drop/rename)하는 마이그레이션은 영향받는 Repository 호출 1회전 필수 포함
- 스키마 변경이 데이터 유실을 동반한다면 별도 데이터 보존 테스트 필요
---
## Pre-release Smoke Suite
```text
필수: 릴리스 태깅 전 composer test-smoke 통과 필수
목적: 마이그레이션 + 모듈 설치 + 핵심 사용자 여정 end-to-end 검증
대상: Clean install, 업그레이드 경로, 번들 모듈 핵심 시나리오
```
### 구성
```text
tests/Feature/Installation/
├── FreshInstallSmokeTest.php # 마이그레이션 전수 실행 + 코어 헬스체크
├── BoardFreshInstallTest.php # 게시판 create → post → comment 1회전
├── EcommerceFreshInstallTest.php # 상품 생성 → 주문 → 결제 mock 1회전
└── UpgradeSmokeTest.php # 이전 릴리스 시드 → 최신 마이그레이션 → 기존 데이터 접근 1회전
```
### 각 테스트 최소 요구사항
- `RefreshDatabase` + 실제 migrations 전수 실행 (mock DB 금지)
- 모듈/플러그인 install artisan 커맨드 호출
- 해당 도메인의 사용자 여정 1개를 end-to-end로 실행 (HTTP 레이어까지)
- 각 단계에 명확한 assertion (`assertTrue(true)` 같은 무의미 assert 금지)
### 실행
```bash
# 전체 스모크 스위트
composer test-smoke
# CI 모드 (첫 실패에서 중단)
composer test-smoke-ci
# 특정 스모크 테스트만
php artisan test tests/Feature/Installation/BoardFreshInstallTest.php
```
### 릴리스 전 체크리스트
- [ ] `composer test-smoke` 통과
- [ ] `config/app.php` 버전 bump와 동시에 CHANGELOG 기록
- [ ] Installation 테스트에서 새 마이그레이션 경로가 커버되는지 확인
---
## 버그 수정 시 회귀 테스트 의무
```text
필수: 버그 수정 작업은 4단계 순서 준수 (스킵 불가)
금지: 회귀 테스트 없이 로직만 수정하고 완료 선언
이유: 테스트 없는 수정은 동일 회귀를 반복 발생시킴 (beta.2 #11 사례)
```
### 4단계 절차
```text
1. 재현 → 먼저 실패하는 회귀 테스트 작성 (fail 확인 필수)
2. 그 테스트가 수정 대상 파일의 도메인에 맞는 유형인지 확인 (§도메인별 테스트 전략 매트릭스)
3. 버그 수정 → 테스트 green 전환
4. 완료 보고에 "회귀 테스트 파일 경로 + 커밋 전 fail 상태 증빙" 포함
```
### 도메인별 회귀 테스트 위치
| 버그 유형 | 회귀 테스트 작성 위치 |
|----------|----------------------|
| 순수 로직 버그 | `tests/Unit/Services/*Test.php` 또는 확장의 동등 위치 |
| CRUD/API 버그 | `tests/Feature/*Test.php` |
| 훅/카운터 동기화 버그 | `tests/Unit/Listeners/*Test.php` |
| 마이그레이션–Repository 결합 버그 | `tests/Feature/Installation/*Test.php` (스모크 스위트 포함) |
### 예시 — #11 회귀 테스트 패턴
```php
// ❌ 버그 수정만 하고 끝내는 패턴 (금지)
// BoardPostService::incrementCommentCount() 수정하고 완료 선언
// ✅ 회귀 테스트 먼저 작성
#[Test]
public function comment_count_updates_after_comment_creation(): void
{
$post = BoardPost::factory()->create(['comment_count' => 0]);
// 댓글 생성 (실제 HTTP 호출 or Service 직접 호출)
$this->postJson("/api/boards/{$post->board_id}/posts/{$post->id}/comments", [
'content' => '테스트 댓글',
])->assertStatus(201);
// ⚠️ 수정 전에는 이 assertion이 실패해야 함
$this->assertSame(1, $post->fresh()->comment_count);
}
```
---
## 테스트 실행 중 발견한 무관 에러 처리
```text
필수: 테스트 실행 중 확인된 FAIL/ERROR는 현재 작업과 무관해도 같은 세션에서 함께 해결
금지: "별건이라 나중에 수정"하고 세션 종료
이유: 세션을 넘기면 휘발되는 정보. 발견 즉시 처리가 가장 효율적
```
### 의사결정 분기
```text
테스트 실행 → 실패/경고 감지
│
├─ 실패 원인이 "테스트 코드가 현행 로직을 따라가지 못함"
│ → 테스트 코드 수정 (stale test 정비)
│ → 판정 근거: git blame + 해당 로직 변경 시점 비교
│
├─ 실패 원인이 "실제 로직의 버그"
│ → 로직 수정 + 회귀 테스트 보강 (§버그 수정 시 회귀 테스트 의무의 4단계 적용)
│
└─ 판별 불가
→ 별도 판단 필요 (현재 작업 단위 내 결정 의무, 다음 단위 이월 금지)
```
### 제외 범위 (함께 수정하지 않아도 되는 경우)
- 실패가 **외부 환경 의존**(네트워크 미연결, 빌드 캐시, 서버 인프라)인 경우
- 실패가 **설계 변경 진행 중**이라 의도적으로 깨진 상태
- 단, 기존 작업 계획에 명시된 상태여야 함 (사후 정당화 금지)
### 테스트 결과 보고 필수 필드
테스트 실행 결과에서 FAIL/ERROR/RISKY가 1건 이상이면 각 항목에 대해 다음 필드를 기록:
| 필드 | 값 예시 | 설명 |
|------|---------|------|
| 연관 여부 | YES / NO / UNCLEAR | 현재 작업 스코프와의 연관성 |
| 판정 근거 | 커밋 해시, 파일 경로, 변경 시점 | git blame/log 기반 증거 |
| 조치 분기 | 테스트 수정 / 로직 수정 / 보류 | 의사결정 분기 결과 |
### 범위
- 현재 작업 디렉토리에서 실행한 테스트 스위트 내 FAIL만 대상
- 다른 모듈/디렉토리까지 강제로 확장하지 않음 (작업 부하 관리)
---
## 필수 원칙
```
필수: 테스트 통과 = 작업 완료 (작성만으로 불충분)
```
---
## 모듈/플러그인 프론트엔드 테스트 독립성
```
필수: 각 모듈/플러그인은 독립적인 vitest.config.ts를 가짐 (루트 vitest.config에 포함 금지)
필수: 테스트는 해당 모듈/플러그인 디렉토리에서 실행
```
### 원칙
모듈과 플러그인은 **독립적인 확장 단위**입니다. 프론트엔드 테스트 역시 코어 프로젝트와 분리되어야 합니다:
1. **루트 vitest.config.ts에 모듈/플러그인 경로 포함 금지**
2. **각 모듈/플러그인은 자체 테스트 환경 구성**
3. **테스트는 해당 디렉토리에서 독립 실행**
### 필수 파일 구성
모듈/플러그인에 프론트엔드 테스트가 필요한 경우 다음 파일을 구성합니다:
**1. vitest.config.ts**
```typescript
// modules/[vendor-module]/vitest.config.ts
import { defineConfig } from 'vitest/config';
import path from 'path';
import fs from 'fs';
// 프로젝트 루트를 동적으로 탐색 (artisan 파일 기준)
// _bundled와 활성 디렉토리 모두에서 동작합니다.
function findProjectRoot(startDir: string): string {
let dir = startDir;
while (dir !== path.dirname(dir)) {
if (fs.existsSync(path.join(dir, 'artisan'))) return dir;
dir = path.dirname(dir);
}
return path.resolve(startDir, '../../'); // fallback
}
const projectRoot = findProjectRoot(__dirname);
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['resources/js/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules/', 'dist/'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'resources/js'),
'@core': path.resolve(projectRoot, 'resources/js/core'),
},
},
});
```
**2. package.json 테스트 스크립트**
```json
{
"name": "@g7/[vendor-module]",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest",
"test:run": "vitest run"
},
"devDependencies": {
"vitest": "^2.1.8"
}
}
```
### 테스트 파일 위치
```
modules/[vendor-module]/
├── resources/
│ └── js/
│ ├── handlers/
│ │ ├── index.ts
│ │ ├── someHandler.ts
│ │ └── __tests__/
│ │ └── someHandler.test.ts # ← 테스트 파일
│ └── utils/
│ ├── someUtil.ts
│ └── __tests__/
│ └── someUtil.test.ts # ← 테스트 파일
├── package.json
└── vitest.config.ts
```
### 테스트 실행 방법
```bash
# ✅ DO: _bundled 모듈 디렉토리에서 직접 실행
cd modules/_bundled/sirsoft-ecommerce
npm install # 최초 1회
npm run test:run # 테스트 실행
# ✅ DO: _bundled 모듈에서도 직접 실행 가능
cd modules/_bundled/sirsoft-ecommerce
npm install # 최초 1회
powershell -Command "npm run test:run"
# ❌ DON'T: 루트에서 모듈 테스트 실행 시도
cd /path/to/g7
npm run test:run -- modules/sirsoft-ecommerce # 작동 안함
```
### 의존성 설치
모듈/플러그인에서 테스트를 처음 실행하기 전 의존성을 설치합니다:
```bash
cd modules/_bundled/[vendor-module]
npm install
```
### 왜 독립적이어야 하는가?
1. **확장 단위의 독립성**: 모듈/플러그인은 언제든 설치/삭제될 수 있는 독립 단위
2. **루트 설정 오염 방지**: 특정 모듈 경로가 루트 설정에 하드코딩되면 유지보수 어려움
3. **테스트 격리**: 각 모듈/플러그인의 테스트가 서로 영향을 주지 않음
4. **배포 독립성**: 모듈 배포 시 테스트 환경도 함께 배포
### 체크리스트
```
□ 모듈/플러그인에 vitest.config.ts 존재하는가?
□ package.json에 test 스크립트가 있는가?
□ devDependencies에 vitest가 있는가?
□ npm install 실행했는가?
□ 해당 디렉토리에서 테스트 실행하는가?
```
### 주의 사항
```
루트 vitest.config.ts에 modules/plugins 경로 추가 금지
모듈/플러그인 테스트는 해당 디렉토리에서만 실행
모듈/플러그인 테스트 설정은 독립적으로 구성
```
---
## 환경 격리 — Shared Settings 파일 주의
### 배경
`storage/app/settings/*.json` 파일(특히 `drivers.json`)은 dev / testing 환경이 **공유**하는 파일입니다. `SettingsServiceProvider::register()`가 부팅 시 이 파일을 읽어 `cache.default`, `session.driver`, `queue.default` 등을 runtime에 오버라이드합니다.
### 발생했던 문제
과거에는 `SettingsServiceProvider::applyDriverConfig()`가 testing 환경에서도 이 오버라이드를 적용하여, `phpunit.xml`이 지정한 `CACHE_STORE=array` 설정이 무효화되고 testing이 dev의 **Redis 인스턴스와 프리픽스를 공유**했습니다. 그 결과:
1. 테스트가 `RefreshDatabase`로 `notification_definitions` 테이블을 truncate
2. 부팅 중 `NotificationDefinitionService::getAllActive()` 호출 → 빈 컬렉션
3. 빈 컬렉션이 dev Redis에 cached
4. 이후 dev 요청이 이 stale 빈 캐시를 읽어 `NotificationHookListener::registerDynamicHooks()`가 어떤 훅도 등록하지 못함
5. **알림이 silent하게 발송 실패**
### 현재 보호 장치
`SettingsServiceProvider::applyDriverConfig()`는 `env('APP_ENV') === 'testing'`에서 cache/session/queue 드라이버 오버라이드를 **건너뜁니다**. testing 환경은 `phpunit.xml` + `.env.testing`이 지정한 드라이버를 그대로 사용합니다.
### 지침
```text
⚠️ testing 환경은 dev의 Redis/DB/file 캐시를 절대 공유하지 않아야 함
✅ phpunit.xml: CACHE_STORE=array (in-memory, per-process)
✅ .env.testing: CACHE_STORE=file 또는 array
❌ drivers.json 오버라이드는 testing에서 비활성화됨 (SettingsServiceProvider 내 가드)
```
**향후 변경 시 주의**: `SettingsServiceProvider`에 새 드라이버 오버라이드를 추가할 때, shared 파일 경로(storage/app/settings)에서 읽는다면 testing 환경 가드를 반드시 포함할 것.
---
## DDL 트랜잭션 격리 문제
### 문제 설명
모듈/플러그인/템플릿 설치 테스트에서 `Artisan::call('migrate')`가 실행되면 MySQL의 DDL(Data Definition Language) 문이 **암시적 커밋(implicit commit)**을 유발하여 RefreshDatabase 트랜잭션이 깨집니다.
```
주의: DDL(CREATE TABLE 등)은 MySQL에서 암시적 커밋을 유발
필수: DDL 테스트는 하나의 메서드로 통합 (개별 분리 시 각 12-14초 소요)
```
### 영향받는 테스트 유형
| 테스트 유형 | DDL 유발 | 해결 방법 |
|-------------|----------|----------|
| 모듈 install/activate/deactivate/uninstall | ✅ | 하나의 테스트로 통합 |
| 플러그인 install/activate/deactivate/uninstall | ✅ | 하나의 테스트로 통합 |
| 템플릿 install/activate | ✅ | 하나의 테스트로 통합 |
| 일반 CRUD 테스트 | ❌ | 개별 테스트 유지 |
| 캐시/리스트 테스트 | ❌ | 개별 테스트 유지 |
### 올바른 패턴
```php
// ❌ BAD: 개별 테스트 (각각 12-14초, 총 48초+)
public function test_install_module(): void { ... } // 12s
public function test_activate_module(): void { ... } // 12s
public function test_deactivate_module(): void { ... } // 12s
public function test_uninstall_module(): void { ... } // 12s
// ✅ GOOD: 통합 테스트 (전체 1-2초)
public function test_module_lifecycle_workflow(): void
{
// Part 1: 미설치 상태 실패 케이스
$this->artisan('module:activate', ['identifier' => $id])->assertExitCode(1);
// Part 2: 설치 워크플로우
$this->artisan('module:install', ['identifier' => $id])->assertExitCode(0);
$this->assertDatabaseHas('modules', ['identifier' => $id]);
// Part 3: 활성화/비활성화 워크플로우
$this->artisan('module:activate', ['identifier' => $id])->assertExitCode(0);
$this->artisan('module:deactivate', ['identifier' => $id])->assertExitCode(0);
// Part 4: 삭제 워크플로우
$this->artisan('module:uninstall', ['identifier' => $id, '--force' => true])
->assertExitCode(0);
}
```
### 테스트 클래스 구조
```php
class ModuleArtisanCommandsTest extends TestCase
{
use RefreshDatabase;
// ========================================
// Non-DDL 테스트: 개별 메서드로 유지 (빠름)
// ========================================
public function test_list_command(): void { ... } // 0.1s
public function test_cache_clear_command(): void { ... } // 0.1s
public function test_nonexistent_module_fails(): void { ... } // 0.1s
// ========================================
// DDL 통합 테스트: 반드시 하나의 메서드로!
// test_z_ 접두사로 마지막에 실행
// ========================================
/**
* DDL 유발: 모듈 설치 시 마이그레이션 실행
*
* 모든 install/activate/deactivate/uninstall 시나리오를
* 하나의 테스트에서 순차 실행
*/
public function test_z_module_commands_full_workflow(): void
{
// Part 1: 미설치 상태 실패 케이스
// Part 2: 설치 워크플로우
// Part 3: 활성화/비활성화 워크플로우
// Part 4: 삭제 워크플로우
// Part 5: --force 옵션 테스트
// Part 6: 전체 라이프사이클
}
}
```
### 참고 자료
- [MySQL Implicit Commit](https://dev.mysql.com/doc/refman/8.0/en/implicit-commit.html)
- 예시 파일: `tests/Feature/Console/Commands/Module/ModuleArtisanCommandsTest.php`
---
## 템플릿 엔진 테스트 필수화
```
필수: 템플릿 엔진 또는 컴포넌트 수정 시 테스트 포함 (테스트 없이 작업 완료 불가)
```
### 적용 범위
다음 작업 수행 시 **반드시** 테스트 코드 작업이 포함되어야 합니다:
| 작업 유형 | 테스트 요구사항 |
|----------|----------------|
| 템플릿 엔진 버그 수정 | 해당 버그를 재현하는 테스트 케이스 추가 |
| 템플릿 엔진 기능 추가 | 새 기능에 대한 테스트 케이스 작성 |
| 컴포넌트 props 변경 | 변경된 props에 대한 테스트 수정/추가 |
| 컴포넌트 동작 변경 | 변경된 동작에 대한 테스트 수정/추가 |
| 새 컴포넌트 추가 | 해당 컴포넌트의 테스트 파일 생성 |
| Action/Handler 수정 | ActionDispatcher 등 관련 테스트 수정/추가 |
### 테스트 파일 위치
```
/resources/js/core/template-engine/
├── ActionDispatcher.ts
├── __tests__/
│ └── ActionDispatcher.test.ts # ← 테스트 파일
/templates/sirsoft-admin_basic/
├── src/components/
│ ├── basic/
│ │ └── __tests__/ # ← basic 컴포넌트 테스트
│ ├── composite/
│ │ └── __tests__/ # ← composite 컴포넌트 테스트
│ └── layout/
│ └── __tests__/ # ← layout 컴포넌트 테스트
```
### 테스트 작성 규칙
**1. 버그 수정 시 (TDD 권장)**
```typescript
// 1. 먼저 버그를 재현하는 테스트 작성 (실패 확인)
it('should handle URL query parameters correctly', () => {
// 버그 재현 코드
});
// 2. 버그 수정
// 3. 테스트 통과 확인
```
**2. 기능 추가 시**
```typescript
describe('NewFeature', () => {
it('should perform expected behavior', () => {
// 새 기능 테스트
});
it('should handle edge cases', () => {
// 엣지 케이스 테스트
});
});
```
**3. 컴포넌트 수정 시**
```typescript
// 기존 테스트가 있으면 수정
// 없으면 새로 생성
describe('ModifiedComponent', () => {
it('should render with new props', () => {
// 변경된 props 테스트
});
});
```
### 테스트 실행 명령어
```text
✅ 프론트엔드 테스트는 루트 또는 템플릿 디렉토리에서 실행 가능
✅ 템플릿은 자체 vitest.config.ts를 가지며 루트 setup/alias 참조
✅ 코어 테스트(template-engine)는 루트에서 실행
❌ DON'T: --testPathPattern 옵션 사용 (Jest 전용, Vitest에서는 동작 안함)
```
### 컴포넌트 테스트
**템플릿 디렉토리에서 실행 (권장)**:
```bash
# 템플릿 디렉토리에서 해당 템플릿 테스트만 실행
cd templates/_bundled/sirsoft-admin_basic
powershell -Command "npm run test:run" # 전체 테스트
powershell -Command "npm run test:run -- DataGrid" # 특정 테스트
powershell -Command "npm run test:run -- SortableMenuItem"
```
**프로젝트 루트에서 실행**:
```bash
# 모든 테스트 (코어 + 모든 템플릿)
powershell -Command "npm run test:run"
# 특정 템플릿만
powershell -Command "npm run test:run -- templates/_bundled/sirsoft-admin_basic"
# 특정 테스트 필터
powershell -Command "npm run test:run -- DataGrid"
```
**코어 템플릿 엔진 테스트 (루트에서만 실행)**:
```bash
# 템플릿 엔진 코어는 루트에서 실행
powershell -Command "npm run test:run -- template-engine"
powershell -Command "npm run test:run -- Router"
```
**잘못된 사용법**:
```bash
# ❌ DON'T: --testPathPattern 옵션 사용 (Vitest에서 지원하지 않음)
npm run test:run -- --testPathPattern=Router # 잘못된 명령
```
**템플릿별 vitest.config.ts 구조**:
각 템플릿은 자체 `vitest.config.ts`를 가지며, 루트의 setup 파일과 alias를 참조합니다:
- `server.fs.allow`로 루트 디렉토리 접근 허용
- `setupFiles`에서 루트의 `resources/js/tests/setup.ts` 참조
- `resolve.alias`에서 `@` → 루트의 `resources/js` 매핑
### 완료 조건 체크리스트
```
□ 관련 테스트 파일이 존재하는가?
→ 없으면: 테스트 파일 생성
→ 있으면: 기존 테스트 검토
□ 변경 사항에 대한 테스트가 있는가?
→ 없으면: 테스트 케이스 추가
→ 있으면: 테스트가 변경 사항을 커버하는지 확인
□ 모든 테스트가 통과하는가?
→ 실패: 코드 수정 후 재실행
→ 통과: 작업 완료 가능
```
### 테스트 필수 원칙
```
필수: 코드 수정 시 테스트 포함 후 완료 선언
필수: 기존 테스트 유지 (삭제/skip 금지)
필수: 테스트 작성 후 실행까지 완료
```
### 테스트 커버리지 기준
코어, 모듈, 플러그인 전 영역에 걸쳐 아래 계층별 테스트가 존재해야 합니다.
**백엔드 (PHPUnit)**:
| 계층 | 테스트 대상 | 검증 항목 |
|------|-----------|----------|
| Controller (Feature) | API 엔드포인트 | 상태 코드, 응답 구조, abilities, 권한 403 |
| Resource (Unit) | 직렬화 | 필드 반환, 관계 미로드 시 null, json_encode 안전성 |
| Collection (Unit) | 목록 직렬화 | abilityMap, resolveCollectionAbilities, pagination |
| Service (Unit) | 비즈니스 로직 | CRUD, 캐싱, 예외 처리 |
| Listener (Unit) | 이벤트/훅 처리 | 훅 구독, 데이터 변환 |
| Upgrade (Unit) | 업그레이드 스텝 | 데이터 이관, 멱등성 |
**프론트엔드 (Vitest)**:
| 계층 | 테스트 대상 | 검증 항목 |
|------|-----------|----------|
| 컴포넌트 | TSX 컴포넌트 | 렌더링, props, 이벤트 |
| 레이아웃 | JSON 레이아웃 | 렌더링, 데이터 바인딩, 액션 |
**신규 기능 구현 시 최소 커버리지**:
- Controller → Feature 테스트 (정상 + 권한 부족)
- Resource → json_encode 안전성 (관계 미로드 시나리오 포함)
- Collection → abilities 반환 검증 (abilityMap 정의 시)
- Service → 핵심 메서드별 정상/예외 케이스
### 레이아웃 렌더링 테스트 (상세)
레이아웃 JSON 파일을 실제 렌더링하고 런타임 동작을 검증하는 테스트 시스템이 제공됩니다.
> 상세 문서: [frontend/layout-testing.md](frontend/layout-testing.md)
**주요 기능**:
- `createLayoutTest()`: 레이아웃 테스트 헬퍼 생성
- `mockApi()`: 데이터소스 API 응답 모킹
- `getState()` / `setState()`: 상태 관리
- `triggerAction()`: 액션 트리거
- `getToasts()` / `getModalStack()`: UI 피드백 추적
**테스트 유틸리티 위치**:
```text
resources/js/core/template-engine/__tests__/
├── utils/
│ ├── mockApiUtils.ts # API 모킹 유틸리티
│ └── layoutTestUtils.ts # 레이아웃 테스트 헬퍼
└── layouts/
└── example.test.tsx # 유틸리티 사용 예제 (코어 전용)
```
**레이아웃 렌더링 테스트 위치**:
```text
필수: 레이아웃 테스트는 해당 레이아웃이 속한 확장 디렉토리에 작성 (코어 디렉토리에 모듈/템플릿 테스트 배치 금지)
```
| 레이아웃 소속 | 테스트 파일 위치 |
|-------------|-----------------|
| 모듈 레이아웃 | `modules/_bundled/{id}/resources/js/__tests__/layouts/*.test.tsx` |
| 템플릿 레이아웃 | `templates/_bundled/{id}/__tests__/layouts/*.test.tsx` |
| 코어 레이아웃 | `resources/js/core/template-engine/__tests__/layouts/*.test.tsx` |
**실행 명령어**:
```powershell
# 모듈 레이아웃 테스트
cd modules/_bundled/sirsoft-ecommerce
powershell -Command "npm run test:run"
# 템플릿 레이아웃 테스트
cd templates/_bundled/sirsoft-admin_basic
powershell -Command "npm run test:run"
# 코어 레이아웃 테스트 (루트에서)
powershell -Command "npm run test:run -- layouts/example.test"
```
**완료 조건**:
```bash
php artisan test --filter=TargetTest
# 결과: Tests: X passed (Y assertions) - 모든 테스트 성공
```
**실패 시 대응**:
1. 에러 메시지 분석
2. 코드 수정
3. 재실행
4. 통과할 때까지 반복
### 테스트 작성 패턴
```php
<?php
namespace Tests\Feature\Modules\Sirsoft\Ecommerce;
use Tests\TestCase;
use App\Models\User;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductCategory;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ProductApiTest extends TestCase
{
use RefreshDatabase;
private User $adminUser;
private ProductCategory $category;
protected function setUp(): void
{
parent::setUp();
// 관리자 사용자 생성
$this->adminUser = User::factory()->create();
$this->adminUser->assignRole('admin');
// 카테고리 생성
$this->category = ProductCategory::factory()->create();
}
/**
* 상품 목록 조회 테스트
*/
public function test_can_list_products(): void
{
// Arrange
Product::factory()->count(5)->create([
'category_id' => $this->category->id,
]);
// Act
$response = $this->actingAs($this->adminUser)
->getJson('/api/admin/sirsoft-ecommerce/products');
// Assert
$response->assertStatus(200)
->assertJsonStructure([
'success',
'message',
'data' => [
'*' => ['id', 'name', 'price', 'category'],
],
]);
$this->assertEquals(5, count($response->json('data')));
}
/**
* 상품 생성 테스트
*/
public function test_can_create_product(): void
{
// Arrange
$productData = [
'name' => '테스트 상품',
'description' => '테스트 설명',
'price' => 10000,
'category_id' => $this->category->id,
'is_active' => true,
];
// Act
$response = $this->actingAs($this->adminUser)
->postJson('/api/admin/sirsoft-ecommerce/products', $productData);
// Assert
$response->assertStatus(201)
->assertJsonFragment(['name' => '테스트 상품']);
$this->assertDatabaseHas('products', [
'name' => '테스트 상품',
'price' => 10000,
]);
}
/**
* 유효성 검증 테스트
*/
public function test_validation_fails_without_required_fields(): void
{
// Act
$response = $this->actingAs($this->adminUser)
->postJson('/api/admin/sirsoft-ecommerce/products', []);
// Assert
$response->assertStatus(422)
->assertJsonValidationErrors(['name', 'price', 'category_id']);
}
}
```
### 체크리스트
**테스트 전**:
- [ ] 필요한 클래스/메서드 구현 완료
- [ ] 모델 관계 정의 완료
- [ ] API 리소스에서 민감 정보 제외
**테스트 중**:
- [ ] 실패 원인 분석
- [ ] 코드 수정
- [ ] 재실행
**테스트 후**:
- [ ] 모든 테스트 통과 확인
- [ ] 실패한 테스트 0건
---
## Mockery 사용 규칙
### 인터페이스 타입힌트 필수
```text
필수: Mock 객체 생성 시 인터페이스/클래스 타입 지정
잘못된 패턴: Mockery::mock() — 타입 미지정
올바른 패턴: Mockery::mock(PluginInterface::class)
```
**문제 상황**: 타입힌트 없는 Mock 객체가 반환 타입이 지정된 메서드에서 반환될 때 TypeError 발생
```php
// ❌ 문제 코드 - TypeError 발생
$mockPlugin = Mockery::mock();
$mockPlugin->shouldReceive('getSettingsSchema')->andReturn([]);
// 에러: Return value must be of type ?PluginInterface, Mockery_3 returned
```
```php
// ✅ 올바른 코드
use App\Contracts\Extension\PluginInterface;
$mockPlugin = Mockery::mock(PluginInterface::class);
$mockPlugin->shouldReceive('getSettingsSchema')->andReturn([]);
```
### Mock 객체 생성 패턴
```php
<?php
namespace Tests\Unit\Services;
use App\Contracts\Extension\PluginInterface;
use App\Extension\PluginManager;
use Mockery;
use Tests\TestCase;
class PluginServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close(); // 필수: 테스트 종료 시 Mock 정리
parent::tearDown();
}
public function test_example(): void
{
// ✅ 인터페이스 타입 지정
$mockPlugin = Mockery::mock(PluginInterface::class);
$mockPlugin->shouldReceive('getIdentifier')
->andReturn('test-plugin');
$mockPlugin->shouldReceive('hasSettings')
->andReturn(true);
// ✅ 클래스 타입 지정
$mockPluginManager = Mockery::mock(PluginManager::class);
$mockPluginManager->shouldReceive('getPlugin')
->with('test-plugin')
->andReturn($mockPlugin);
// 테스트 로직...
}
}
```
### Mockery 체크리스트
- [ ] `Mockery::mock(Interface::class)` 형태로 타입 지정
- [ ] `tearDown()`에서 `Mockery::close()` 호출
- [ ] 반환 타입이 있는 메서드는 해당 타입의 Mock 반환
---
## PHPUnit Attributes 사용
```
주의: PHPUnit 11.x부터 doc-comment annotation은 deprecated
필수: 새 테스트 작성 시 PHP 8 Attributes 사용 (@test, @dataProvider 등 doc-comment 사용 금지)
```
### 배경
- PHPUnit 11.x에서 doc-comment 기반 메타데이터 deprecated
- PHPUnit 12에서 완전히 제거 예정
- 기존 코드는 이미 PHP 8 Attributes로 변환 완료됨
### 사용 방법
**1. 기본 use 구문**
```php
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Depends;
```
**2. 테스트 메서드 정의**
```php
// ❌ DON'T: deprecated annotation
/**
* @test
*/
public function it_can_create_user(): void { ... }
// ✅ DO: PHP 8 Attribute
#[Test]
public function it_can_create_user(): void { ... }
```
**3. DataProvider 사용**
```php
// ❌ DON'T: deprecated annotation
/**
* @dataProvider validDataProvider
*/
public function test_validation(string $input, bool $expected): void { ... }
// ✅ DO: PHP 8 Attribute
#[Test]
#[DataProvider('validDataProvider')]
public function it_validates_input(string $input, bool $expected): void { ... }
```
**4. Group 지정**
```php
// 메서드 레벨
#[Test]
#[Group('slow')]
public function it_processes_large_data(): void { ... }
// 클래스 레벨
#[Group('module-dependent')]
class BoardModelTest extends TestCase { ... }
```
**5. Depends 사용**
```php
#[Test]
public function it_creates_user(): User
{
$user = User::factory()->create();
$this->assertNotNull($user);
return $user;
}
#[Test]
#[Depends('it_creates_user')]
public function it_updates_user(User $user): void
{
$user->update(['name' => 'Updated']);
$this->assertEquals('Updated', $user->fresh()->name);
}
```
### 메서드명 규칙
```
✅ DO: it_can_*, it_should_*, it_validates_* (Attribute 사용 시)
✅ DO: test_* (Attribute 없이도 자동 인식)
❌ DON'T: @test annotation과 test_ prefix 혼용
```
### 변환 도구
기존 annotation을 Attributes로 변환해야 하는 경우:
```bash
# 통계 확인
php artisan phpunit:convert-annotations --stats-only
# Dry-run (미리보기)
php artisan phpunit:convert-annotations --dry-run
# 실제 변환 (백업 포함)
php artisan phpunit:convert-annotations --backup
# 특정 경로만 변환
php artisan phpunit:convert-annotations --path=tests/Unit
```
### 체크리스트
```
□ 새 테스트 작성 시 #[Test] Attribute 사용했는가?
□ use PHPUnit\Framework\Attributes\Test; 추가했는가?
□ @test, @dataProvider 등 doc-comment annotation 사용하지 않았는가?
□ IDE에서 deprecation 경고가 없는가?
```
---
## Config 캐시와 테스트 환경
### 문제 설명
`php artisan config:cache` 실행 후 테스트를 실행하면 테스트 환경이 `testing`이 아닌 `local`로 적용됩니다.
```
주의: config 캐시가 존재하면 Laravel은 환경 변수를 무시
증상: app()->environment()가 'testing'이 아닌 'local' 반환
해결: tests/bootstrap.php에서 자동으로 config 캐시 삭제
```
### 근본 원인
1. `php artisan config:cache` 실행 시 `bootstrap/cache/config.php` 파일 생성
2. 이 캐시 파일에 `'env' => 'local'`이 하드코딩됨
3. Laravel은 **config 캐시 파일이 존재하면 환경 변수를 완전히 무시**
4. PHPUnit이 `APP_ENV=testing`을 설정해도 Laravel은 캐시에서 `local`을 읽어옴
### 검증 결과
| 상태 | `env('APP_ENV')` | `app()->environment()` |
|------|------------------|------------------------|
| 캐시 있음 | testing | **local** ← 문제 |
| 캐시 삭제 | testing | **testing** ← 정상 |
### 해결 방법
`tests/bootstrap.php`에서 PHPUnit 실행 시 자동으로 config 캐시를 삭제합니다:
```php
// tests/bootstrap.php
$configCacheFile = __DIR__.'/../bootstrap/cache/config.php';
if (file_exists($configCacheFile)) {
unlink($configCacheFile);
}
```
### 동작 방식
1. `php artisan config:cache` 실행 → 캐시 생성 (개발/운영 환경 성능 최적화)
2. `php artisan test` 실행 → bootstrap.php에서 캐시 자동 삭제 → testing 환경 적용
3. 다음 웹 요청 시 → 캐시 없으면 `.env` 파일에서 읽음
### 수동 해결 (참고용)
```bash
# config 캐시 삭제
php artisan config:clear
# 또는
rm bootstrap/cache/config.php
# 테스트 실행
php artisan test
```
---