[React] 반응형UI에 대처하는 테스트의 자세

2022. 3. 29. 22:55공부내용 공유하기

반응형 컴포넌트

React를 사용해서 반응형 웹을 만들어 본 사람이라면, 반응형 레이아웃에 대응하기 위해 아래와 같은 컴포넌트(혹은 비슷한 역할을 하는 컴포넌트)를 만들어 본 경험이 있을 거라 생각한다.

 

예시 1

레이아웃에 따라 렌더링 할 컴포넌트를 분기하는 Switch 컴포넌트

 

예시 2

children의 노출 여부를 명시하는 Display 컴포넌트

 

 

두 컴포넌트의 공통점은 무엇일까?

 

바로 반응형 레이아웃에 대한 처리를 JSX내부에서 바로 확인할 수 있게끔 컴포넌트화 했다는 점이다.

별도의 스타일 코드로 이동해 확인할 필요가 없고, 직관적으로 어떤 컴포넌트들이 반응형에 따른 분기 처리가 되는지 알 수 있으니 장점이 많다 하겠다.

 

테스트 코드와 함께 사용 시 발생하는 문제점

하지만 이 컴포넌트는 아쉽게도 테스트 코드 작성 시에 문제가 있다.

 

위와 같은 컴포넌트를 구현할 때는 보통 컴포넌트 내부에서 미디어 쿼리를 사용하여 display:none으로 숨겨야 할 컴포넌트들을 렌더 트리에서 제외하는 방식을 사용한다. (resize 이벤트를 감지하여 css가 아닌 js로도 구현이 가능하지만 layout shift 등의 이슈가 있어 권장하지 않는다)

 

우리 회사에서는 파트에서는 테스트를 위해 Jest + jsDom + React Testing Library 조합을 사용하고 있는데, jsDom은 브라우저의 렌더링과는 달리 Dom Tree는 생성하지만 Cssom Tree는 생성하지 않고, 그렇기 때문에 Painting + Layout 과정이 일어나지 않는다.

 

따라서 위 조합으로는 미디어 쿼리에 적용된 css를 해석하지 못한다.

 

jsDom의 기본 크기는 1024 * 768로 설정되어 있지만, 이 크기는 window객체에 부여된 너비/높이일 뿐 실제로 이 너비에 따라 미디어 쿼리가 동작하지는 않는다. (window.matchMedia는 동작한다)

 

Set window width in jsDom?

