👀 문제 상황
모바일 리뉴얼 프로젝트를 시작하면서, 바텀시트 컴포넌트의 개발이 필요해졌다. 바텀시트는 화면 하단에 위치하는 오버레이 UI로, 주로 모바일에서 추가적인 정보나 액션을 표시하기 위해 사용한다.
바텀시트에서 가장 까다로운 부분은 높이를 조절할 수 있어야 한다는 부분이었다. 바텀시트 상단의 핸들이나 헤더 부분을 누른 채로 드래그하면 바텀시트의 높이를 조절할 수 있고, 드래그가 끝나면 바텀시트의 heightType에 따라 높이를 조정해줘야 한다.
개발에 들어가기 전 바텀시트 heightType과 관련된 정책을 정리해보니, 정책이 정말 많고 복잡했다. 이 기능을 테스트 코드 없이 개발하기 시작한다면 바텀시트 높이 관련 코드를 고칠 때마다 모든 heightType 케이스에 대한 바텀시트를 직접 드래그해보며 테스트해야 할 것 같았다.
테스트를 작성해 두면 기능 동작에 대한 검증과 동시에 기능에 대한 명세를 할 수 있을 것이라고 생각했다. 그래서 BDD & TDD 기반으로 일단 테스트를 작성해 둔 다음 기능을 개발해 보기로 했다.
무엇을 테스트할지 정하기
먼저, 바텀시트 컴포넌트에서 제공하는 기능을 리스팅했다.
Context | It |
---|---|
heightType = hug일 경우 | 바텀시트의 높이는 바텀시트 컨텐츠 높이가 된다 |
핸들을 50px 아래로 당기면 바텀시트를 닫을 수 있다 | |
heightType = fixed일 경우, snapPoints = [100px, 200px, 500px, 600px] | 바텀시트의 높이는 snapPoints 중 가장 작은 값이 된다 (100px) |
핸들을 50px 위로 당기면 한 단계 큰 snapPoint로 높이가 조정된다 (200px) | |
핸들을 600px 위치로 당기면 바텀시트 높이가 600px이 된다 | |
핸들을 50px 아래로 당기면 한 단계 낮은 snapPoint로 높이가 조정된다 (500px) | |
핸들을 가장 작은 snapPoints보다 50px 낮게 내리면 바텀시트가 닫힌다 | |
heightType = fullPage일 경우, | 바텀시트의 높이는 스크린의 높이와 같다 |
🤔 React Testing Library로 할 수 없을까?
바텀시트 컴포넌트를 테스트하기 위해, RTL을 사용해 테스트를 작성해 두고, 기능을 개발하려고 했다. 처음 작성한 테스트는 아래와 같다.
1describe("bottom sheet", () => {2 describe("heightType: fixed일 때", () => {3 const snapPoints = [4 "100px",5 "200px",6 "500px",7 "600px",8 ];910 test("snap points 중 작은 값으로 열려야 한다", async () => {11 const wrapper = createWrapper();1213 render(<BottomSheet snapPoints={snapPoints} />, { wrapper });1415 const bottomSheet = await screen.findByRole("dialog");16 expect(bottomSheet.getBoundingClientRect().height).toBeCloseTo(200);17 });1819 test("헤더를 잡고 마우스를 놓은 위치가 300이면 바텀시트의 높이가 200px이 된다", async () => {20 const wrapper = createWrapper();2122 render(<TestBottomSheet snapPoints={snapPoints} />, { wrapper });2324 const header = screen.getByTestId("header");2526 fireEvent.pointerDown(header);27 fireEvent.pointerMove(header, { clientY: header.clientTop + 200 - 300 });28 fireEvent.pointerUp(header);2930 const bottomSheet = await screen.findByRole("dialog");31 expect(bottomSheet.getBoundingClientRect().height).toBeCloseTo(200);32 });33 });34});
그런데 테스트를 작성하던 중 몇 가지 문제가 발생했다.
getBoundingClientRect가 반환하는 값이 모두 0이다
바텀시트의 높이를 알아오기 위해 getBoudingClientRect
를 사용했다. 그런데 높이가 0이라며 실패했다.
bottomSheet.getBoundingClientRect
을 콘솔에 출력해 보면 아래와 같이 나온다.
1 {2 x: 0,3 y: 0,4 bottom: 0,5 height: 0,6 left: 0,7 right: 0,8 top: 0,9 width: 010 }
모든 값이 0으로 나오는 이유는, RTL은 실제 브라우저 상에 DOM을 렌더링한 것이 아니라 JSDOM을 사용해 Node.js 환경에서 렌더링하고 있기 때문이다. 따라서 getBoundingClientRect
를 통해 유의미한 값을 가져올 수 없는 상황이다.
이를 우회하기 위해 getComputedStyle
을 사용할 수도 있다.
1expect(getComputedStyle(bottomSheet).getPropertyValue("height")).toBe("200px");
하지만, getComputedStyle은 계산된 CSS를 반환하는 것이지 실제로 렌더링되는 값과는 차이가 발생할 수도 있다. 뷰포트 상에 렌더링된 정확한 값을 검증하려면 getBoundingClientRect
를 사용하는 게 더 낫다.
포인터 이벤트 발생 시, 콜백에 넘어오는 pointerEvent의 clientY가 undefined
이다
사용자가 드래그한 위치를 확인하기 위해 clientY 값이 필요한데, fireEvent로 포인터 이벤트를 발생시킬 때 타겟 element나 좌표를 넘겨줘도 항상 clientY 값이 undefined로 넘어왔다. 이것도 이전과 같이 RTL이 Node.js 환경에서 JSDOM을 통해 렌더링하고 있기 때문에 발생하는 문제이다.
이 부분은 조금 우회해서 해결할 수 있는데, Test Setup 과정에서 PointerEvent에 대한 폴리필을 추가해주는 방법이 있다. 자세한 내용은 이 이슈 코멘트에 설명되어 있다.
이렇게 폴리필 처리를 해주면 targetElement를 지정하는 경우에는 clientX/Y가 0으로 나오지만, 옵션에 clientX/Y를 직접 지정해서 넘겨준 경우에는 그 값이 그대로 넘어간다.
RTL이 답일까?
위와 같은 방법으로 우회하면 어떻게든 테스트를 작성할 순 있지만, 과연 제대로 테스트를 짜고 있는 게 맞는 건지에 대한 의문이 들었다. 그래서 동료 개발자 분들과 논의해본 후, 현재 작성하고 있는 테스트는 시각적 요소를 포함하고 있기 때문에 RTL로 테스트하기엔 부적합하다고 결론내렸다.
시각적 요소를 정확히 검증하려면 브라우저에 실제로 렌더링해야 할 텐데, 그렇다면 E2E 테스트가 필요하겠다는 생각이 들었다. 그렇다고 프로젝트 전체에 E2E 테스트를 추가하고 싶진 않았고, 해당 컴포넌트만 검증할 수 있었으면 했다.
이런 요구사항에 가장 적합한 도구가 Storybook에서 제공하는 Interaction Tests 애드온이었다.
🪄 Storybook Interaction Tests (🔗)
Storybook Interaction Tests는 스토리북에서 제공하는 @storybook/addon-interactions
애드온을 통해 사용할 수 있다. 인터랙션 테스트는 Jest, RTL, Playwright를 활용해서 구현되어 있어서, 실제 브라우저 상에서 특정 컴포넌트의 동작을 시뮬레이션해 볼 수 있다.
이 애드온은 이런 상황에 사용하면 좋다.
- 시각적 요소에 대한 테스트가 필요한 경우
- 실제 브라우저 환경에서 렌더링해서 테스트해야 할 경우 (특히 모바일 기기 확인이 필요할 경우)
- 특정 컴포넌트에 대해서만 시각적 인터랙션 테스트를 진행하고 싶은 경우
설정 추가
아래 패키지를 설치한다.
1npm install @storybook/testing-library @storybook/jest @storybook/addon-interactions --save-dev
스토리북 설정의 addon에 @storyboo/addon-interactions
애드온을 추가한다.
1// .storybook/main.ts2import type { StorybookConfig } from "@storybook/your-framework";34const config: StorybookConfig = {5 // ...6 addons: [7 // Other Storybook addons8 "@storybook/addon-interactions", // 👈 여기 애드온 추가9 ],10};1112export default config;
이것만 하면 세팅이 끝난다!
각 context는 스토리북의 스토리가 되고, test는 Interaction Test의 step
으로 만들 수 있다.
1export default {2 component: BottomSheet,3} as Meta<typeof BottomSheet>;45type Sroty = StoryObj<typeof BottomSheet>;67export const HugBotomSheet: StoryObj = {8 render: () => {9 const [open, setOpen] = useState(true);10 return (11 <BottomSheet12 open={open}13 onClose={() => setOpen(false)}14 heightType={"hug"}15 >16 <BottomSheet.Body style={{ height: "350px", flexShrink: 0 }} />17 </BottomSheet>18 );19 },20 play: async ({ canvasElement, step }) => {21 await step("바텀시트 높이는 바텀시트 컨텐츠의 높이이다", async () => {22 // 여기에 테스트 작성하기23 });2425 await step("핸들을 50px 아래로 당기면 바텀시트가 닫힌다", async () => {26 // 여기에 테스트 작성하기27 });28 },29};
이 케이스에 대한 테스트는 이렇게 작성했다.
1{2 // ...3 play: async ({ canvasElement, step }) => {4 const bottomSheet = screen.getByRole('dialog');5 const handle = screen.getByRole('separator');67 await step('바텀시트 높이는 바텀시트 컨텐츠의 높이이다', async () => {8 await waitFor(() => expect(bottomSheet.getBoundingClientRect().height).toBeCloseTo(350);9 });1011 await step('핸들을 50px 아래로 당기면 바텀시트가 닫힌다', async () => {12 await userEvent.pointer([13 {14 keys: '[TouchA>]',15 target: handle,16 },17 {18 pointerName: 'TouchA',19 coords: { y: -50 },20 },21 {22 keys: '[/TouchA]',23 },24 ]);2526 await expect(screen.queryByRole('dialog')).toBeNull();27 });28 }29}
스토리북에서 해당 스토리를 확인해 보면, play에 작성해둔 시나리오대로 컴포넌트가 알아서 상호작용하는 것을 확인할 수 있다. 원하는 단계를 선택해서 실행시킬 수도 있다.
모바일 기기에서 스토리북에 접속했을 때에도 인터랙션 테스트가 실행되기 때문에, 개발하는 동안 모바일 기기에 스토리북을 띄워놓기만 하면 직접 테스트해보지 않아도 편하게 인터랙션을 확인할 수 있다!
screen? canvasElement?
RTL에서 제공하는 screen
객체가 아니라, play 메서드를 구현할 때 인자로 받아오는 canvasElement
을 사용해서 엘리먼트를 선택해야 한다. 그래야 스토리의 캔버스 안에서만 컴포넌트를 select 해올 수 있다.
그런데, 바텀시트의 경우 React Portal을 사용하기 위해 preview-body.html
에 Portal Container로 사용할 div 엘리먼트를 정의해둔 상태라 어쩔 수 없이 screen 객체로 엘리먼트를 가져왔다.
pointer 이벤트에서 target을 사용할 경우, coords는 상대값이다
특정 element를 타겟으로 PointerDown 이벤트가 발생하면, 이 이벤트가 가지고 있는 clientY 값은 해당 element의 위치가 되기를 기대하게 된다. 그런데 실제로 값을 확인해 보면 어떤 element에 PointerDown 이벤트가 발생하든 clientY 값은 0
이 출력된다.
그래서 pointer move 이벤트를 발생시킬 때 이 부분을 감안해서 위치를 줘야 한다. target element가 있는 경우라면 상관이 없지만, coords를 직접 작성하는 경우라면 PointerDown한 element로부터의 상대 좌표를 찍어 줘야 원하는 위치로 옮길 수 있다.
y 값을 줄 때 방향이 자주 헷갈렸는데, y는 스크린의 가장 윗부분이 0이고, 아래로 갈수록 숫자가 늘어난다. 즉, coords의 y 값에 음수를 주면 위, 양수를 주면 아래 방향이다.
CLI로 테스트 실행하기
CLI를 통해 테스트를 실행하기 위해서는 스토리북에서 제공하는 test-runner가 필요하다.
1npm install @storybook/test-runner --save-dev
test-runner는 Playwright를 사용하기 때문에, playwright install
을 한 적이 없다면 설치 커맨드를 통해 브라우저를 설치해줘야 한다.
1npx playwright install
그러면 test-storybook
커맨드로 프로젝트에 있는 스토리들의 테스트를 실행할 수 있다.
1test-storybook
마치며
시각적 요소를 포함한 기능이라서 테스트하기 어려울 것 같았는데, 스토리북의 인터랙션 테스트를 활용해 보니 생각보다 쉽게 테스트를 작성할 수 있었다. 특히 좋았던 점은 모바일 기기에서도 스토리북에 들어가기만 하면 테스트를 실행해 볼 수 있어서, RTL만 사용했다면 테스트하기 어려웠을 부분을 커버할 수 있었다는 점이다.
사실 이렇게 테스트를 작성했는데도, 내가 예상치 못했던 케이스에서 버그가 발생했다. 평소대로라면 버그 상황을 재현해보고 코드만 수정했겠지만, 이번에는 테스트 기반을 만들어 뒀기 때문에 문제 케이스에 대한 테스트를 먼저 작성한 다음 그 테스트를 성공시키는 코드를 작성하는 방식으로 버그를 해결했다.
이런 방식으로 작업하니 새로운 케이스 뿐만 아니라 기존 동작도 정상적으로 동작한다는 것을 보장하면서 버그를 해결할 수 있었다. 덤으로 스토리북 스토리도 조금 더 풍부(?)해졌다.
스토리북 인터랙션 테스트에서 조금 아쉬운 점은, 테스트 작성 시 testing-library
를 그대로 사용하는 것이 아니라 스토리북에서 래핑해둔 것을 사용해야 하는데, fireEvent가 정상적으로 동작하지 않는 등 RTL과는 약간씩 다르게 동작하는 부분이 있었다. 이런 경우 테스트를 어떻게 작성해야 할 지에 대한 레퍼런스가 아직 많이 부족한 것 같아 테스트 작성에 약간의 어려움이 있었다.
그렇지만 시각적 요소를 간편하게 테스트 할 수 있다는 점에서는 정말 좋았다. 앞으로도 RTL만으로는 테스트할 수 없는 부분이 있거나, 컴포넌트의 인터랙션을 스토리 상에 남겨둬야 하는 경우가 있다면 자주 사용하게 될 것 같다.