Storybook + Jest + RTL로 모던하게(?) 프론트엔드 단위 테스트하기

오형근·2023년 1월 29일
2

Testing

목록 보기
3/5
post-thumbnail

이번에 디자인 시스템 내에서 특정 컴포넌트들을 Compound Component pattern으로 리팩토링하다보니, 리팩토링 전에 해당 컴포넌트들에 대한 테스트 코드 작성이 먼저라는 생각이 들었다.

기존에 Jest를 이용한 단위 테스트를 하는 방법만 간단하게 알아보았는데, 이번에는 Storybook 내에서 단위 테스트를 진행하는 방법과 더불어 Storybook Interaction을 이용하여 Storybook을 더 맛있게 사용하는 방법을 알아보고자 한다(with Jest, react-testing-library)!


Jest + RTL + Storybook?

Storybook은 Jest와 통합한 @storybook/jest 라이브러리를 이용하여 기존의 컴포넌트가 아닌 Story를 불러와 렌더링하고, 이를 테스트할 수 있도록 하고 있다.

이를 통해 작성된 Story가 의도에 맞게 렌더링되고 있는지 확인할 수 있고, Storybook 내에 Interactions 기능을 이용하여 다양한 이벤트들을 발생시키고 변화를 확인할 수 있다.

정리하면 기존처럼 jest를 실행하여 단위 테스팅 하는 방법이 존재하고, Storybook을 직접 실행하였을 때 Interactions 탭을 확인하여 직접 테스트를 확인하는 방법이 있다.

참고로 테스트를 진행할 때 jest-plugin-context 라이브러리를 사용한 describe-context-it패턴을 적용하였다.

Story를 불러와 단위 테스팅하기

기존에 프론트엔드 단에서는 Jest와 React-testing-library를 이용하여 컴포넌트를 가상으로 렌더링하고 의도에 맞게 렌더링되었는지 확인하는 과정을 거쳤는데, 이번에는 해당 렌더링되는 컴포넌트가 기존의 React.Element에서 Story.fn으로 변화되었다는 것 외에 큰 다른 점은 없다. 그래서 더 익숙하다!

아래 코드로 살펴보자.

Input.test.tsx

import React from 'react';
import * as stories from '../stories/Input.stories';
import { render, screen } from '@testing-library/react';
import { composeStories } from '@storybook/testing-react';
// @ts-ignore
import context from 'jest-plugin-context'; // 요건 어케 해결하나...
import userEvent from '@testing-library/user-event';

const { TextInput, InputWithSteper } = composeStories(stories);

test('<TextInput />', async () => {
    render(<TextInput />);
    const textElement = await screen.findByPlaceholderText('Input Text');
    expect(textElement).toBeInTheDocument();
});

describe('Steper operating test', () => {
    it('<InputWithSteper />', async () => {
        render(<InputWithSteper />);
        const steper = await screen.findByDisplayValue('0');
        expect(steper).toBeInTheDocument();
    });

    context('When up steper clicked', () => {
        it('Number should be 1', async () => {
            render(<InputWithSteper />);
            const upSteper = await screen.findByTestId('upSteper');
            
            userEvent.click(upSteper);
            expect(await screen.findByDisplayValue('1')).toBeInTheDocument();
        });
    });

    context('When down steper clicked', () => {
        it('Number should be -1', async () => {
            render(<InputWithSteper />);
            const downSteper = await screen.findByTestId('downSteper');
            
            userEvent.click(downSteper);
            expect(await screen.findByDisplayValue('-1')).toBeInTheDocument();
        });
    });
});

위 코드에서 import문을 주의깊게 보면 기존의 React 컴포넌트를 불러오는 것이 아니라 Story를 stories로 불러오고, 이를 @storybook/testing-react라이브러리 내장 메서드인 composeStories로 Story들을 구조분해할당 해주는 것을 알 수 있다. 이를 통해 Story를 가상의 환경에 렌더링하고 테스트할 수 있게 된다.

이후 과정은 기존의 테스트 코드 작성과 다르지 않다.

1. describe에 어떤 컴포넌트를 테스트할 것인지 명시해두고(렌더링할 컴포넌트를 적어두어도 좋다!)