Should be a simple question. How do I set the width in a jsDom object? jsdom.env({ url:'http://testdatalocation', scripts: ['http://code.jquery.com/jquery.js'], done:

stackoverflow.com

미디어 쿼리를 인식하지 못하고 실제 렌더링 엔진도 가지고 있지 않은 테스트 환경에서는, testing library의 쿼리 method 사용 시 보이지 말아야 할 요소도 보이게 되는 것이다.

 

문제 해결을 위한 방법

이 문제를 해결하기 위해 사용한 방법은 다음과 같다 (Display 컴포넌트 기준)

 

1. Dispaly 컴포넌트 내부에 data attribute를 사용해서 컴포넌트의 반응형 정보를 기재한다.

const Display = ({ children, ...restProps }: PropsWithChildren<DisplayProps>) => {
  const { mobile, tablet, desktop } = restProps

  return (
    <DisplayContainer
      data-not-mobile={!mobile}
      data-not-tablet={!tablet}
      data-not-desktop={!desktop}
      {...restProps}
    >
      {children}
    </DisplayContainer>
  );
};

 

2. Display 컴포넌트에서 사용한 data attribute를 querySelectorAll로 쿼리 해서 지워야 하는 NodeList를 가져오고, 해당 element들을 제거하는 removeOtherMediaQueryDisplay method를 만든다.

export type DeviceSize = 'desktop' | 'mobile' | 'tablet';

const removeOtherMediaQueryDisplay = (size: DeviceSize = 'desktop') => {
  const targets = (() => {
    switch (size) {
      case 'desktop':
        return document.querySelectorAll(`[data-not-desktop]='true'`);
      case 'tablet':
        return document.querySelectorAll(`[data-not-tablet]='true'`);
      case 'mobile':
        return document.querySelectorAll(`[data-not-mobile]='true'`);
    }
  })();
  removeElements(targets);
};

const removeElements = (targetElements: NodeListOf<Element>): void => {
  targetElements.forEach((element) => {
    element?.parentNode?.removeChild(element);
  });
};

 

3. React Testing Library의 render method를 커스텀하게 사용한다면, defaultRender 이후 removeOtherMediaQueryDisplay method를 호출해준다. (커스텀하게 사용하지 않는다면 이번 기회에 사용해 보시는 것도?)

import { render as defaultRender, RenderOptions } from '@testing-library/react';

type BaseRenderOptions = Omit<RenderOptions, 'wrapper'> & {
  media?: DeviceSize;
};
const baseRender = (ui: ReactElement, options?: BaseRenderOptions) => {
  const BaseProviders: FC = ({ children }) => {
    // ...생략
    
    return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
  };

  const renderReturn = defaultRender(ui, { wrapper: BaseProviders, ...options });

  removeOtherMediaQueryDisplay(options?.media);

  return renderReturn;
};

// ...생략

export * from '@testing-library/react';
export { baseRender as render };

 

4. 테스트 환경에서 편리하게 사용한다.

test('보여주려는 Display 컴포넌트만 남는다', () => {
    // when
    render(
      <>
        <Display mobile>{mock.mobileText}</Display>
        <Display desktop>
          <p>{mock.desktopText}</p>
          <Display mobile>{mock.mobileText}</Display>
        </Display>
      </>,
      { media: 'desktop' },
    );

    // then
    expect(screen.queryByText(mock.mobileText)).toBeNull();
    screen.getByText(mock.desktopText);
  });

 

Trouble Shooting

Error: Uncaught [NotFoundError: The node to be removed is not a child of this node.]

element 제거 시에 아래 코드를 사용 중인데, 제거 당시의 element가 React.Fragment인 경우 에러가 발생한다.

element.parentNode?.removeChild(element);

 

element.remove() 호출 시에도 동일한 에러가 나오고, catch를 통한 에러 핸들링도 불가능하다.

 

이 문제를 해결하는 가장 간단한 방법은, Display 컴포넌트를 감싸고 있는 최상위 컴포넌트를 React.Fragment가 아니라 div 등의 실제 dom에 마운트 되는 컴포넌트를 사용하는 것이다.

 

render에서 사용되는 wrapper에 div를 하나 추가하면 문제가 해결된다.

const baseRender = (ui: ReactElement, options?: BaseRenderOptions) => {
  const BaseProviders: FC = ({ children }) => {
    // ...생략
    
    return (
      <ThemeProvider theme={theme}>
        <div id="root">{children}</div> // root에 div 추가
      </ThemeProvider>
    );
  };

  const renderReturn = defaultRender(ui, { wrapper: BaseProviders, ...options });

  removeOtherMediaQueryDisplay(options?.media);

  return renderReturn;
};

 

테스트와 반응형

사실 jsDom을 사용한 테스트 환경 자체가 반응형 + 스타일을 테스트하는데 적합하지 않다.

궁여지책으로 Display 컴포넌트를 사용한 일부 컴포넌트를 테스트 가능하게 작업했지만, 늘 검색하면 나오는 대로 e2e test tool인 cypress 혹은 playwright 등을 사용하는 것이 맞다... (사실 알고 있다 ㅠㅠ)

 

본격적인 e2e test를 도입하기 전, 현재 도입된 테스트 환경에서의 나름대로의 발버둥 혹은 여러 여건상 아직 프로젝트에 e2e 테스트를 붙일 수 없는 경우 사용할 수 있는 방법이라고 보면 될 것 같다.