스토리북으로 인터랙션 테스트하기


우리는 더 좋은 UI 테스팅 도구가 필요하다

브라우저가 그리는 UI(본문에서는 브라우저에 그려지는 요소의 부분 혹은 전체를 의미한다)가 다양한 환경에서 사용자의 인터랙션(Interaction, 상호작용)에 따라 기대하던 대로 반응하는지 테스트하는 것은 어렵다. 그 이유로 보통 아래 두 가지 문제가 있다.

  • 브라우저, OS, 네트워크 등 다양한 환경적 변수에 따라 다른 결과물이 나올 수 있다. 다행히도 요즘 많이 사용되는 브라우저만 대응하여 개발한다면 몹시 어려운 문제는 아니다.
  • 사용자의 인터랙션을 자동화 테스트로 작성하고 실행하는 과정이 어렵다. 테스트하고 싶은 부분만 격리하여 인터랙션을 수행하려니 부분만 렌더링하는 도구가 마땅히 없었고, 전체 화면을 그려서 사용자의 인터랙션을 자동으로 실행해주는 E2E 테스팅 도구들은 준비 및 수행 시간이 오래 걸린다.

요즘은 전체 애플리케이션을 로드 할 필요 없이 단일 컴포넌트만 화면에 그리거나 테스트 할 수 있는 도구들이 널리 사용되면서 개발 중인 UI의 일부부터 시작하여 전체까지 테스트 하는 건 그리 어려운 일도 아니다.

예를 들어 React(리액트) 기반의 모던 프런트엔드 애플리케이션에 사용되는 테스팅 도구를 나열하자면 아래와 같다.

  • 단위 테스트

    • Storybook(이하 스토리북)으로 컴포넌트만 브라우저 화면에 렌더링하고 다양한 상태에 따른 결과물을 살펴볼 수 있다.
    • 테스팅 환경에서 임의의 컴포넌트를 렌더링한 뒤 testing-library로 사용자 시점의 인터랙션 및 단언을 확인할 수 있다.
  • 통합 테스트

    • 여러 컴포넌트의 결합체를 테스트하는 것도 testing-library를 활용하여 컴포넌트의 세부 구현에 매몰되지 않고 사용자 인터랙션 수준의 통합 테스트를 수행할 수 있다.
    • 만약 API 서버에서 데이터를 가져오는 로직이 포함된다면, MSW(Mock Service Worker)를 사용하여 백엔드 API를 모킹할 수 있다.
  • E2E 테스트

    • Cypress, Playwright 등을 활용하여 실제 브라우저에서 렌더링 된 애플리케이션 전체를 대상으로 테스트한다.
    • MSW는 E2E 테스트에서도 유용하게 사용할 수 있다.

위에 언급한 도구 중 직접 시각적 피드백을 얻으며 자동화된 테스트를 하는 도구는 스토리북, Cypress, Playwright 정도이다. testing-library로 사용자 인터랙션 테스트를 할 수는 있지만 Node.js(jsdom) 환경에서만 동작하는 테스트이며, 테스트 결과로부터 시각적 피드백을 얻을 수 없다는 아쉬움이 있다.

스토리북은 컴포넌트 단위로 시각적인 피드백을 얻기에 가장 효과적인 도구이지만, 개발자나 디자이너가 직접 다양한 인터랙션을 실행해 보고 그 결과를 확인해야 한다. 많은 개발자가 '스토리북 안에서 내가 원하는 인터랙션이 자동으로 재생되도록 할 수는 없을까?' 라는 생각을 해 왔는지, 스토리북은 베타 기간을 거쳐 6.4버전부터 'Interactive Stories' 기능을 릴리즈했다. 이 기능을 이용하여 스토리북 안에서 사용자의 인터랙션을 자동으로 실행해볼 수 있다. 덕분에 스토리북 기반의 컴포넌트 단위 테스트를 훨씬 유용하게 할 수 있게 되었다.

이번 글을 통해 스토리북으로 자동화 테스트를 작성하는 방법, Interactive Stories 기능 등을 활용하여 컴포넌트의 인터랙션을 자동으로 재생하는 방법, 그리고 E2E 도구를 결합하여 테스트하는 방법을 소개하고자 한다.

(참고 사항)

본문의 예제 코드는 리액트, 스토리북 사용 및 testing-library 활용 방법을 이해하고 있다는 것을 가정하여 작성된 코드이다. 따라서 필요하다면 아래의 문서를 참고하여 기본적인 지식을 습득할 수 있다.

대신 코드 안의 사용되는 함수의 이름을 통해 수행하려는 동작이 직관적으로 드러나기 때문에 코드를 읽는 것은 아주 어렵지 않으리라 기대한다.

