18 KiB
18 KiB
그누보드7 레이아웃 파일 렌더링 테스트 가이드
이 문서는 레이아웃 JSON 파일을 실제 렌더링하고 런타임 동작을 검증하는 테스트 시스템 사용법을 설명합니다.
TL;DR (5초 요약)
1. createLayoutTest()로 테스트 헬퍼 생성, mockApi()로 API 응답 모킹
2. render() 후 validateSlots()와 validateDataBindings()로 레이아웃 검증
3. assertNoValidationErrors()로 슬롯 누락/타입 불일치 자동 감지
4. API 응답 구조: { success: true, data: { data: [], meta: {} } }
5. cleanup() 필수 호출로 테스트 격리
목차
개요
목적
레이아웃 테스트 시스템은 다음 목적으로 사용됩니다:
| 용도 | 설명 |
|---|---|
| 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가 필수입니다:
resolve: {
alias: {
'@core': path.resolve(projectRoot, 'resources/js/core'),
},
},
테스트 파일에서의 import 패턴:
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 디렉티브를 추가합니다:
// @vitest-environment jsdom
import '@testing-library/jest-dom';
기본 사용법
1. 테스트 헬퍼 생성
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 모킹 및 렌더링
// 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. 정리 (필수)
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 응답을 모킹합니다.
// 성공 응답
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 에러를 시뮬레이션합니다.
testUtils.mockApiError('products', 500, '서버 에러');
testUtils.mockApiError('products', 404, '리소스를 찾을 수 없습니다');
setState(path, value, target)
상태를 설정합니다.
// local 상태
testUtils.setState('filterVisible', true, 'local');
testUtils.setState('filter.keyword', '검색어', 'local');
// global 상태
testUtils.setState('user', { id: 1, name: 'Admin' }, 'global');
triggerAction(actionDef)
액션을 트리거합니다.
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 },
});
테스트 패턴
컴포넌트 레지스트리 설정
테스트에서는 실제 컴포넌트 대신 간단한 테스트 컴포넌트를 사용합니다:
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,
});
조건부 렌더링 테스트
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 테스트
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();
});
토스트/모달 테스트
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();
});
인증 상태 테스트
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가 있지만 등록된 컴포넌트가 없음 (정보) |
버그 있는 레이아웃 예시
{
"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가 없어서 화면에 표시되지 않습니다.
올바른 레이아웃
{
"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 |
타입 불일치 예시
{
"props": {
"data": "{{coupons?.data || []}}"
}
}
coupons?.data는 { data: [], meta: {} } 객체를 반환합니다. DataGrid는 배열을 기대하므로 data_type_mismatch 경고가 발생합니다.
올바른 바인딩
{
"props": {
"data": "{{coupons?.data?.data || []}}"
}
}
검증 사용 예시
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 -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 - 전체 테스트 가이드
- layout-json.md - 레이아웃 JSON 스키마
- data-sources.md - 데이터소스 정의
- state-management.md - 상태 관리
- actions.md - 액션 핸들러