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

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 필요)

관련 문서