안정적인 소프트웨어 개발을 위해서는 테스트 작성이 필요하고 그에 따른 장점은 많습니다. 하지만 이 글에서는 테스트의 정의와 그 이유를 자세하게 설명하진 않습니다. 이 부분을 더 알고 싶으시다면 개인적으로는 Kent Beck님의 "Test Driven Development" 도서를 추천해 드립니다. 이 글에서의 리액트 테스트 전략은 정답이 아닙니다. 개발하는 리액트 프로젝트에 맞게 테스트를 작성하면 됩니다. 이 글에서는 실용적인 리액트 테스트를 위해 어떻게 테스트를 작성해야 할 지에 대한 고민을 담아봤습니다.
프론트엔드에서는 UI 변경이 자주 이루어지며 결과가 어떤 값이 아닌 시각적 요소로 나타납니다. 따라서 이를 어떻게 테스트 해야할 지 쉽지 않았습니다.
리액트는 컴포넌트와 Action과 Reducer로 이루어지는 상태관리를 중점으로 개발하기 때문에 테스트도 이 두 가지를 중점으로 테스트하는 경우가 많습니다. 다양한 글에서 소개 중인 리액트 테스팅을 참고하여 테스트를 작성해보면서 느낀 점들을 정리합니다.
테스트 환경은 아래와 같습니다.
Jest
: 테스팅 프레임워크
Enzyme
: 리액트 테스팅 라이브러리
Cypress
: E2E 테스트 도구
컴포넌트는 props에 따라 내용이나 스타일이 변경될 수 있는데 이를 검증하는 테스트를 작성할 수 있습니다.
describe('Button', () => {
it('disabled prop에 따라 변경이 이루어진다.', () => {
// given
const isDisabled = mount(<Button disabled>iambutton</Button>);
// then
expect(isDisabled.prop('disabled')).toBe(true);
expect(isDisabled).toHaveStyleRule('pointer-events', 'none');
});
});
App Component가 Counter 컴포넌트를 그려내고 Counter 컴포넌트의 prop와 상태가 정상적으로 반영되는 것을 테스트할 수 있습니다.
describe('App Component', () => {
it('App Componet가 Counter를 그려낸다.', () => {
// given
const wrapper = shallow(<App><Counter counter={0} /></App>);
// then
expect(wrapper.find(Counter)).to.have.length(1);
});
it('Counter의 counter 값이 유효하다.', () => {
// given
const wrapper = shallow(<App><Counter counter={0} /></App>);
let counterWrapper = wrapper.find(Counter);
// when
wrapper.setState({ counter: -1 });
// then
counterWrapper = wrapper.find(Counter);
expect(counterWrapper.props().counter).to.equal(-1);
});
});
위에서 하나의 컴포넌트에 대한 테스트 또는 여러 자식 컴포넌트를 가지고 있는 컴포넌트에 대한 단위, 통합 테스트를 작성할 때 props가 제대로 전달되었는지,상태변경에 따라 props가 의도한대로 값이 변화하는지 테스트를 작성해봤습니다. 근데 이 테스트가 정말 유효할까요? state 변경은 dispatcher가 담당하지만 그 dispatcher는 리액트가 제공해준 것이지 우리가 만든 것이 아닙니다. 또한 상태에 따라 props가 변경되는 것도 마찬가지입니다.
이 테스트가 완전히 유효하지 않다는 것이 아닙니다. 상태가 변경되고 이 상태를 (순수함수로 작성될 수 있는)다른 값으로 변환하는 함수를 거쳐서 prop으로 전달한다면 유효할 수도 있겠습니다. 그렇다면 컴포넌트의 상태와 props를 테스트하기 보다는 위 함수를 testable하게 분리하고 이를 테스트하는 것이 더 유효성이 높다고 생각됩니다.
결국 우리는 컴포넌트를 테스트를 하는 것이 아닌 리액트를 테스트하는 것이었고, 가능하면 컴포넌트 내부에 작성된 순수 함수에 대해 테스트를 작성하는 것이 실용적이지 않을까요?
스냅샷테스팅 이전에 저장된 스냅샷과 현재의 스냅샷을 비교하는 테스트입니다.
describe("Button", () => {
it("[SNAPSHOT] Button", () => {
// given
const wrapper = mount(<Button>iamButton</Button>);
// then
expect(wrapper).toMatchSnapshot(); // 📸
});
});
스냅샷 테스트가 진행되면 button.ts.snap
파일이 생성되며 이 파일에는 최종적으로 DOM이 가지는 마크업과 스타일이 쓰입니다.
스냅샷 테스트을 사용하면 테스트 작성비용이 많이 줄어듭니다. props에 따른 컴포넌트의 마크업과 스타일을 검사할 필요가 없이 각기 다른 prop을 전달한 컴포넌트에 대해 스냅샷을 진행하면 됩니다. 스냅샷 테스트는 컴포넌트의 온전한 마크업과 스타일을 저장하기 때문에 컴포넌트가 수정되면 테스트는 실패하게 됩니다. 따라서 자주 수정이 되지 않는 컴포넌트를 중점으로 진행하는 게 좋다고 생각합니다. 그러나, UI는 정적인 요소가 아니므로 자주 수정이 이루어질 수밖에 없고 테스트는 그때마다 실패하게 됩니다. 또한 이 테스트는 jest에서 스냅샷을 다시 촬영하는 옵션(--u
)을 사용하면 쉽게 통과할 수 있습니다. 자주 실패하고 쉽게 통과할 수 있는 테스트를 작성해야 할까요?
컴포넌트를 작성하고 결과를 확인하는 과정 또한 테스트라고 할 수 있습니다. 스냅샷테스팅의 단점을 안고 가지 않으면서 테스트 할 수 없을까요? 리액트에서 컴포넌트 작성할 때 페이지 어딘가 넣어보고 (테스트하기 위해서) 결과를 확인할 텐데 이때 Storybook을 이용하면 작성하고 있는 또는 작성된 컴포넌트를 독립된 환경에서 개발하고 체계적으로 관리할 수 있습니다. 아래는 현재 사용하고 있는 스토리북 스크린 샷입니다.
스토리북을 사용하게 되면 얻는 장점은 많습니다. 첫번째로 스토리북의 Addons를 이용하면 개발자 도구에서 테스트하기 위해 값을 고치고 컴포넌트를 다시 확인하는 과정도 스토리북이 편리하게 조절할 수 있도록 지원해주기 때문에 컴포넌트 테스트 시간을 크게 줄여줍니다. 또한, 디렉터리에서 그 컴포넌트를 찾고 분석하며 컴포넌트 사용을 위해 고민하는 것을 줄여주고 더 빠르게 UI 개발이 가능해집니다. 왜냐하면 하나의 Story(컴포넌트 페이지)를 보면 컴포넌트의 사용법을 알 수 있으므로 동료가 작성한 컴포넌트도 쉽게 사용할 수 있습니다.
Storybook을 활용하여 컴포넌트를 관리하고 쉽게 확인이 가능해짐으로써 스냅샷테스팅의 목표였던 컴포넌트가 보여지는 모습에 대한 기대를 충족시킬 수 있다고 생각합니다.
useFetch는 컴포넌트에서 서버로 데이터를 요청하고 이를 컴포넌트에 그 데이터 내용을 Fetch하는 커스텀 훅입니다. 프론트엔드에서는 비동기 요청에 따른 비즈니스 로직이 중점이라고 볼 수 있습니다. 이를 아래와 같이 커스텀 훅으로 만들고 테스트 할 수 있습니다.
// useFetch.ts
function reducer(result: FetchProps, action: FetchProps,
): FetchProps {
const { type, data, err } = action;
switch (type) {
case REQUEST: return { type };
case SUCCESS: return { type, data };
case FAILURE: return { type, err };
}
return result;
}
export default function useFetch(axiosOptions: AxiosRequestConfig): FetchProps {
const initialState: FetchProps = { type: REQUEST };
const [result, dispatch] = useReducer>(reducer, initialState);
useEffect(() => {
const fetchData = async () => {
try {
const { status, data } = await axios(axiosOptions);
if (status < 300) dispatch({ type: SUCCESS, data });
} catch (err) dispatch({ type: FAILURE, err });
};
if (result.type === REQUEST) fetchData();
}, [result.type, axiosOptions]);
return [result, dispatch];
}
// useFetch.spec.ts
describe('Hooks', () => {
it('useFetch가 비동기 요청을 처리하고 이를 정상적으로 응답한다.', async () => {
// given
const url = 'http://www.mocky.io/v2/5dde1af02f00004b697eacd6';
const resultTypes = [];
const steps = [
{ type: 'request' },
{ type: 'success', data: { hello: 'world' }, status: OK },
];
function MockComponent() {
const result = useFetch({ method: 'get', url });
useEffect(() => {
resultTypes.push(result);
}, [result]);
return <>;
}
// when
mount(<MockComponent />);
// then
waitFetching();
expect(steps).toEqual(resultTypes);
});
리액트에선 주로 서버로부터 비동기 요청 후에 컴포넌트를 통해 시각적으로 보여주는 일을 주로 하게 됩니다. 이 때 컴포넌트의 상태변경이 이루어지고 이를 Custom Hook과(또는 Redux) Action, Reducer를 통해 관리하게 되는데 이 코드들이 리액트에서 주요한 비즈니스 로직 이라고 생각합니다. 이를 보장할 수 있는 테스트는 필수적이라고 생각합니다.
기존에 Selenium을 이용하여 E2E 테스트를 작성했다면 최근에는 Cypress를 이용하여 E2E 테스트를 진행할 수 있습니다. Cypress를 이용하면 사용자의 클릭이나 입력, 테스트를 위한 쿠키설정, Route를 mock으로 만들고 window의 alert를 stub으로 만들어 spy로 등록하는 등 테스트를 위한 더 다양한 기능들을 사용할 수 있습니다.
아래와 같이 이벤트를 예약하는 사용자 입장에서의 시나리오에 대해 검증하기 위한 테스트를 작성할 수 있습니다. 더 자세한 테스트 케이스는 생략하였습니다. 아래 코드에서는 다음과 같은 일을 합니다.
context('이벤트 예약 페이지', () => {
beforeEach(() => {
cy.server();
// Mock - 이벤트 정보를 불러오는 API
cy.route('/api/events/1234', 'fixture:reserve_event.json').as('getJoinPageEvent');
// Mock - 이벤트 정보를 불러오는 API
cy.route('POST', '/api/users/reserve/check', 'OK').as('joinCheck');
// 로그인 상태 유지
cy.setCookie('UID', Cypress.env('auth_token'));
cy.visit('/events/1234/register/tickets');
cy.wait('@getJoinPageEvent');
cy.wait('@joinCheck');
});
it('(여러 수량을 구매할 수 있는 이벤트의) 티켓 체크박스 클릭 시 수량 Counter가 보여진다.', () => {
cy.get('[data-testid=ticketbox-chkbox]').click();
cy.get('[data-testid=counterbox-container]').within(items => {
expect(items).has.length(1);
});
});
it('예약이 성공적으로 이루어지면 alert와 내 티켓 페이지로 리다이렉션이 이루어진다', () => {
cy.route({
method: 'POST',
url: '/api/users/reserve',
status: 200,
});
const alertStub = cy.stub();
cy.on('window:alert', alertStub);
cy.get('[data-testid=ticketpurchase-purchasebtn]')
.click()
.then(() => {
expect(alertStub.getCall(0)).to.be.calledWith(RESERVE_COMPLETE);
});
cy.location('pathname').should('eq', '/my/tickets');
});
});
styled-component와 같은 라이브러리를 사용하고, className을 따로 지정해주지 않는다면 사용자 정의 attribute(ex.
data-testid
)를 만들어 사용해야 합니다. 링크에서 더 자세한 내용을 볼 수 있습니다. cypress best practices
사용자 시나리오 기반으로 테스트하는 것은 모든 프론트엔드에서 필요한 테스트입니다. 사용자 시나리오 기반으로 웹페이지 내에서 클릭하거나 타이핑하거나 하는 모든 사용자 인터렉션을 정의할 수 있으며 부자연스럽거나 이상 현상이 없는지 자동화된 테스트를 할 수 있습니다.
컴포넌트를 작성할 때 UI에 대한 코드를 작성하고 이 코드가 화면에 어떻게 보이는지 확인하고 다시 코드를 조금 수정하고, 확인하는 반복적인 작업을 하게됩니다. 이 과정 또한 테스트라고 볼 수 있습니다. 하지만 우리가 이 테스트를 해야할까요? 사람은 반복적인 작업을 싫어하고 컴퓨터는 반복적인 작업을 좋아합니다. 우리는 반복적인 작업을 싫어하고 더 중요한 다른 곳에 시간을 쓰고 싶습니다. 따라서 이를 검증하는 자동화된 테스트를 작성할 필요가 있습니다.
하지만 E2E 테스트는 기획요소에 따라 테스트가 수정될 수 있고 사용자 시나리오가 복잡할수록 가장 큰 비용(시간과 노력)이 들 수 있습니다. 그렇지만 사용자 관점에서 테스트하기 때문에 효과는 가장 크다고 볼 수 있습니다. 또한 테스트는 자주 수정될 수 있는 시나리오보다 확정된 시나리오일수록 구체적으로 작성될 필요가 있습니다.
위의 고민 끝에 실용적인 리액트 테스트는 나열하면 아래와 같고, 1번과 2번을 기반으로 3번을 진행하는 것이 좋다고 생각합니다.
(Custom Hooks or Redux, Action, Reducer..)
"우리는 Product code에 대한 보수를 받는 것이지, Test code에 대한 보수를 받지 않는다." 라고 말씀하셨던 Kent Beck님의 말씀이 생각나네요. 테스트 커버리지 100%를 향하기보다는, 실제 제품 코드보다 테스트 코드를 더 중요시하기보다는, 소프트웨어의 안정적인 동작을 목표로 효과적인 테스트를 작성하기 위해 노력해야 한다고 생각합니다. 이 글에서도 리액트에서 실용적인 테스트를 어떻게 해야할까의 고민을 녹여보려 했습니다.
아래는 저희 프로젝트 저장소와 스토리북, Cypress dashboard 링크입니다. 참고되셨으면 좋겠습니다.
관심가져주신 분들이 많아 사용하는 환경에 대해서 boilerplate를 만들었습니다 :)
저는 이 글을 끝으로 참여하고 있던 부스트캠프 2019도 마치게 되었습니다. 5개월 전 처음 "테스트 코드" 를 알게 된 후 관심이 많았었는데 일찍 알게 되어 정말 행운이었다는 생각이 듭니다. 프론트엔드에서의 테스트에 대한 고민도 값진 경험이었습니다.
+ 프로젝트를 진행하며 같이 고민하고 함께해준 팀원들에게 고마움을 표하고 싶네요. 고마워요. 여러분 😘
@dobest27 @FullOfOrange @inthewalter
(banner image reference: https://morioh.com/p/849382d0de0e)
잘 읽고 갑니다!