2. context에 해당 컴포넌트에 발생하는 이벤트나 상황을 명시해준다.

3. 마지막으로 it에 예상되는 결과를 적어준다.

4. 이후 컴포넌트를 렌더링하고, screen에서 해당 컴포넌트를 다양한 방법으로 찾아낸다(이때 찾는 메서드가 Promise를 반환하므로 꼭 async/await 문법을 이용해 작성하도록 하자).

5. 이후 userEvent를 이용해 이벤트를 발생시키거나 의도된 값이 렌더링되었는지 확인한다.

다만 아직 해결하지 못한 문제가 하나 있는데, 바로 context사용을 위해 import한 jest-plugin-context라이브러리에서 d.ts 파일이 오류를 발생시킨다는 것이다... 이를 해결하기 위해 여러 방법에 도전해봤지만 결국 오랜 시간 해결되지 않아 우선은 @ts-ignore 키워드를 이용하여 타입 강제만 막아둔 상태이다. 아마 ts오류일 것 같기도...? 추후에 다시 짚어보려고 한다.



Screen에서 원하는 요소 찾아내기

일반적으로 findBy 혹은 getBy 메서드를 활용하여 screen 내에 렌더링된 요소를 찾고는 하는데, getBy는 waitFor 메서드를 추가적으로 활용하여 비동기 로직을 작성해주어야하는 반면에, findBy는 그 자체로 비동기함수이므로 코드가 더 간단해져 findBy 메서드를 사용하게 되었다.

이때 무엇을 기준으로 요소를 찾을 것인지가 중요한데, findByDisplayValue, findByPlaceholderText 등등 찾을 수 있는 방법은 많이 존재한다.

다만 각자 장단점이 존재하고 상황에 맞는 메서드를 사용하는 것이 중요한데,

나는 이벤트를 통해 value 가 변화하는 상황을 제외하고는 전부 findByTestId를 사용하여 요소를 찾고자 하였다. 이 방법을 통해 컴포넌트들에 각각 고유 식별자를 부여하고 손쉽게 찾아낼 수 있을 것이라 생각하였다. 또한, 매 요소들마다 다른 방법을 적용하고 이를 고민하기 쉽지 않을 것이라 생각되어 이렇게 진행하였다.

testid를 부여하기 위해서는 아래 예시와 같이 관찰하고자 하는 변화가 직접적으로 일어나는 컴포넌트 요소에 data-testid="원하는 id"의 방식으로 속성을 추가해주면 된다.

Box.tsx
const DefaultBox = ({
    width,
    borderRadius,
    backgroundColor,
    children,
    height,
}: BoxProps) => {
    return (
        <div data-testid="DefaultBox" css={style({ width, height, backgroundColor, borderRadius })}>
            {children}
        </div>
    )
}


Style 요소 변화 테스트하기

기본적인 정적 요소들을 확인하고 값의 변화가 정확한지 확인했다면, 스타일의 변화도 확실하게 잡아낼 수 있어야 한다.

스타일은 hover, click등 다양한 이벤트들로 인해 자주 발생하며, 이를 모두 캐치해서 의도에 맞는지 확인하는 절차 또한 필수적이다.

여기서 문제가 되는 점은 우리가 직접 style 코드를 하드코딩하여 작성하지 않는다는 것이다. 대부분의 경우 Styled-component, Emotion과 같은 CSS-In-JS 라이브러리를 활용하여 SCSS 문법이 적용된 style 코드를 작성하게 된다.

그렇다면 이렇게 전처리된 style 코드를 Jest에서 파악할 수 있는가? 물론 Jest 자체적으로 스타일을 감지하고 이를 보내주고 있지만, 다양한 비동기 이벤트들이 발생하는 것을 모두 우리의 의도대로 캐치하지는 못한다. 따라서 우리는 각 이벤트 상황에 맞는 테스트 코드를 작성할 필요가 있다.

우리는 사용된 CSS 전처리 라이브러리들에 대한 Jest 사용 가이드를 읽어볼 필요가 있다. 다행히 이들도 자신들과 Jest가 최적화될 수 있도록 나름의 방법을 제시해두었다. 나는 디자인 시스템을 만들면서 Emotion을 사용하였으므로 이에 대한 방법을 살펴보자.

