본문 바로가기

Test/vitest

[vitest] Framer Motion 컴포넌트 테스트 구현하기 - Vitest와 Proxy 패턴 활용 ( feat. react )

 

Framer motion ( 요즘은 motion 이란 이름으로 더 알려져 있는) 라이브러리를 사용해서 화면 애니메이션을 구현하다보니...

테스트 시에 어찌해야할 지 감이 오지 않았습니다. 하지만 생각해보니 단위 테스트는 비즈니스 로직과 핵심 기능을 검증하는 것이 목적 이므로 할 필요가 없다는 결론에 도달했습니다😅😅😅

 

그렇지만 proxy 패턴을 써보았다는 거에 의의를 두고... 아래 내용은 남겨두도록 하겠습니다

 


 

 

다른 분께서 포스팅해놓은 글에서 힌트를 얻어 다행히 Framer motion 테스트를 할 수 있게 되었습니다😂

https://dev.to/tmikeschu/mocking-framer-motion-v4-19go

 

Mocking framer-motion v4

Testing Library has changed the UI testing game for the better. If you haven't tried it yet, check it...

dev.to

 

 

요점은 JavaScript의 Proxy 를 활용하여 코드는 framer-motion 라이브러리를 모킹(가짜로 흉내 내는 것)하면서, 컴포넌트의 속성(props)을 유연하게 처리하는 것 입니다. 

전체코드는 아래와 같습니다.

// Header.test.tsx

import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import React from 'react';
import Header from '../Header';

// framer-motion 모킹
vi.mock('framer-motion', () => {
  return vi.importActual<typeof import('framer-motion')>('../__mocks__/framerMotion');
});

// window.innerWidth 모킹을 위한 설정
const originalInnerWidth = window.innerWidth;

describe('Header 네비게이션 애니메이션', () => {
  beforeEach(() => {
    // 모바일 메뉴를 열어야 motion-ul이 렌더링됨
    render(<Header />);
    const menuButton = screen.getByLabelText('메뉴 열기');
    fireEvent.click(menuButton);
  });

  afterEach(() => {
    // 테스트 후 원래 window.innerWidth 복구
    window.innerWidth = originalInnerWidth;
  });

  it('모바일 상태에서 올바른 애니메이션 설정값을 가져야 함', () => {
    // 모바일 환경 설정
    window.innerWidth = 500;
    fireEvent(window, new Event('resize'));
    
    // AnimatePresence 내부의 ul 엘리먼트 찾기
    const motionUl = screen.getByRole('list');
    
    // 애니메이션 속성 테스트
    expect(JSON.parse(motionUl.dataset.initial!)).toEqual({ x: '-100%' });
    expect(JSON.parse(motionUl.dataset.animate!)).toEqual({ x: 0 });
    expect(JSON.parse(motionUl.dataset.transition!)).toEqual({
      type: "spring",
      stiffness: 300,
      damping: 30
    });
  });

  it('데스크톱 상태에서 올바른 애니메이션 설정값을 가져야 함', () => {
    // 데스크톱 환경 설정
    window.innerWidth = 1024;
    fireEvent(window, new Event('resize'));
    
    // AnimatePresence 내부의 ul 엘리먼트 찾기
    const motionUl = screen.getByRole('list');
    
    expect(JSON.parse(motionUl.dataset.initial!)).toEqual({ x: 0 });
    expect(JSON.parse(motionUl.dataset.transition!)).toEqual({
      duration: 0
    });
  });
});

 

//framerMotion.tsx
import React from 'react';

type MotionComponentProps = {
  children?: React.ReactNode;
  [key: string]: any;
};

// data- 접두사를 붙이지 않을 속성들
const PRESERVED_PROPS = ['aria-label', 'className', 'role', 'id', 'style'];

// 속성 이름을 소문자로 변환하는 함수, 변환하지 않으면 에러발생
const toLowerCaseKey = (key: string): string => {
  return key.toLowerCase();
};

