Toggle 컴포넌트 테스트 코드 작성기
- 이 게시글은 테스트 코드를 작성하며 공부한 내용을 개인적으로 기록하기 위한 목적으로 작성되어져 틀린 내용이 많을 수 있습니다. 혹시 더 좋은 방법이 있으시다면 댓글을 부탁드립니다. 😁
테스트 코드의 필요성을 느낀 순간
저는 얼마전부터 over-ui
라는 리액트 컴포넌트 라이브러리를 작성하고, 배포하고 있었습니다. 그런데 그러던 중 한가지 유틸 함수를 수정할 일이 있었습니다.
export const composeEvent = <E>( defaultEvent: (event: E) => void, newEvent?: (event: E) => void ) => { return (event: E) => { defaultEvent?.(event); newEvent?.(event); }; }; // 사실, 의미가 없는 함수처럼 보이기도 했습니다.
위의 함수에 e.preventDefault
를 제어해주는 부분을 추가해주고 싶었습니다. 그런데 문제는 이 패키지는 core
패키지에 속해 있었고 core
패키지는 여러개의 패키지들이 의존하고 있는 패키지였습니다.
nx graph
로 확인해 본 패키지 간의 의존성인데 8개의 패키지들이 이 core
에 의존성을 가지고 있습니다. 이러한 상황에서 저는 위 함수를 수정 했을때에 다른 패키지의 작동에 문제가 없음을 확인해야만 했습니다. 이를 확인할 수 있는 방법은 테스트 코드 또는 스토리북을 통한 UI 테스트라는 생각이 들었는데요. 이 부분에서 테스트 코드를 작성하지 않은 것이 아쉬웠고 왜 테스트 필요가 필요한지 느낄 수 있었습니다.
저는 이러한 점을 느끼고, Toggle
컴포넌트에 대해 우선 테스트 코드를 작성하기로 했습니다. over-ui
에서 Toggle
컴포넌트는 아래와 같았습니다. (여기에서도 확인하실 수 있습니다.)
import * as React from 'react'; import { Poly, composeEvent } from '@over-ui/core'; import { useControlled } from '@over-ui/use-controlled'; export type ToggleRenderProps = { /** * children을 render할때 사용할 수 있는 renderProps 입니다. @example * ```tsx * <Toggle>{({ pressed }) => (pressed ? 'pressed' : 'notPressed')}</Toggle> * // pressed 값에 따라, 다른 컴포넌트, string, 아이콘을 조건부 렌더링할 수 있습니다. * ``` */ pressed: boolean; disabled: boolean; }; export type ToggleProps = { /** * 외부에서 선언한 상태를 이용할때 사용합니다. * `defaultPressed`와 함께 사용할 수 없습니다. * `onPressedChange` 를 함께 사용해야합니다. * * @example * ```tsx * const [state, setState] = React.useState(false); * return <Toggle pressed={state} onPressedChange={setState}/> * ``` */ pressed?: boolean; /** * `Toggle`의 `pressed` 상태의 초기값을 지정합니다. * `pressed`와 함께 사용할 수 없습니다. * `onPressedChange`와 함께 사용할 수 있습니다. * @defaultValue false */ defaultPressed?: boolean; /** * `Toggle`의 상태가 바뀔때 실행되는 함수입니다. */ onPressedChange?: (pressed: boolean) => void; /** * 토글을 `disabled`하는 prop입니다. * @defaultValue false */ disabled?: boolean; children?: React.ReactNode | ((props: ToggleRenderProps) => React.ReactNode); }; const DEFAULT_TOGGLE = 'button'; const TOGGLE_DISPLAY_NAME = 'Toggle'; const TOGGLE_KEYS = ['Enter', ' ']; export const Toggle: Poly.Component<typeof DEFAULT_TOGGLE, ToggleProps> = React.forwardRef( <T extends React.ElementType = typeof DEFAULT_TOGGLE>( props: Poly.Props<T, ToggleProps>, forwardedRef: Poly.Ref<T> ) => { const { onClick: theirOnClick, onKeyDown: theirOnKeyDown, onPressedChange, disabled = false, children, defaultPressed = false, pressed: pressedProp, as, ...restProps } = props; const Tag = as || DEFAULT_TOGGLE; const [pressed, setPressed] = useControlled({ value: pressedProp, defaultValue: defaultPressed, valueOnChange: onPressedChange, }); const renderProps = { pressed, disabled, }; const handleClick = () => { if (disabled) return; setPressed(!pressed); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (!TOGGLE_KEYS.includes(e.key)) return; e.preventDefault(); // button 이외 다른 태그 사용할 수 있으므로 button 태그의 기본 이벤트를 막고, 새로운 이벤트를 추가했습니다. handleClick(); }; return ( <Tag ref={forwardedRef} type="button" // form과 함께 사용될 경우, submit으로 사용되지 않게 하기 위해 onClick={composeEvent(handleClick, theirOnClick)} onKeyDown={composeEvent(handleKeyDown, theirOnKeyDown)} data-pressed={pressed ? 'on' : 'off'} data-disabled={disabled ? true : undefined} disabled={disabled ? true : undefined} role="button" tabIndex={disabled ? undefined : 0} aria-pressed={pressed} aria-disabled={disabled} {...restProps} > {typeof children === 'function' ? children(renderProps) : children} </Tag> ); } ); Toggle.displayName = TOGGLE_DISPLAY_NAME;
무엇을 테스트해야 할까?
제가 작성한 Toggle
컴포넌트는 생각보다 여러가지 기능을 가지고 있었습니다. 기능을 정리해보면 다음과 같습니다.
- 렌더링 관련
- 문제 없이 렌더링 되어야 한다.
Poly
타입으로 선언되어as
프로퍼티를 통해 다른 태그로 렌더링될 수 있습니다. → 제거pressed, disabled
의render Props
를 통해 하위 요소를 렌더링할 수 있어야 합니다.
- pressed 관련
defaultPressed
를 통해 초기 상태를 설정할 수 있어야 합니다.Click
을 통해 내부적인pressed
상태가 변경됩니다.- 키보드의
Space, Enter
키 동작에서도pressed
상태가 변경됩니다. → 접근성 부분으로 pressed
상태가 변경되면,pressed
를 매개변수로 가지는 콜백함수가 호출되어야 합니다.
- 접근성 관련
tabIndex
로 포커스할 수 있어야 합니다.- 키보드의
Space, Enter
키 동작에서도pressed
상태가 변경됩니다.
- disabled 관련 → 렌더링 부분으로 추가
disabled
라면tab
으로 포커스 할 수 없어야 합니다.- 마우스의
click
을 통해pressed
의 상태가 변경되지 않습니다. - 키보드의
Space, Enter
키 이벤트도 동작하지 않습니다.
여기에서, Toggle
컴포넌트만이 가지는 부분 만을 테스트해보려고 합니다. 저는 여기에서 몇가지 부분을 제외하고 테스트 할 예정입니다.
제가 제거한 부분은 Poly
타입 부분이었는데요. 해당 부분은 Toggle
컴포넌트가 가지는 고유한 기능이라고 보기는 어려웠고, 이 부분을 테스트 한다면 이 외에도 모든 컴포넌트에 해당 부분이 중복되어 들어갈 것이라 생각했습니다. 오히려 Poly
를 이용한 컴포넌트를 따로 작성해, 해당 부분을 테스트하는 것이 좋게 느껴졌습니다.
또 몇개의 부분으로 나누어 진행하고 하였으므로, 키보드 이벤트와 관련된 부분은 접근성 항목으로 추가했습니다.
마지막으로 disabled
부분을 따로 작성할 지 고민했으나 rendering
부분에 포함되는 것이 맞다는 생각이 들어 (disabled
로 렌더링 된 결과물을 테스트 한다고 생각했습니다. ) rendering
항목에 추가했습니다.
테스트 코드 작성하기
위에 작성한 리스트를 기반으로 시작합니다.
Rendering
describe("Rendering Toggle", () => { it("Toggle 컴포넌트가 문제없이 렌더링 되어야 한다.", () => { // arrange render(<Toggle />); const toggle = screen.getByRole("button"); // assertion expect(toggle).toBeInTheDocument(); }); it("Render Props를 통해 렌더링 될 수 있어야 한다.", async () => { // arrange render( <Toggle>{({ pressed }) => (pressed ? "pressed" : "not Pressed")}</Toggle>, ); const toggle = screen.getByRole("button"); // assertion expect(toggle).toHaveAccessibleName("not Pressed"); _**// 이 부분에서, toggle은 defaultPressed가 없다면, false로 렌더링되는 부분이 // 필요하다고 생각했습니다.**_ // act - click await userEvent.click(toggle); // assertion expect(toggle).toHaveAccessibleName("pressed"); }); });
이 부분을 진행할 때에 고민되었던 점은 두번째 항목을 진행하는 부분이었는데, 두번째 항목에서 pressed
의 초기 상태가 defaultPressed = false
라는 점이 명시되어 있지 않았기 때문입니다. 그래서, defaultPressed
관련 테스트를 이 부분에 추가했습니다.
it("Toggle 컴포넌트는 defaultPressed가 등록되어 있지 않다면 default 값은 false이다.", () => { render(<Toggle />); const toggle = screen.getByRole("button"); expect(toggle).toHaveAttribute("aria-pressed", "false"); // 해당 어트리뷰트의 값을 통해 테스트를 진행했습니다. }); it("defaultPressed의 값을 통해 Toggle의 초깃값을 설정할 수 있어야 한다.", () => { render(<Toggle defaultPressed={true} />); const toggle = screen.getByRole("button"); expect(toggle).toHaveAttribute("aria-pressed", "true"); }); // 추가한 부분
이 부분에서 aria-pressed
의 값을 통해 pressed
상태를 테스트 하는게 맞는지 의문이 들었습니다. 내부적인 코드라고 생각했기 때문입니다. (화이트박스 테스트에 가깝지 않나? 라는 생각이 들었습니다.) 다만, 내부적인 코드를 테스트하지 않는 이유가 해당 부분이 변한 다면 테스트 코드에 수정을 해야 하고, 테스트 코드의 실패 여부가 달라진다는 점이라고 생각했고 aria-pressed
는 Toggle
컴포넌트에서 바뀌지 않을 부분이라는 생각이 들어 해당 부분을 이용해 진행했습니다.
다만 지금까지는 중복코드가 많은 점이 아쉬웠습니다. 개별적인 토글들에 대한 원하는 모습이, 달랐기 때문에 beforeEach
를 사용할 수 없었기 때문입니다. 이어 진행한 disabled
에서는 이를 적용할 수 있었습니다.
// rendering 관련 부분이라 생각해, 해당 항목의 하위 부분으로 넣었습니다. describe("disabled", () => { let rendered: RenderResult; let toggle: HTMLElement; let before: HTMLElement; let mockOnPressedChange = jest.fn((pressed) => {}); let initialState = false; beforeEach(() => { rendered = render( <> <button>Before</button> <Toggle disabled defaultPressed={initialState} onPressedChange={mockOnPressedChange} > Toggle </Toggle> , </>, ); toggle = rendered.getByRole("button", { name: /Toggle/ }); before = rendered.getByRole("button", { name: /Before/ }); // Tab 이벤트를 확인해보고자 tabIndex가 무조건 등록되는 button을 before로 이용했습니다. }); // BeforeEach를 통해 중복적인 코드를 줄일 수 있었습니다. it("disabled로 렌더링할 수 있어야 한다.", () => { ~~expect(toggle).toBeInTheDocument();~~ // 이 부분은 필요없는 테스트라는 생각이 들어 제거했습니다. expect(toggle).toBeDisabled(); }); it("disabled로 렌더링 되면, 클릭해도 onPressedChange가 실행되지 않는다.", async () => { await userEvent.click(toggle); expect(mockOnPressedChange).not.toHaveBeenCalled(); }); it("Tab을 통해 포커스되지 않아야 한다.", async () => { before.focus(); expect(before).toHaveFocus(); // 이전에 focus를 하고 해당 부분을 확인 후 await userEvent.tab(); expect(toggle).not.toHaveFocus(); // Tab 키를 진행해도, toggle에는 Focus가 적용되지 않음을 확인할 수 있었습니다. }); });
Pressed
Pressed
관련 테스트에서는, 아래와 같이 진행했습니다. 일단, 테스트하려고 하는 모든 대역들이 한가지 토글로만 가능했으므로 beforeEach
를 통해 먼저 arrange
했습니다.
describe("pressed와, pressed 관련 콜백함수가 문제없이 작동되어야 한다.", () => { let rendered: RenderResult; let toggle: HTMLElement; let initialState = false; let mockOnPressedChange = jest.fn((pressed) => {}); beforeEach(() => { mockOnPressedChange.mockClear(); // mock함수를 초기화시켜 주었습니다. rendered = render( <Toggle defaultPressed={initialState} onPressedChange={mockOnPressedChange} > Toggle </Toggle>, ); toggle = rendered.getByRole("button"); }); ...
그리고 위의 beforeEach
를 통해 다음과 같이 마무리할 수 있었습니다.
it("click하면 toggle의 내부 상태가 토글링된다.", async () => { // act await userEvent.click(toggle); // assertion expect(toggle).toHaveAttribute("aria-pressed", "true"); }); it("click하면 onPressedChange가 실행되며, 바뀐 pressed를 매개변수로 가진다.", async () => { // act await userEvent.click(toggle); // assertion expect(mockOnPressedChange).toHaveBeenCalledWith(true); }); // 지금까지 해왔던 테스트 코드들이랑 다른 점이 크게 없어 빠르게 진행할 수 있었습니다.
Accessibility
우선 jest-axe
를 통한 접근성 테스트를 먼저 진행했습니다. beforeEach
는 pressed
거의 유사하게 설정했습니다.
import { axe } from "jest-axe"; import "jest-axe/extend-expect"; describe("접근성과 키보드 이벤트에 문제가 없어야 한다.", () => { let rendered: RenderResult; let toggle: HTMLElement; let initialState = false; let mockOnPressedChange = jest.fn((pressed) => {}); beforeEach(() => { mockOnPressedChange.mockClear(); rendered = render( <Toggle defaultPressed={initialState} onPressedChange={mockOnPressedChange} > Toggle </Toggle>, ); toggle = rendered.getByRole("button"); }); it("접근성 위반사항이 없어야 한다.", async () => { expect(await axe(rendered.container)).toHaveNoViolations(); }); });
그리고 이후, keyboard
이벤트에 대한 테스트를 진행했습니다. 지금까지는 userEvent
를 통해 인터렉션을 테스트 해왔지만, 키보드 이벤트 관련은 fireEvent
를 이용했습니다.
it("focus되었다면, 스페이스로 토글을 할 수 있어야 한다.", () => { toggle.focus(); fireEvent.keyDown(toggle, Keys.Space); expect(toggle).toHaveAttribute("aria-pressed", "true"); }); it("focus되었다면, Enter로 토글을 할 수 있어야 한다.", () => { toggle.focus(); fireEvent.keyDown(toggle, Keys.Enter); expect(toggle).toHaveAttribute("aria-pressed", "true"); }); // 아래에 존재하는 객체를 이용해 진행할 수 있었습니다. const Keys: Record<string, Partial<KeyboardEvent>> = { Space: { key: " ", keyCode: 32, charCode: 32 }, Enter: { key: "Enter", keyCode: 13, charCode: 13 }, Escape: { key: "Escape", keyCode: 27, charCode: 27 }, Backspace: { key: "Backspace", keyCode: 8 }, ArrowLeft: { key: "ArrowLeft", keyCode: 37 }, ArrowUp: { key: "ArrowUp", keyCode: 38 }, ArrowRight: { key: "ArrowRight", keyCode: 39 }, ArrowDown: { key: "ArrowDown", keyCode: 40 }, Home: { key: "Home", keyCode: 36 }, End: { key: "End", keyCode: 35 }, PageUp: { key: "PageUp", keyCode: 33 }, PageDown: { key: "PageDown", keyCode: 34 }, Tab: { key: "Tab", keyCode: 9, charCode: 9 }, };
위의 키보드 이벤트 부분은 어려운 부분이 아니므로, 중복코드를 제거하기 위해 하나로 합칠 수 있다고 생각해 하나로 합쳐 마무리했습니다.
커버리지 확인하기
npx jest --coverage toggle.test.tsx // 이 명령어를 통해 커버리지를 확인했습니다.
그런데, 87번째 라인에 커버가 되지 않은 부분이 있다고 나옵니다. 당장 해당 부분을 해결하러 가봅시다.
const handleClick = () => { if (disabled) return; setPressed(!pressed); };
87번 라인은 이 함수가 있는 부분이며, disabled
일 경우 click
이벤트가 동작하지 않는 부분이었습니다. 저는 해당 부분을 아직 고치지는 못했습니다. 이런 저런 방법을 시도하고 있는데, 제대로 고쳐지지 않아 추후에 보완할 계획입니다.
느낀점
사실 테스트 코드를 작성하는 법을 익히고 작성한 지는 조금 되었지만 어떤 부분에 테스트가 필요하고 어떤 부분은 필요 없는지 확인한 적은 없었습니다. 얼마전 부터, 단위테스트 책을 읽고 있는데 이번에 작성하는 테스트 코드는 조금 잘 써보고 싶어 하나하나 기록해보며 작성해 보았습니다. 그런데, 역시 좋은 테스트 코드를 쓰는 것은 어렵다고 느껴집니다.
또 한가지 이번에 느꼈던 아쉬운 점은, 저희 over-ui
프로젝트는 개발 기한이 상대적으로 넉넉하였었는데도 불구하고 개발 주기에 테스트 코드를 작성하지 않았던 점입니다. 개발 기한이 넉넉했고, 테스트 코드를 작성할 시간이 충분함에도 불구하고 단순히 귀찮다는 이유로 작성하지 않았었습니다. 그런데, 개발하는 동안 테스트 코드를 통합해 작성했다면 컴포넌트의 코드를 작성할 때에도 큰 도움이 되었을 것 같다라는 생각이 들었습니다.