import * as stories from '../stories/Box.stories';
import { render, screen } from '@testing-library/react';
import { composeStories } from '@storybook/testing-react';
// @ts-ignore
import context from 'jest-plugin-context'; // 요건 어케 해결하나...
import { matchers } from '@emotion/jest'

// @emotion/jest 내부의 matcher를 연동해주어 
// emotion으로 작성된 스타일들을 테스트할 수 있도록 하자.
expect.extend(matchers)

const { Default, CanHover } = composeStories(stories);

describe('Box operating test', () => {
    context('When CanHoverBox hovered', () => {
        it('border color should change', async () => {
            render(<CanHover hoverColor='#000000' />);
            const Box = await screen.findByTestId('BoxCanHover');
            // hover를 테스트하는 경우 event를 발생시키켜 확인하는 방법을 찾지 못했다. 
            // 따라서 toHaveStyleRule 메서드를 이용하여 내부 style이 잘 작성되어 있는지 확인하는 방식을 사용하였다.
            expect(Box).toHaveStyleRule("border", "0.3px solid #DDDDDD");
            expect(Box).toHaveStyleRule("border", "1px solid #000000", { target: ":hover" });
        });
    });
});

기존에 style을 체크하는 방법으로는 toHaveStyle메서드를 사용하는 방법이 있었는데, hover와 같이 비영구적인 이벤트에 대해서는 달라진 스타일이 감지가 되지 않는 것 같다. 그래서 나는 toHaveStyleRule메서드를 활용하여 스타일 변화를 확인했다.

Emotion의 경우 @emotion/jest라는 라이브러리를 통해 메서드를 추가 제공해주고 있다.

  1. 먼저 @emotion/jest라이브러리에서 matchers내장함수를 꺼내주고

  2. expect.extend(matchers)를 통해 expect에 기능을 추가, 확장해준다.

  3. 이렇게 확장된 expect 함수는 toHaveStyleRule메서드를 활용한 스타일 체크가 가능해진다!!

이렇게 되면 코드 작성법이 조금 바뀌는데, 어떻게 달라지는지 아래 예시를 살펴보자.

// 기존 toHaveStyle을 사용하는 경우
expect(Box).toHaveStyle("border: 0.3px solid #DDDDDD;");

// toHaveStyleRule을 사용하는 경우
expect(Box).toHaveStyleRule("border", "0.3px solid #DDDDDD");

이렇게 코드를 작성하면, userEvent를 통해 hover 전후를 확인하는 것이 아니라, 해당 요소 스타일에 hover 이벤트가 적용되어있는지 확인하는 과정을 거친다.

따라서 아래 코드와 같이 작성한다.

expect(Box).toHaveStyleRule("border", "1px solid #000000", { target: ":hover" });

이렇게 실제 렌더링된 요소의 스타일 문법을 직접 체크함으로써 hover 되었을 때 변화가 어떻게 일어날 지 확인할 수 있다.



Storybook Interaction 사용하기

Storybook 공식문서에서는 Interaction 기능을 다음과 같이 소개하고 있다.

사용자의 동작을 시뮬레이션하고 기능별 검사를 실행하는 방법

표면상으로 사용자들이 보고 상호작용하는 것은 UI입니다. 그 안을 들여다 보면, UI는 정보와 이벤트(event)의 흐름이 잘 동작하도록 연결되어 있습니다.

페이지처럼 더 복잡한 UI를 만들수록 컴포넌트들은 UI 렌더링 그 이상의 역할을 하게 됩니다. 컴포넌트들은 정보를 조합하고 상태를 관리합니다. 이 장에서는 컴퓨터를 사용하여 사용자 상호 작용을 시뮬레이션하고 확인하는 방법에 대해 설명하겠습니다.

문서를 살펴보면 Interaction 기능을 통해 Storybook은 BDD(Behavior-Driven Development)에 충실한 프론트엔드 테스팅을 제공하고자 하는 것을 알 수 있다. 이에 따라 Storybook Interaction은 예상된 사용자의 행동 시나리오에 따른 다양한 이벤트와 상호작용들을 시뮬레이션하고 확인하는 방법을 제공한다.