// 동적 프로퍼티 접근을 위한 Proxy 패턴 사용
// framer-motion 모킹시 props 앞에 data- 를 붙이기 위해 사용
export const motion = new Proxy(
  {},
  {
    get: (_, tag: string) => {
      return function MotionComponent({ children, ...props }: MotionComponentProps) {
        const Tag = tag as keyof React.JSX.IntrinsicElements;
        return (
          <Tag
            data-testid={`motion-${tag}`}
            {...Object.entries(props).reduce((acc, [key, value]) => ({
              ...acc,
              ...(key.startsWith('data-') || PRESERVED_PROPS.includes(key)
                ? { [key]: value }
                : { [`data-${toLowerCaseKey(key)}`]: JSON.stringify(value) })
            }), {})}
          >
            {children}
          </Tag>
        );
      };
    },
  }
);

export const AnimatePresence = ({ children }: { children: React.ReactNode }) => (
  <div data-testid="animate-presence">{children}</div>
);

export default { motion, AnimatePresence };

 

1. 코드 개요

이 코드는 크게 두 가지 역할을 합니다.

  1.  motion: framer-motionmotion  API를 흉내 내는 Proxy 객체를 만듭니다. 이를 통해 motion.dev , motion.span 과 같이 HTML 태그를 동적으로 생성하고 애니메이션 관련 속성을 추가할 수 있습니다.
  2. AnimatePresence: framer-motion AnimatePresence 컴포넌트와 유사한 기능을 제공하는 간단한 래퍼 컴포넌트입니다.

2. 코드 분석

2.1. 임포트 및 타입 정의

import React from 'react';

type MotionComponentProps = {
  children?: React.ReactNode;
  [key: string]: any;
};
  • MotionComponentProps: motion 컴포넌트가 받을 수 있는 속성들의 타입을 정의합니다.
    • children?: React.ReactNode;: React 컴포넌트의 자식 요소들을 의미합니다. ?는 선택적인 속성임을 나타냅니다.
    • [key: string]: any;: 나머지 속성들을 key-value 형태로 받을 수 있도록 정의합니다. key는 문자열이고, value는 어떤 타입이든 가능합니다.

물론 타이트하게 타입을 설정할 수도 있지만, 여기서는 테스트란 목적에 중점을 두고 타입설정은 느슨하게 했습니다.

 

2.2. 보존해야 할 속성 목록

// data- 접두사를 붙이지 않을 속성들
const PRESERVED_PROPS = ['aria-label', 'className', 'role', 'id', 'style'];
  • PRESERVED_PROPS: 이 배열에 포함된 속성들은 data- 접두사를 붙이지 않고 그대로 사용할 속성들의 이름을 정의합니다. aria-label, className, role, id, style은 HTML에서 자주 사용되는 속성들이죠.

2.3. motion Proxy 객체 생성

// 동적 프로퍼티 접근을 위한 Proxy 패턴 사용
// framer-motion 모킹시 props 앞에 data- 를 붙이기 위해 사용
export const motion = new Proxy(
  {},
  {
    get: (_, tag: string) => {
      return function MotionComponent({ children, ...props }: MotionComponentProps) {
        const Tag = tag as keyof React.JSX.IntrinsicElements;
        return (
          <Tag
            data-testid={`motion-${tag}`}
            {...Object.entries(props).reduce((acc, [key, value]) => ({
              ...acc,
              ...(key.startsWith('data-') || PRESERVED_PROPS.includes(key)
                ? { [key]: value }
                : { [`data-${key}`]: JSON.stringify(value) })
            }), {})}
          >
            {children}
          </Tag>
        );
      };
    },
  }
);
  • Proxy: JavaScript의 Proxy 객체는 객체의 기본적인 동작(속성 접근, 할당, 열거 등)을 가로채고 재정의할 수 있게 해줍니다. 여기서는 motion.div, motion.span과 같이 동적으로 태그를 생성하는 기능을 구현하기 위해 사용됩니다.
  • get: (_, tag: string) => { ... }: Proxy 객체의 get 트랩(trap)은 객체의 속성에 접근하려고 할 때 실행되는 함수입니다. 여기서 tag는 접근하려는 속성 이름(예: 'div', 'span')을 나타냅니다.
  • MotionComponent: get 트랩 내에서 반환되는 React 컴포넌트입니다. 이 컴포넌트는 childrenprops를 받아서 HTML 태그를 생성하고, 속성들을 적용합니다.
  • const Tag = tag as keyof React.JSX.IntrinsicElements;: tag 문자열을 React.JSX.IntrinsicElements의 키로 타입 변환합니다. React.JSX.IntrinsicElements는 React가 지원하는 HTML 태그들의 타입을 정의하는 인터페이스입니다. 