스토리는 컴포넌트이자, 테스트이다

Component Story Format

스토리북 5.2 버전 이전까지만 해도 하나의 스토리를 작성하기 위해서는 storiesOf 라는 레거시 API를 사용하여 스토리를 만들어야 했다. 이 API를 사용하면 대략 이런 방식으로 스토리를 만들 수 있다.

storiesOf('Button', module)
  .addParameters({backgrounds: {values: [{name: "red" value: "#f00"}]}})
  .addDecorator((Story) => <div style={{ margin: '3em' }}><Story/></div>)
  .addDecorator((Story) => <div style={{ height: '600px' }}><Story/></div>)
  .add('with text', () => <Button onClick={action('clicked')}>Hello Button</Button>)
  .add('with some emoji', () => (
    <Button onClick={action('clicked')}>
      <span role="img" aria-label="so cool">
        😀 😎 👍 💯
      </span>
    </Button>
  ));

빌드 된 스토리북을 열어 보면 'with text', 'with some emoji' 라는 스토리에 각각의 상황에 따른 버튼 컴포넌트가 렌더링 되어 있을 것이다. 하지만 이렇게 만들어진 스토리는 오로지 스토리북 안에서만 활용될 수 있다.

이후 스토리북 팀을 중심으로 CSF(Component Story Format)이라는 오픈 표준 규격이 만들어졌고, 각각의 스토리를 아래와 같이 컴포넌트 단위로 선언하고 내보낼 수 있게 되었다.

export default { title: 'atoms/Button' };
export const text = () => <Button>Hello</Button>;
export const emoji = () => <Button>😀😎👍💯</Button>;

스토리북은 해당 파일을 읽어 들여 atoms > Button 페이지 아래 'text', 'emoji' 스토리를 만들어낸다. 코드를 보면 특별히 라이브러리에 종속된 구현체가 아니라 흔한 ES6 모듈로 보인다.

여기서 레거시 API와 결정적인 차이가 있는데, 하나의 스토리는 하나의 컴포넌트와 같은 독립성을 가지게 된다는 점이다. 어떤 컴포넌트가 하나 있고 그 컴포넌트로 표현할 수 있는 다양한 상황에 따라 스토리를 만들었다면, 각각의 스토리를 가지고 어딘가에 렌더링해보고 결과물을 확인해보거나 테스트할 수 있다.

스토리를 테스트하기

본격적인 예제는 이 공개 저장소의 코드 기반으로 제공된다.

아래와 같이 임의의 할 일 관리 애플리케이션을 상황에 따라 다르게 보여주는 스토리 파일이 있다.

// src/InboxScreen.stories.js
// ...
 
export default {
  component: InboxScreen,
  title: 'InboxScreen',
};

const Template = (args) => <InboxScreen {...args} />;

export const Default = Template.bind({});
Default.parameters = {
  // msw를 이용하여 API 서버 요청 모킹
  msw: [
    rest.get('/tasks', (req, res, ctx) => {
      return res(ctx.json(TaskListDefault.args));
    }),
  ],
};

export const Error = Template.bind({});
Error.args = {
  error: 'Something',
};
Error.parameters = {
  msw: [
    rest.get('/tasks', (req, res, ctx) => {
      return res(ctx.json([]));
    }),
  ],
};

Template 컴포넌트는 함수이므로 bind 를 호출하면 새로운 함수가 리턴된다. 스토리북에서는 이렇게 하나의 기본 형태를 선언하고 Template.bind({}) 로 각각의 스토리를 찍어낸 뒤 인자를 parameters, args 등으로 주입하는 방식으로 상황에 따라 다른 UI를 표현하도록 권장한다.

이 중 DefaultInboxScreen 이라는 컴포넌트가 정상적으로 표시될 때를 보여주는 스토리이다.

01-story-default.png [하나의 InboxScreen 컴포넌트가 Default 스토리로 렌더링 된 예]

만약 API 서버로부터 내려받은 데이터로 InboxScreen 컴포넌트를 렌더링하고, 이 상태에서 사용자의 인터랙션을 테스트해야 한다고 가정해보자. 기존에는 서버 모킹(Mocking)을 하는 코드부터, 필요하다면 props를 전달하는 코드까지 작성해야 할 것이다. 하지만 이미 모든 밑 준비가 완료된 Default 컴포넌트를 가져올 수 있기 때문에, 이를 이용해 테스트를 작성할 수 있다.

// src/InboxScreen.test.js
// ...
// 스토리를 테스트 안에서 사용하기 위한 함수
import { composeStories } from '@storybook/testing-react';
// 스토리를 모두 가져왔다.
import * as stories from './InboxScreen.stories';