아래 코드를 먼저 살펴보자.

Select.stories.tsx

import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { expect } from '@storybook/jest';
import { waitFor, userEvent, within } from '@storybook/testing-library';
import Select from "../components/Select/Select";

export default {
    title: 'Components/Select',
    component: Select,
    parameters: {
        componentSubtitle: "셀렉트 박스입니다."
    }
} as ComponentMeta<typeof Select>;

const Template: ComponentStory<typeof Select> = () => <Select />;

export const Default = Template.bind({});

// Story를 직접 테스트하는 부분!
Default.play = async ({ canvasElement }) => {
    // 직접 screen API를 쓸 수도 있지만 스토리북에서는 within(canvasElement) 로 캔버스를 가져올 것을 권장한다.
    const canvas = within(canvasElement);
    await waitFor(async () => {
        expect(await canvas.findByText('defaultValue')).toBeInTheDocument();
    });

    const getTask = await canvas.findByText('defaultValue');
    userEvent.click(getTask);

    await waitFor(async () => {
        expect(await canvas.findByText('Option 1')).toBeInTheDocument();
    });
};

위 코드는 이번에 Compound Component pattern을 이용하여 리팩토링한 Select 컴포넌트의 story이다.

컴포넌트를 불러와 Default Template을 생성하는 기본적인 코드 외에,
중요하게 보아야 하는 것은 Default.play() 의 부분이다.

해당 메서드를 이용하여 Storybook Interation을 추가해줄 수 있는데, 여기서 중요한 것은 위에서 언급한 것처럼 findBy 메서드 사용을 위한 async/await 문법 적용과 Storybook 최적화 테스팅 라이브러리의 사용이다. within, waitFor, userEvent 등의 메서드를 사용할 때 최대한 기존의 RTL 사용이 아닌 @storybook/testing-library라는 Storybook 라이브러리를 이용하여 테스트를 진행하도록 하자!

Interaction 적용을 위해 여러 문서를 참고하였는데, 공식문서에서는 @storybook/testing-library내장 findBy를 사용하였으나 에러를 잡지 못해 Toast UI 포스트 중 하나인 스토리북으로 인터렉션 테스트하기의 예제를 참고하여 작성하였다. 해당 예제에서는 기존 RTL 내부 메서드인 findBy를 사용한다. 추후에 다시 공식문서 가이드대로 적용하는 방법을 공부하자.

로직을 살펴보면

1. 캔버스 내에 Select 컴포넌트가 정상적으로 렌더링되는지 확인

2. Select 컴포넌트를 클릭하였을 때 Option 컴포넌트가 보여지고 있는지 확인

의 과정을 거치고 있다. 추가 로직을 작성한다면 Option 컴포넌트를 클릭하였을 때 defaultValue가 변경되는지 확인하는 과정을 추가할 수 있을 것이다.

아쉬웠던 점은 해당 파일이 테스트 파일이 아니기 때문에 describe와 같은 메서드를 사용하여 구체적인 케이스 분화할 수는 없었다는 점이다.

결과적으로 해당 코드를 작성하면 Storybook 내에 다음과 같은 내용이 추가된다.

실패하면 Jest와 비슷한 포맷으로 에러를 표기해주기 때문에 잘 수정하여 주어진 시나리오를 모두 통과하는 코드를 작성해보자!!


이번 시간에는 Storybook을 사용하면서 테스트 코드를 작성하는 두 가지 방법을 알아보았다. 앞으로 디자인 시스템 제작과 같은 프론트엔드 프로젝트를 진행하면서 이러한 다양한 방법을 적용한다면 좋을 것 같다!

앞으로 디자인 시스템 프로젝트를 이어나가면서도 개별 컴포넌트마다 구체적인 시나리오를 작성해보고 이에 맞는 테스트 코드를 작성하는 연습을 꾸준히 이어나가려고 한다.


글 내용 관련하여 틀렸거나 수정이 필요한 내용은 언제든지 댓글 부탁드립니다!!

Reference

스토리북으로 인터렉션 테스트하기
Storybook 공식문서
Cannot find name 'describe' Error in TypeScript
jest-plugin-context - npm
ByDisplayValue | Testing library

profile
https://nohv.site

0개의 댓글