Files
Gnuboard7/docs/frontend/layout-testing.md
T
2026-04-01 10:30:52 +09:00

724 lines
18 KiB
Markdown

# 그누보드7 레이아웃 파일 렌더링 테스트 가이드
> 이 문서는 레이아웃 JSON 파일을 실제 렌더링하고 런타임 동작을 검증하는 테스트 시스템 사용법을 설명합니다.
---
## TL;DR (5초 요약)
```text
1. createLayoutTest()로 테스트 헬퍼 생성, mockApi()로 API 응답 모킹
2. render() 후 validateSlots()와 validateDataBindings()로 레이아웃 검증
3. assertNoValidationErrors()로 슬롯 누락/타입 불일치 자동 감지
4. API 응답 구조: { success: true, data: { data: [], meta: {} } }
5. cleanup() 필수 호출로 테스트 격리
```
---
## 목차
- [개요](#개요)
- [파일 위치](#파일-위치)
- [기본 사용법](#기본-사용법)
- [API 레퍼런스](#api-레퍼런스)
- [테스트 패턴](#테스트-패턴)
- [레이아웃 검증 기능](#레이아웃-검증-기능)
- [테스트 실행](#테스트-실행)
- [체크리스트](#체크리스트)
---
## 개요
### 목적
레이아웃 테스트 시스템은 다음 목적으로 사용됩니다:
| 용도 | 설명 |
|------|------|
| **TDD** | 새 기능/UI 개발 시 요구사항 커버리지 확인 |
| **BDD** | 개발 후 동작 검증 |
| **회귀 테스트** | 버그 수정 후 재발 방지 |
### 핵심 특징
- **실제 렌더링**: DynamicRenderer를 통한 실제 React 렌더링
- **API 모킹**: fetch 모킹으로 데이터소스 응답 제어
- **상태 관리**: `_global`, `_local` 상태 조작 및 검증
- **액션 트리거**: navigate, setState 등 액션 실행
- **모달/토스트**: UI 피드백 추적
- **다국어**: TranslationEngine 연동
---
## 파일 위치
### 테스트 유틸리티 (공유)
```
resources/js/core/template-engine/__tests__/
├── utils/
│ ├── mockApiUtils.ts # API 모킹 유틸리티
│ └── layoutTestUtils.ts # 레이아웃 테스트 헬퍼
└── layouts/
└── example.test.tsx # createLayoutTest() 사용 예제 (코어 전용)
```
### 레이아웃 렌더링 테스트 위치 규칙
```
필수: 레이아웃 렌더링 테스트는 해당 레이아웃이 속한 확장(모듈/템플릿) 디렉토리에 작성 (코어 디렉토리에 모듈/템플릿 테스트 작성 금지)
코어 __tests__/layouts/에는 example.test.tsx(유틸리티 예제)만 존재
```
| 레이아웃 소속 | 테스트 파일 위치 |
|-------------|-----------------|
| 모듈 (`modules/**/resources/layouts/**`) | `modules/_bundled/{id}/resources/js/__tests__/layouts/*.test.tsx` |
| 템플릿 (`templates/**/layouts/**`) | `templates/_bundled/{id}/__tests__/layouts/*.test.tsx` |
| 코어 (`resources/layouts/**`) | `resources/js/core/template-engine/__tests__/layouts/*.test.tsx` |
### 확장 디렉토리 테스트 구조
```
modules/_bundled/{vendor-module}/
├── resources/
│ ├── layouts/admin/*.json # ← 레이아웃 JSON
│ └── js/__tests__/
│ └── layouts/*.test.tsx # ← 해당 레이아웃 테스트
├── package.json
└── vitest.config.ts
templates/_bundled/{vendor-template}/
├── layouts/**/*.json # ← 레이아웃 JSON
├── __tests__/
│ └── layouts/*.test.tsx # ← 해당 레이아웃 테스트
└── vitest.config.ts
```
### 확장 vitest.config.ts 필수 설정
모듈/템플릿에서 `createLayoutTest()`를 사용하려면 `@core` alias가 필수입니다:
```typescript
resolve: {
alias: {
'@core': path.resolve(projectRoot, 'resources/js/core'),
},
},
```
테스트 파일에서의 import 패턴:
```typescript
import { createLayoutTest, screen } from '@core/template-engine/__tests__/utils/layoutTestUtils';
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
```
### jsdom 환경 설정
모듈의 vitest.config.ts가 `environment: 'node'`인 경우, 레이아웃 테스트 파일 상단에 per-file 디렉티브를 추가합니다:
```typescript
// @vitest-environment jsdom
import '@testing-library/jest-dom';
```
---
## 기본 사용법
### 1. 테스트 헬퍼 생성
```typescript
import { createLayoutTest, screen } from '../utils/layoutTestUtils';
const layoutJson = {
version: '1.0.0',
layout_name: 'test_layout',
components: [
{
id: 'page-header',
type: 'composite',
name: 'PageHeader',
props: { title: '$t:test.title' },
},
{
id: 'filter-button',
type: 'basic',
name: 'Button',
text: '필터',
props: { 'data-testid': 'filter-button' },
},
],
data_sources: [
{
id: 'products',
type: 'api',
endpoint: '/api/admin/products',
auto_fetch: true,
},
],
};
const testUtils = createLayoutTest(layoutJson, {
translations: { test: { title: '테스트 페이지' } },
locale: 'ko',
});
```
### 2. API 모킹 및 렌더링
```typescript
// API 응답 모킹 (필수: render() 전에 호출)
testUtils.mockApi('products', {
response: { data: [{ id: 1, name: '상품1' }] },
});
// 렌더링
await testUtils.render();
// 요소 검증
expect(screen.getByText('테스트 페이지')).toBeInTheDocument();
expect(screen.getByTestId('filter-button')).toBeInTheDocument();
```
### 3. 정리 (필수)
```typescript
afterEach(() => {
testUtils.cleanup();
});
```
---
## API 레퍼런스
### createLayoutTest(layoutJson, options)
레이아웃 테스트 헬퍼를 생성합니다.
#### Options
| 옵션 | 타입 | 설명 |
|------|------|------|
| `initialState` | `{ _local?: object, _global?: object }` | 초기 상태 |
| `routeParams` | `Record<string, string>` | URL 파라미터 (route.id 등) |
| `queryParams` | `Record<string, string>` | 쿼리 파라미터 |
| `auth` | `{ isAuthenticated, user?, authType? }` | 인증 상태 |
| `translations` | `Record<string, any>` | 다국어 데이터 |
| `locale` | `'ko' \| 'en'` | 현재 로케일 |
| `initialData` | `Record<string, any>` | 데이터소스 초기 데이터 |
| `componentRegistry` | `ComponentRegistry` | 컴포넌트 레지스트리 |
| `templateId` | `string` | 템플릿 ID |
#### 반환 객체
| 메서드 | 설명 |
|--------|------|
| `render()` | 레이아웃 렌더링 |
| `rerender()` | 리렌더링 |
| `mockApi(id, options)` | API 응답 모킹 |
| `mockApiError(id, status, message)` | API 에러 모킹 |
| `getState()` | 현재 상태 조회 (`_local`, `_global`) |
| `setState(path, value, target)` | 상태 설정 |
| `triggerAction(actionDef)` | 액션 트리거 |
| `openModal(modalId)` | 모달 열기 |
| `closeModal()` | 모달 닫기 |
| `getModalStack()` | 모달 스택 조회 |
| `getToasts()` | 토스트 목록 조회 |
| `getNavigationHistory()` | 네비게이션 이력 |
| `mockNavigate` | navigate mock 함수 |
| `waitForDataSource(id)` | 데이터소스 로딩 대기 |
| `waitForState(path, value)` | 상태 대기 |
| `cleanup()` | 정리 (필수) |
| `user` | userEvent 인스턴스 |
| `screen` | React Testing Library screen |
### mockApi(dataSourceId, options)
데이터소스 API 응답을 모킹합니다.
```typescript
// 성공 응답
testUtils.mockApi('products', {
response: { data: [{ id: 1, name: '상품' }] },
});
// 지연 시뮬레이션
testUtils.mockApi('products', {
response: { data: [] },
delay: 1000, // 1초 지연
});
// 간단한 형태
testUtils.mockApi('products', { data: [] });
```
### mockApiError(dataSourceId, status, message)
API 에러를 시뮬레이션합니다.
```typescript
testUtils.mockApiError('products', 500, '서버 에러');
testUtils.mockApiError('products', 404, '리소스를 찾을 수 없습니다');
```
### setState(path, value, target)
상태를 설정합니다.
```typescript
// local 상태
testUtils.setState('filterVisible', true, 'local');
testUtils.setState('filter.keyword', '검색어', 'local');
// global 상태
testUtils.setState('user', { id: 1, name: 'Admin' }, 'global');
```
### triggerAction(actionDef)
액션을 트리거합니다.
```typescript
await testUtils.triggerAction({
type: 'click',
handler: 'navigate',
params: { path: '/admin/products/1/edit' },
});
await testUtils.triggerAction({
type: 'click',
handler: 'setState',
params: { target: 'local', filterVisible: true },
});
```
---
## 테스트 패턴
### 컴포넌트 레지스트리 설정
테스트에서는 실제 컴포넌트 대신 간단한 테스트 컴포넌트를 사용합니다:
```typescript
import { ComponentRegistry } from '../../ComponentRegistry';
// 테스트용 컴포넌트
const TestButton: React.FC<{ text?: string; onClick?: () => void }> =
({ text, onClick }) => <button onClick={onClick}>{text}</button>;
const TestInput: React.FC<{ placeholder?: string }> =
({ placeholder }) => <input placeholder={placeholder} />;
// 레지스트리 설정
function setupTestRegistry(): ComponentRegistry {
const registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Button: {
component: TestButton,
metadata: { name: 'Button', type: 'basic' },
},
Input: {
component: TestInput,
metadata: { name: 'Input', type: 'basic' },
},
};
return registry;
}
// 사용
const registry = setupTestRegistry();
const testUtils = createLayoutTest(layoutJson, {
componentRegistry: registry,
});
```
### 조건부 렌더링 테스트
```typescript
it('조건부 렌더링이 동작한다', async () => {
const layoutJson = {
components: [
{
id: 'panel',
type: 'composite',
name: 'Panel',
if: '{{_local.visible}}',
props: { 'data-testid': 'panel' },
},
],
};
const testUtils = createLayoutTest(layoutJson, { componentRegistry: registry });
testUtils.mockApi('products', { response: { data: [] } });
await testUtils.render();
// 초기: 보이지 않음
expect(screen.queryByTestId('panel')).not.toBeInTheDocument();
// 상태 변경 후 리렌더링
testUtils.setState('visible', true, 'local');
await testUtils.rerender();
expect(screen.getByTestId('panel')).toBeInTheDocument();
testUtils.cleanup();
});
```
### iteration 테스트
```typescript
it('iteration에서 각 행이 올바른 데이터를 참조한다', async () => {
const layoutJson = {
components: [
{
id: 'list',
type: 'layout',
name: 'Grid',
iteration: {
source: 'products.data',
item_var: 'product',
index_var: 'idx',
},
children: [
{
id: 'item-{{idx}}',
type: 'basic',
name: 'Text',
text: '{{product.name}}',
},
],
},
],
};
const testUtils = createLayoutTest(layoutJson, {
componentRegistry: registry,
initialData: {
products: {
data: [
{ id: 1, name: '첫번째' },
{ id: 2, name: '두번째' },
],
},
},
});
await testUtils.render();
expect(screen.getByText('첫번째')).toBeInTheDocument();
expect(screen.getByText('두번째')).toBeInTheDocument();
testUtils.cleanup();
});
```
### 토스트/모달 테스트
```typescript
it('토스트가 표시된다', async () => {
testUtils.mockApi('products', { response: { data: [] } });
await testUtils.render();
// G7Core.toast 직접 호출
const G7Core = (window as any).G7Core;
G7Core.toast.success('저장되었습니다');
const toasts = testUtils.getToasts();
expect(toasts).toContainEqual({
type: 'success',
message: '저장되었습니다',
});
testUtils.cleanup();
});
it('모달이 열린다', async () => {
testUtils.mockApi('products', { response: { data: [] } });
await testUtils.render();
// G7Core.modal 직접 호출
const G7Core = (window as any).G7Core;
G7Core.modal.open('confirm-modal');
expect(testUtils.getModalStack()).toContain('confirm-modal');
testUtils.cleanup();
});
```
### 인증 상태 테스트
```typescript
it('인증된 상태로 렌더링한다', async () => {
const testUtils = createLayoutTest(layoutJson, {
auth: {
isAuthenticated: true,
user: { id: 1, name: 'Admin', role: 'super_admin' },
authType: 'admin',
},
componentRegistry: registry,
});
testUtils.mockApi('products', { response: { data: [] } });
await testUtils.render();
// 인증된 사용자만 볼 수 있는 요소 확인
expect(screen.getByText('관리자')).toBeInTheDocument();
testUtils.cleanup();
});
```
---
## 레이아웃 검증 기능
레이아웃 테스트 유틸리티는 런타임에 발생할 수 있는 버그를 사전에 감지하는 검증 기능을 제공합니다.
### 검증 API
| 메서드 | 설명 |
|--------|------|
| `validateSlots()` | 슬롯 시스템 검증 (slot 속성 vs SlotContainer 매칭) |
| `validateDataBindings()` | 데이터 바인딩 타입 검증 (배열/객체 불일치) |
| `getValidationWarnings()` | 모든 검증 경고 조회 |
| `assertNoValidationErrors()` | 검증 오류 시 예외 발생 |
### API 응답 구조
mockApi로 설정한 응답은 자동으로 그누보드7 API 응답 구조로 래핑됩니다:
```
mockApi 설정 → fetch mock → 실제 응답 구조
mockApi('coupons', { response: { data: [], meta: {} } })
↓
fetch().json() = { success: true, data: { data: [], meta: {} } }
↓
레이아웃에서 접근:
- coupons = { success: true, data: { data: [], meta: {} } }
- coupons?.data = { data: [], meta: {} } ← 객체!
- coupons?.data?.data = [] ← 배열! (올바른 바인딩)
```
### 슬롯 시스템 검증 (validateSlots)
`slot` 속성으로 등록된 컴포넌트에 대응하는 `SlotContainer`가 있는지 검증합니다.
#### 검증 항목
| 타입 | 설명 |
|------|------|
| `missing_slotcontainer` | slot 속성이 있지만 SlotContainer가 없음 (버그) |
| `orphan_slot` | SlotContainer가 있지만 등록된 컴포넌트가 없음 (정보) |
#### 버그 있는 레이아웃 예시
```json
{
"slots": {
"content": [
{
"id": "filter_row",
"type": "basic",
"name": "Div",
"slot": "basic_filters"
},
{
"id": "filter_display",
"type": "basic",
"name": "Div",
"slot": "basic_filters"
}
]
}
}
```
위 레이아웃에서 `filter_row`는 `basic_filters` 슬롯에 등록되지만, 해당 슬롯을 렌더링하는 `SlotContainer`가 없어서 화면에 표시되지 않습니다.
#### 올바른 레이아웃
```json
{
"slots": {
"content": [
{
"id": "filter_row",
"type": "basic",
"name": "Div",
"slot": "basic_filters"
},
{
"id": "filter_container",
"type": "composite",
"name": "SlotContainer",
"props": { "slotId": "basic_filters" }
}
]
}
}
```
### 데이터 바인딩 타입 검증 (validateDataBindings)
배열이 필요한 컴포넌트 prop에 객체가 바인딩되는지 검증합니다.
#### 검증 대상 컴포넌트
| 컴포넌트 | 배열 필요 props |
|----------|----------------|
| `DataGrid` | `data`, `rows` |
| `Table` | `data`, `rows` |
| `Select` | `options` |
| `RichSelect` | `options` |
| `CheckboxGroup` | `options` |
| `RadioGroup` | `options` |
#### 타입 불일치 예시
```json
{
"props": {
"data": "{{coupons?.data || []}}"
}
}
```
`coupons?.data`는 `{ data: [], meta: {} }` 객체를 반환합니다. DataGrid는 배열을 기대하므로 `data_type_mismatch` 경고가 발생합니다.
#### 올바른 바인딩
```json
{
"props": {
"data": "{{coupons?.data?.data || []}}"
}
}
```
### 검증 사용 예시
```typescript
it('레이아웃 검증 오류가 없어야 함', async () => {
const { render, assertNoValidationErrors, cleanup } = createLayoutTest(layoutJson);
mockApi('items', {
response: {
data: [{ id: 1 }],
meta: { total: 1 }
}
});
await render();
// 모든 검증 통과 확인
expect(() => assertNoValidationErrors()).not.toThrow();
cleanup();
});
it('개별 검증 경고 확인', async () => {
const { render, validateSlots, validateDataBindings, cleanup } = createLayoutTest(layoutJson);
await render();
const slotWarnings = validateSlots();
const bindingWarnings = validateDataBindings();
// 특정 경고 타입 확인
expect(slotWarnings.some(w => w.type === 'missing_slotcontainer')).toBe(false);
expect(bindingWarnings.some(w => w.type === 'data_type_mismatch')).toBe(false);
cleanup();
});
```
---
## 테스트 실행
### 명령어
```powershell
# 레이아웃 테스트만 실행
powershell -Command "npm run test:run -- layouts/example.test"
# 유틸리티 테스트 실행
powershell -Command "npm run test:run -- __tests__/utils"
# 특정 테스트 파일
powershell -Command "npm run test:run -- layoutTestUtils"
# 전체 템플릿 엔진 테스트
powershell -Command "npm run test:run -- template-engine"
```
### 테스트 파일 네이밍
```
__tests__/layouts/
├── [feature].test.tsx # 기능별 테스트
├── [screen_name].test.tsx # 화면별 테스트
└── regression/
└── [issue_id].test.tsx # 회귀 테스트 (버그 번호)
```
---
## 체크리스트
### 테스트 작성 전
```
□ 테스트할 레이아웃 JSON 준비
□ 필요한 컴포넌트 목록 확인
□ 테스트용 컴포넌트 레지스트리 설정
□ 다국어 키 준비 ($t: 사용 시)
```
### 테스트 작성 중
```
□ mockApi() 호출 후 render() 호출
□ 비동기 작업 후 waitFor() 또는 findBy* 사용
□ screen.getByTestId() 또는 getByText()로 요소 검증
□ 상태 변경 후 rerender() 호출
```
### 테스트 작성 후
```
□ cleanup() 호출 확인 (afterEach)
□ 테스트 실행 및 통과 확인
□ 테스트 격리 확인 (다른 테스트에 영향 없음)
```
### 금지 사항
```
❌ cleanup() 없이 테스트 종료
❌ render() 전에 screen API 호출
❌ mockApi() 없이 auto_fetch 데이터소스 사용
❌ 비동기 작업 후 즉시 assertion (waitFor 필요)
```
---
## 관련 문서
- [testing-guide.md](../testing-guide.md) - 전체 테스트 가이드
- [layout-json.md](layout-json.md) - 레이아웃 JSON 스키마
- [data-sources.md](data-sources.md) - 데이터소스 정의
- [state-management.md](state-management.md) - 상태 관리
- [actions.md](actions.md) - 액션 핸들러