JSX.IntrinsicElements 이란?

더보기

React에서 `JSX.IntrinsicElements`는 TypeScript를 사용할 때 JSX 요소의 타입 검사를 위해 특별히 사용되는 인터페이스입니다.

  1. JSX.IntrinsicElements의 역할
    • HTML 요소 타입 정의: `JSX.IntrinsicElements`는 TSX에서 사용 가능한 모든 HTML 요소의 타입을 나열하고, 각 요소의 태그 이름을 속성 및 자식 타입에 매핑합니다.
    • 내장 요소 조회: JSX의 내장 요소는 `JSX.IntrinsicElements` 인터페이스에서 조회됩니다. 인터페이스가 지정되지 않은 경우, 내장 요소의 타입 검사는 건너뜁니다. 그러나 인터페이스가 있는 경우, 내장 요소의 이름은 `JSX.IntrinsicElements` 인터페이스의 속성으로 조회됩니다.
    • 속성 타입 정의: 요소 속성의 타입은 `JSX.IntrinsicElements`의 프로퍼티 타입과 동일합니다.
    • JSX 타입 검사 활성화: `JSX.IntrinsicElements` 인터페이스를 선언하면, JSX 내의 HTML 태그에 대한 타입 검사가 활성화됩니다.
    • 사용자 정의 속성 지원: `JSX.IntrinsicElements`에 catch-all 문자열 인덱서를 지정하여 사용자 정의 속성을 지원할 수 있습니다.

  2. JSX.IntrinsicElements 사용 예시
declare namespace JSX {
  interface IntrinsicElements {
    foo: any;
  }
}

<foo />; // 성공
<bar />; // 오류


