(React)컴파운드 패턴으로 드롭다운 컴퍼넌트 만들기-2 테스트하기
컴파운드 패턴을 통해서 드롭다운을 만들었고 이를 바탕으로 Select를 만들었습니다.
import useDropDown from '../shared/dropdown/hook/useDropDown';
import { DropDown_Data } from '../constant/data';
import { DropDown } from '../shared/dropdown';
const Select = () => {
const [isOpen, toggleOpen, value, selectValue] = useDropDown();
return (
<DropDown isOpen={isOpen} toggleOpen={toggleOpen}>
<DropDown.Trigger isOpen={isOpen} toggleOpen={toggleOpen} value={value} />
<DropDown.Menu
isOpen={isOpen}
value={value}
setValue={selectValue}
list={DropDown_Data}
toggleOpen={toggleOpen}
>
{DropDown_Data.map((item) => {
return (
<DropDown.Item
key={item.id}
value={item.value}
selected={item.value === value}
toggleOpen={toggleOpen}
setValue={selectValue}
></DropDown.Item>
);
})}
</DropDown.Menu>
</DropDown>
);
};
export default Select;
이제 해당 컴퍼넌트를 기반으로 테스트코드를 작성하려고 합니다.
https://www.samdawson.dev/article/how-to-test-compound-components/
How to test Compound Components
For a quick overview on Compound Compounds check out this article. Compound components are made up of many component parts. The most…
www.samdawson.dev
이때 DropDown은 결국 하나의 완성된 select를 만들기 위한 합성 컴포넌트이고, '컴파운드 패턴의 컴퍼넌트가 뭉쳐서 제대로 작동하는지를 확인하려면 전체를 한번에 테스트하는 것이 좋다' 라고 얘기합니다.
따라서 저도 Select에서 하나의 테스트로서 해당 컴퍼넌트를 테스트 하기로 했습니다.
드랍다운의 폴더구성은 아래와 같습니다.
┣ 📂hook
┃ ┣ 📜useDropClick.ts
┃ ┣ 📜useDropDown.ts
┃ ┗ 📜useDropKeyboard.ts
┣ 📜DropDown.css
┗ 📜index.tsx
각각 클릭이벤트, 상태, 키보드 이벤트를 관리하는 훅 3개와 CSS, 컴퍼넌트를 관리하는 index 파일이 존재합니다.
이 중에서 테스트할 내용은 아래와 같다
- 랜더링이 제대로 되는지
- 드랍다운이 제대로 열리는지
- 키보드 이벤트가 제대로 먹히는 지
- 외부 영역 클릭시 닫히는 지
- 아이템을 선택했을 때, 해당 값이 선택되는지
RTL 공식문서에 따르면, 테스트를 할 경우 유저가 사용하는 역할을 통해서 테스트 하는 것이 좋다고 했습니다
이를 위해서 Trigger를 일반 div에서 버튼으로 변경했습니다.
const Trigger: React.FC<TriggerProps> = ({ isOpen, toggleOpen, value }) => {
return (
<button
className={`trigger text ${isOpen ? 'open' : ''}`}
onClick={toggleOpen}
>
{value ? value : '선택해주세요'}
</button>
);
};
그리고 item의 유무를 통해서 select가 제대로 열렸는지 판단을 하려하는데 아이템에 value값은 동적이기에 data-testid를 부여하였습니다.
const Item: React.FC<ItemProps> = ({
value,
setValue,
toggleOpen,
selected,
}) => {
const onClickValue = () => {
setValue(value);
toggleOpen();
};
return (
<div
data-testid="dropdown-item"
className={`text item ${selected ? 'current' : ' '}`}
onClick={onClickValue}
>
{value}
</div>
);
};
테스트 코드 작성
반복 작업 모듈화 이전..
import userEvent from '@testing-library/user-event';
import Select from './Select';
import { render, screen } from '@testing-library/react';
it('셀렉트가 초기에 "선택해주세요"로 렌더링 되고, dropdowm이 닫혔는가', () => {
render(<Select />);
expect(screen.getByText('선택해주세요')).toBeInTheDocument();
expect(screen.queryByTestId('dropdown-item')).not.toBeInTheDocument();
});
it('클릭 시 열리면서 item들이 보이는가', async () => {
render(<Select />);
const trigger = screen.getByRole('button');
await userEvent.click(trigger);
expect(screen.getAllByTestId('dropdown-item')[0]).toBeInTheDocument();
});
it('아이템 선택 클릭시 value값이 선택되면서 닫히는가', async () => {
render(<Select />);
const trigger = screen.getByRole('button');
await userEvent.click(trigger);
const dropdownItems = screen.getAllByTestId('dropdown-item');
const value = dropdownItems[0].textContent;
await userEvent.click(dropdownItems[0]);
expect(trigger).toHaveTextContent(value as string);
expect(screen.queryByTestId('dropdown-item')).not.toBeInTheDocument();
});
describe('키보드 이벤트', () => {
it('열린 후 키보드로 벨류 값이 조작되는가?', async () => {
render(<Select />);
const trigger = screen.getByRole('button');
await userEvent.click(trigger);
const dropdownItems = screen.getAllByTestId('dropdown-item');
const value = dropdownItems[0].textContent;
await userEvent.keyboard('{arrowdown}');
expect(trigger).toHaveTextContent(value as string);
expect(dropdownItems[0]).toHaveClass('current');
await userEvent.keyboard('{arrowdown}');
expect(trigger).toHaveTextContent(dropdownItems[1].textContent as string);
expect(dropdownItems[1]).toHaveClass('current');
await userEvent.keyboard('{arrowup}');
expect(trigger).toHaveTextContent(value as string);
expect(dropdownItems[0]).toHaveClass('current');
});
it('열렸을 때, enter 혹은 esc로 닫히는가?', async () => {
render(<Select />);
const trigger = screen.getByRole('button');
await userEvent.click(trigger);
await userEvent.keyboard('{enter}');
expect(screen.queryByTestId('dropdown-item')).not.toBeInTheDocument();
await userEvent.click(trigger);
const dropdownItems = screen.getAllByTestId('dropdown-item');
expect(dropdownItems.length).toBeGreaterThan(0);
await userEvent.keyboard('{escape}');
expect(screen.queryByTestId('dropdown-item')).not.toBeInTheDocument();
});
});
it('열린 셀렉트가 외부 영역 클릭시 닫히는가?', async () => {
render(<Select />);
const trigger = screen.getByRole('button');
await userEvent.click(trigger);
// Select 컴포넌트 외부 영역을 클릭합니다.
const outsideArea = document.body;
await userEvent.click(outsideArea);
expect(screen.queryByTestId('dropdown-item')).not.toBeInTheDocument();
});
각각을 설명하면 아래와 같습니다.
it('셀렉트가 초기에 "선택해주세요"로 렌더링 되고, dropdowm이 닫혔는가', () => {
render(<Select />);
expect(screen.getByText('선택해주세요')).toBeInTheDocument();
expect(screen.queryByTestId('dropdown-item')).not.toBeInTheDocument();
});
trigger를 정의할 때, 값이 undefined면 선택해주세요 라는 값이 보이도록 하였습니다. 따라서 초기에 정상적으로 렌더링이 되었다면 선택해주세요가 보일 것이고, 드롭다운이 닫혔다면 isOpen이 false이기에 item들이 랜더링되지 않습니다.
it('클릭 시 열리면서 item들이 보이는가', async () => {
render(<Select />);
const trigger = screen.getByRole('button');
await userEvent.click(trigger);
expect(screen.getAllByTestId('dropdown-item')[0]).toBeInTheDocument();
// 윗 줄은 아래 두 줄로 대체가능
//const dropdownItems = screen.getAllByTestId('dropdown-item');
//expect(dropdownItems.length).toBeGreaterThan(0);
});
위 코드가 trigger를 버튼으로 바꾼 이유입니다. trigger의 text는 선택된 value에 따라 변경되지만 역할은 button으로 고정됩니다. 또한 dropdown에서 유일하게 존재하는 버튼 요소이기엔 탐색하기도 쉽습니다.
user가 클릭했는지를 테스트하고, dropdown아이템이 존재하는지 찾아야 합니다.
이때 유의할 점은 2가지입니다.
- userEvent는 비동기이기에 async await을 사용해야 한다.
- 요소가 2개 이상일때는 getByTestId는 에러를 뱉는다.
- getAllByTestId는 배열이기에 검증시에는 인덱스를 붙여 사용해야 한다.
사실 처음에 작성할 때는 queryAllByTestId를 사용해야 하는가 생각했습니다. 하지만 eslint 에러가 떴습니다.
아래 내용을 요약하자면 있다는 사실을 검증할 때는 getBy, 없다는 사실을 검증하려면 queryBy를 사용해야 합니다.
https://github.com/testing-library/eslint-plugin-testing-library/blob/main/docs/rules/prefer-presence-queries.md
eslint-plugin-testing-library/docs/rules/prefer-presence-queries.md at main · testing-library/eslint-plugin-testing-library
ESLint plugin to follow best practices and anticipate common mistakes when writing tests with Testing Library - testing-library/eslint-plugin-testing-library
github.com
it('아이템 선택 클릭시 value값이 선택되면서 닫히는가', async () => {
render(<Select />);
const trigger = screen.getByRole('button');
await userEvent.click(trigger);
const dropdownItems = screen.getAllByTestId('dropdown-item');
const value = dropdownItems[0].textContent;
await userEvent.click(dropdownItems[0]);
expect(trigger).toHaveTextContent(value as string);
expect(screen.queryByTestId('dropdown-item')).not.toBeInTheDocument();
});
이번에도 select를 연 후에, dropDownItems들을 찾아낸 후에 첫 번쨰 값의 text내용을 value로 저장한다.
그리고 첫 번쨰 item을 클릭했을 때, trigger값이 value와 일치하는지 비교한다.
그리고 클릭 후에 닫혔는지를 확인한다.
이때 클릭 하기 위해서 trigger를 클릭해서 여는 작업이 매번 반복된다. 하지만 이 내용은 추후에 할 테스트에서는 중요한 부분이 아니고, 가독성만 방해한다.
따라서 이를 모듈화할 필요성이 있다.
초기에 생각한 것은 beforeEach 메서드를 사용하는 것이다.
beforeEach(() => {
render(<Select />);
});
하지만 이렇게 할 경우 eslint에러가 뜬다.
해당 링크는 deprecated 되서 https://github.com/testing-library/eslint-plugin-testing-library/blob/main/docs/rules/no-render-in-lifecycle.md에서 내용을 확인할 수 있는데, 관련 내용은 굳이 beforeEach를 써서 구현해야 하는 내용인가이다.
차라리 모듈화를 통해서 하는 것이 맞다고 해당 내용에서 제시한다. feforEach와 describe등에 너무 많은 내용들이 담기게 되면, 특정 변수가 어디서 왔는지 찾기도 힘들고, 이를 유지보수하는 사람 입장에서 오히려 불편해진다.
따라서 Drop을 연 상태로 세팅하기위 아래와 같이 명명했고, trigger 버튼을 사용할 때도 있기에 return해주었다.
const setUpDropOpen = async () => {
render(<Select />);
const trigger = screen.getByRole('button');
await userEvent.click(trigger);
return trigger;
};
이를 반영해서 테스트 코드들을 수정하였다. 단지 코드만 수정했을 뿐만 아니라, describe를 통해서 좀 더 테스트 내용을 가독성 좋게 바꿀 수 있었다. trigger라는것이 드롭다운을 연 결과물에서 나오는 것도 test내부에서 파악할 수 있다.
모듈화 이후의 코드
it('셀렉트가 초기에 "선택해주세요"로 렌더링 되고, 드롭다운이 닫혔는가', () => {
render(<Select />);
expect(screen.getByText('선택해주세요')).toBeInTheDocument();
expect(screen.queryByTestId('dropdown-item')).not.toBeInTheDocument();
});
it('클릭 시 열리면서 item들이 보이는가', async () => {
await setUpDropOpen();
expect(screen.getAllByTestId('dropdown-item')[0]).toBeInTheDocument();
});
describe('셀렉트가 열린 상태에서', () => {
it('아이템 선택 클릭시 value값이 선택되면서 닫히는가', async () => {
const trigger = await setUpDropOpen();
const dropdownItems = screen.getAllByTestId('dropdown-item');
const value = dropdownItems[0].textContent;
await userEvent.click(dropdownItems[0]);
expect(trigger).toHaveTextContent(value as string);
expect(screen.queryByTestId('dropdown-item')).not.toBeInTheDocument();
});
describe('키보드 이벤트가 작동하는가?', () => {
it('열린 후 키보드로 벨류 값이 조작되는가?', async () => {
const trigger = await setUpDropOpen();
const dropdownItems = screen.getAllByTestId('dropdown-item');
const value = dropdownItems[0].textContent;
await userEvent.keyboard('{arrowdown}');
expect(trigger).toHaveTextContent(value as string);
expect(dropdownItems[0]).toHaveClass('current');
await userEvent.keyboard('{arrowdown}');
expect(trigger).toHaveTextContent(dropdownItems[1].textContent as string);
expect(dropdownItems[1]).toHaveClass('current');
await userEvent.keyboard('{arrowup}');
expect(trigger).toHaveTextContent(value as string);
expect(dropdownItems[0]).toHaveClass('current');
});
it('열렸을 때, enter 혹은 esc로 닫히는가?', async () => {
const trigger = await setUpDropOpen();
await userEvent.keyboard('{enter}');
expect(screen.queryByTestId('dropdown-item')).not.toBeInTheDocument();
await userEvent.click(trigger);
const dropdownItems = screen.getAllByTestId('dropdown-item');
expect(dropdownItems.length).toBeGreaterThan(0);
await userEvent.keyboard('{escape}');
expect(screen.queryByTestId('dropdown-item')).not.toBeInTheDocument();
});
});
it('외부 영역 클릭시 닫히는가?', async () => {
await setUpDropOpen();
// Select 컴포넌트 외부 영역을 클릭합니다.
const outsideArea = document.body;
await userEvent.click(outsideArea);
expect(screen.queryByTestId('dropdown-item')).not.toBeInTheDocument();
});
});
피드백
위 코드는 UI 랜더링에 관련된 코드고 별도의 API로직 등에는 관련이 없다. 비동기적인 요소도 없고,,
StoryBook을 통한 비쥬얼 테스트로 끝내는게 가장 간단하고 적합하지 않았을까? 생각이 든다.