describe('InboxScreen', () => {
  // ...

  // 스토리를 렌더링 가능하도록 처리한다.
  const { Default } = composeStories(stories);

  it('should pin a task', async () => {
    const { queryByText, getByRole } = render(<Default />);

    await waitFor(() => {
      expect(queryByText('You have no tasks')).not.toBeInTheDocument();
    });

    const getTask = () => getByRole('listitem', { name: 'Export logo' });

    const pinButton = within(getTask()).getByRole('button', { name: 'pin' });

    fireEvent.click(pinButton);

    const unpinButton = within(getTask()).getByRole('button', {
      name: 'unpin',
    });

    expect(unpinButton).toBeInTheDocument();
  });
});

자세한 내용은 스토리북의 testing-react 애드온 문서를 참고해보자.

스토리로 컴포넌트의 인터랙션을 재생해보자

각종 애드온을 활용하긴 했지만, 이전 단락에서 실행시켜본 테스트는 Jest + testing-library 기반으로 이루어져 있다. 그래서 버튼을 클릭하고 원하는 대로 UI가 변화했는지 확인할 수단이 터미널에 표시된 테스트의 성공/실패 여부밖에 없다.

02-story-test-result.png [Jest로 테스트를 실행해보고 출력된 결과]

원하는 대로 UI가 변화했는지 시각적인 피드백을 얻기 위해서 직접 스토리북을 켜서 스토리를 열어보고, 클릭하고, 키보드 입력을 해볼 수 있지만, 여러 단계에 걸친 동작을 직접 실행한다면 테스트의 효율이 많이 떨어질 것이다.

그래서 스토리북의 interaction 애드온을 이용하여 인터랙션을 코드로 작성하고, 이를 단계별로 재생시켜볼 수 있다.

먼저 프로젝트 안에서 필요한 패키지를 설치한다.

# 혹은 npm install -D 로 설치한다.
yarn add -D @storybook/addon-interactions @storybook/jest @storybook/testing-library

그리고 .storybook/main.js 파일에 애드온을 등록해준다.

// .storybook/main.js
module.exports = {
  addons: [
    // ...
    '@storybook/addon-interactions',
  ],
};

이제 할 일 관리 애플리케이션에서 초기 데이터 로딩이 끝난 뒤, 할 일 하나를 고정하기 위한 버튼을 클릭하는 인터랙션을 코드로 작성해보자. 앞서 작성했던 테스트와 거의 유사하다.

// src/InboxScreen.stories.js
// ...
// 새로운 import 구문 추가
import { expect } from '@storybook/jest';
import { waitFor, userEvent, within } from '@storybook/testing-library';

// ...

// Default 스토리에 인터랙션 스토리를 재생하는 속성을 추가한다.
Default.play = async ({ canvasElement }) => {
  // 직접 screen API를 쓸 수도 있지만 스토리북에서는 within(canvasElement) 로 캔버스를 가져올 것을 권장한다.
  const canvas = within(canvasElement);

  await waitFor(() => {
    expect(canvas.queryByText('You have no tasks')).toBe(null);
  });

  const getTask = () => canvas.getByRole('listitem', { name: 'Export logo' });

  const pinButton = within(getTask()).getByRole('button', { name: 'pin' });

  userEvent.click(pinButton);
};

이후 스토리북을 실행시켜서 Default 스토리를 열면 자동으로 버튼 클릭까지 완료되고 그 결과를 확인할 수 있다. expect 를 사용하여 단언을 작성할 수 있으니 인터랙션 실행 결과가 의도대로 되었는지 확인하는 테스트 케이스도 작성할 수 있다.

03-story-play.png [Default 스토리의 인터랙션 재생 결과]

이렇게 play 함수에 작성한 인터랙션은 해당 스토리를 열 때마다 재생되기 때문에, 기존에 작성된 스토리와 별도로 인터랙션 재생용 스토리를 따로 만들어서 play 함수를 작성할 것을 권한다.

작성한 인터랙션을 구간별로 되감기/빨리 감기를 하며 살펴보기 위해 실험 기능인 단계별 디버깅을 활성화할 수도 있다.

// .storybook/main.js
module.exports = {
  features: {
    interactionsDebugger: true,
  },
};

E2E 테스팅 도구와 함께

스토리북 실행 시 특정 스토리를 새로운 브라우저 탭에서 열 수 있다. 각각의 스토리는 iframe.html? 로 시작하는 고유의 URL이 존재한다.

04-story-open-standalone.png 04-story-standalone.png [하나의 스토리를 개별적으로 열어볼 수 있는 예]