위 예시에서 `<foo />`는 성공적으로 동작하지만, `<bar />`는 `JSX.IntrinsicElements`에 지정되지 않았기 때문에 오류가 발생합니다.

  • data-testid={\motion-${tag}`}: 테스트를 위한data-testid` 속성을 추가합니다.
  • {...Object.entries(props).reduce((acc, [key, value]) => ({ ... }), {})}: props 객체를 순회하면서 각 속성에 data- 접두사를 붙일지 여부를 결정하는 로직입니다.
    • Object.entries(props): props 객체를 [key, value] 형태의 배열로 변환합니다.
    • reduce((acc, [key, value]) => ({ ... }), {}): 배열의 각 요소를 순회하면서 누적값(acc)을 만들어갑니다. 초기값은 빈 객체({})입니다.
    • key.startsWith('data-') || PRESERVED_PROPS.includes(key): 속성 이름이 data-로 시작하거나, PRESERVED_PROPS 배열에 포함되어 있다면, data- 접두사를 붙이지 않고 그대로 사용합니다.
    • { [\data-${key}`]: JSON.stringify(value) }: 그렇지 않다면, 속성 이름에data-접두사를 붙이고, 값을JSON.stringify를 사용하여 문자열로 변환합니다. 이렇게 하는 이유는framer-motion이 특정 속성들을data-` 속성으로 처리하기 때문입니다. 그리고 객체를 반환하기 때문에 문자열로 변경해줘야 합니다.
  • {children}: 컴포넌트의 자식 요소들을 렌더링합니다.

2.4. AnimatePresence 컴포넌트 생성

export const AnimatePresence = ({ children }: { children: React.ReactNode }) => (
  <div data-testid="animate-presence">{children}</div>
);
  • AnimatePresence: 이 컴포넌트는 framer-motionAnimatePresence 컴포넌트와 유사하게, 자식 요소가 나타나거나 사라질 때 애니메이션을 적용하는 기능을 제공합니다. 여기서는 간단하게 data-testid 속성을 가진 div 태그로 감싸는 역할만 합니다.

2.5. 모듈 내보내기

export default { motion, AnimatePresence };
  • export default { motion, AnimatePresence };: motion Proxy 객체와 AnimatePresence 컴포넌트를 다른 파일에서 사용할 수 있도록 내보냅니다.

3. 사용 예시

import React from 'react';
import { motion } from './motion'; // motion.ts 파일

function MyComponent() {
  return (
    <motion.div
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      transition={{ duration: 0.5 }}
      className="my-div"
    >
      Hello, Motion!
    </motion.div>
  );
}

export default MyComponent;
  • motion.div: motion Proxy 객체를 통해 div 태그를 생성하고, initial, animate, transition과 같은 애니메이션 관련 속성을 추가합니다. 이러한 속성들은 실제로 data-initial, data-animate, data-transition 형태로 HTML 요소에 추가될 것입니다.

 

4. proxy 를 사용하지 않았다면?

 

4.1. 위 코드에서의 프록시 패턴

motion 객체는 Proxy를 사용하여 framer-motion 라이브러리의 API를 모방하고 있습니다. 

motion.div, motion.span과 같이 HTML 태그에 접근할 때, Proxy의 get 트랩이 실행되어 동적으로 컴포넌트를 생성하고 속성을 처리합니다. 

 

Proxy를 사용함으로써, 실제 DOM 요소에 직접 접근하지 않고도 속성을 가공하고 추가 기능을 제공할 수 있습니다.

특히, framer-motion에서 특정 속성을 data- 속성 형태로 처리하는 방식을 모방하기 위해, 속성 이름에 따라 data- 접두사를 추가하는 로직이 Proxy 내부에 구현되어 있습니다.

 

4.2. 프록시 패턴을 사용하지 않았다면?

프록시 패턴을 사용하지 않았다면, 각 HTML 태그 (div, span, button 등)에 대한 React 컴포넌트를 직접 만들어야 하고, 각 컴포넌트마다 속성을 처리하는 로직을 반복적으로 구현해야 합니다. 

import React from 'react';

type MotionDivProps = {
  children?: React.ReactNode;
  [key: string]: any;
};

const PRESERVED_PROPS = ['aria-label', 'className', 'role', 'id', 'style'];

const MotionDiv = ({ children, ...props }: MotionDivProps) => {
  return (
    <div
      data-testid="motion-div"
      {...Object.entries(props).reduce((acc, [key, value]) => ({
        ...acc,
        ...(key.startsWith('data-') || PRESERVED_PROPS.includes(key)
          ? { [key]: value }
          : { [`data-${key}`]: JSON.stringify(value) })
      }), {})}
    >
      {children}
    </div>
  );
};

const MotionSpan = () => {}
const MotionButton = () => {}

export default MotionDiv;


이러한 방식으로 motion.span, motion.button 등 다른 태그에 대한 컴포넌트를 각각 만들어야 합니다. 만약 속성 처리 로직이 변경된다면, 모든 컴포넌트를 수정해야 하는 번거로움이 있습니다. Proxy를 사용하면 이러한 중복을 피하고, 속성 처리 로직을 중앙 집중적으로 관리할 수 있습니다.

 

 

5. 정리

이 코드는 React와 Proxy를 활용하여 동적으로 애니메이션 컴포넌트를 만드는 방법을 보여줍니다. motion Proxy 객체는 framer-motion 라이브러리의 API를 모킹하면서, 컴포넌트의 속성을 유연하게 처리할 수 있도록 해줍니다. 또한, AnimatePresence 컴포넌트는 자식 요소의 애니메이션을 관리하는 데 사용됩니다. 혹시 저처럼 framer-motion에 대한 테스트를 고민하시는 분이 계셨다면 도움이 되었다면 좋겠습니다.😊