여기에 직접 브라우저를 통해 지정된 URL에 접속하여 렌더링 된 화면으로 테스트를 실행하는 E2E 테스팅 도구를 사용하면 어떨까? 스토리북으로 렌더링 된 컴포넌트만 인터랙션 테스트를 할 수 있을 것이다. 다만 이 방식은 URL 접속 → 페이지 로드 → 테스트 수행의 단계를 거치다 보니 실행 속도가 빠른 편은 아니다.

어떠한 E2E 테스팅 도구를 사용하더라도 원하는 요소를 찾은 뒤 그 요소에 인터랙션을 실행하는 코드를 작성하기만 하면 된다. 활용할만한 도구는 다음과 같다.

  • Cypress

    • 커맨드를 원하는 대로 커스터마이징 할 수 있고, 테스트가 수행되는 과정에 따라 스냅샷을 볼 수도 있다.
    • Headless 모드 뿐 아니라 브라우저를 통해 테스트가 실행되는 Headed 모드의 UI가 아주 잘 구성되어 있다. 다른 E2E 테스팅 도구와 비교하면 독보적으로 유리한 특징이다.
    • Chrome, Firefox, Edge(Chromium), Electron 환경을 지원한다.
  • Playwright

    • Chrome, Firefox 뿐 아니라 Safari(Webkit) 브라우저를 지원한다. 따라서 크로스 브라우징을 고려해야 한다면 좋은 선택이 될 수 있다.
    • 테스트 용도로 설치할 때는 @playwright/test 패키지를 설치해야 한다.
    • 기본적으로 Headless 모드로 사용하는 도구이며 디버깅 용으로 별도의 Inspector를 제공하지만, Cypress에서 제공하는 UI에 비하면 많이 부족하다.
// Cypress의 경우
describe('InboxScreen', () => {
  it('should pin a task', () => {
    cy.visit('/iframe.html?id={스토리 주소}');
    cy.get('li').contains('Export logo').within(() => {
      cy.get('button').click();
    });
    // ...
  });
});

// Playwright의 경우
test.describe('InboxScreen', () => {
  test('should pin a task', ({ page }) => {
    await page.goto('/iframe.html?id={스토리 주소}');
    await page.click('li:has-text("Export logo") >> button');
    // ...
  });
});

추가로 아직 알파 테스트 단계이지만 Cypress에서 자체적으로 컴포넌트 단위 테스트를 지원하고 있다. 이 기능을 사용하여 Cypress의 테스트 UI와 API를 활용하여 더 눈에 잘 들어오는 인터랙션 테스트를 할 수 있을 것이다.

import * as React from "react";
import { composeStories } from "@storybook/testing-react";
import { mount } from "@cypress/react";
import * as stories from "./MyInput.stories";

// 불러오는 방식은 이전의 testing-library 예제와 같다.
const { Primary } = composeStories(stories);

it("Should empty the field when clicking the cross", () => {
  // 스토리를 마운트
  mount(<Primary />);

  // then run our tests
  cy.get("svg").click();
  cy.get("input").then((i) => expect(i.val()).to.be.empty);
});

Cypress 컴포넌트 테스팅을 하는 예제는 이 저장소에서 확인할 수 있다.

마치며

과거와 비교해 웹 애플리케이션은 점점 더 복잡해지고 규모가 커지고 있다. 그러면서 완성된 소프트웨어 하나를 만들기 위해 오랜 시간 개발하는 게 아니라, 빠르게 제품을 배포하고 사용자의 피드백을 받아들여 제품을 개선하고 다시 사용자의 요구 사항을 받아들이는 흐름을 유지하는 방식이 대세가 되었다.

애플리케이션의 규모가 커진 만큼 사용자 수준의 통합 테스트를 하려면 환경을 구축하거나 API 서버 데이터를 모킹하는 등 너무 많은 것을 준비해야 한다. 단일 UI의 기능도 복잡하고 어려워져서 컴포넌트 테스트를 작성하는 것도 복잡하다. 특히 사용자의 인터랙션을 테스트할 때 자동화된 테스트를 도입하지 않고, 개발자가 일일이 브라우저 화면을 열어보며 개발한 컴포넌트가 의도대로 동작하는지 확인한다면 개발 효율이 더욱 떨어질 것이다.

이런 상황에서 스토리북은 요구 사항의 변화에 유연하게 대응하면서 응집도가 높은 컴포넌트를 개발하는 데 큰 도움을 주는 도구로 자리매김했다. 오늘 소개한 방법처럼 인터랙션을 스토리 안에서 재생시켜볼 때 play를 활용하고, 스토리를 직접 자동화 테스트에 접목한다면 더 효과적인 UI 테스트를 할 수 있을 것이다. 그리고 우리는 그 테스트 결과를 믿고 더 편안한 마음으로 개발을 이어갈 수 있을 것이다.

References

안도형2022.01.11
Back to list