TOAST FORWARD 프런트엔드 테스팅 기초부터 실전까지 참여
요즘 테스트에 큰 관심을 가지고 있는데 NHN TOAST 에서 좋은 세미나를 해서 참여하게 되었다.
백엔드 코드들은 단위테스트라도 꾸준히 짜려고 노력하고 있는데 프론트에서 테스트 코드를 작성하는게 좀 애매했기 때문에 이번 기회에 많이 배울 수 있겠다 싶어 추첨되길 엄청 기대했다 ㅠㅠ.
다행히 추첨에 성공해 들을 수 있었다!!
export function add(a = 0, b = 0) {
return a + b;
}
export function swap(arr) {
if (arr.length !== 2) return arr;
return [arr[1], arr[0]];
}
--------------------
import { add, swap } from '../src/util';
// XXX: TDD is not difficult. STEP BY STEP
describe('add()', () => {
test('인자가 없으면 0을 반환한다.', () => {
expect(add()).toBe(0);
});
test('인자가 하나이면, 인자 그대로 반환한다.', () => {
expect(add(5)).toBe(5);
});
test('인자가 두 개이면 두 인자를 더한 결과를 반환한다', () => {
expect(add(3, 5)).toBe(8);
});
});
describe('swap()', () => {
const nums = [1, 2];
const nums2 = [1, 2, 3];
test('배열의 인자가 두 개가 아닌 경우, 기존 배열을 그대로 반환한다.', () => {
expect(swap(nums)).not.toEqual(nums);
expect(swap(nums2)).toEqual(nums2);
});
test('배열 내의 두 요소의 순서를 바꾸어 새로운 배열을 반환한다.', () => {
expect(swap(nums)).toEqual([2, 1]);
});
test('변경된 배열은 기존 배열과 다른 새로운 배열이다.', () => {
const tmp = [nums[1], nums[0]];
expect(swap(nums)).not.toBe(tmp);
});
});
위의 코드가 한번에 적혀있어서 음 당연하네? 생각할 수 있는데
실습에서 한 줄씩 TDD로 개발하니까 엄청 와닿는 것들이 많았다. 위의 코드를 한줄씩 추가하다보면 기존의 통과한 테스트가 깨지는 경우가 많다.(인자를 추가하고 return 값을 바꾸는 등) 여기가 바로 버그가 생길 수 있는 지점이다.
어떤 로직을 개발해 QA 다 통과해 라이브로 배포가 되었다고 하자. 이 로직에 추가되어 새로운 피쳐가 하나 더 들어갔을 때 QA 는 기존의 것부터 처음부터 다 테스트를 해야할까? 이런 비용을 테스트 코드로 상당히 줄일 수 있게 된다.
테스트 코드 작성시 중요한 점에서 크게 공감했던 부분이다.
- 속도가 빨리야 한다.
-
내부 구현 변경 시 실패하지 않아야 한다.
- 인터페이스를 기준으로 테스트 작성, 모델과 뷰를 분리: 내부 종속적 코드를 작성하지 말라; private 변수 검출 등
-
라이브 버그를 검출할 수 있어야 한다.
- 테스트 더블: Mock;의 사용을 최소화 한다.
-
커버리지 100%를 목표로 하지 마라.
- 모든 API를 다 통과해야할까? 비용과 타협의 적절한 선이 중요하다.
-
나는 제품 코드를 작성해 돈을 받는거지 테스트 코드를 작성해 돈을 받는게 아니다.
- 테스트 코드를 맹신하지 말라.
UI 테스트
내가 전혀 모르던 것들을 볼 수 있는 기회라서 엄청 좋았다.
프론트의 경우 시각적 요소가 강하기 때문에 테스트에 어려운 점들이 많다. 페이지 로딩시 돔이 어디까지 그려졌는지 등은 결국 눈으로 봐야 하는데, 아래의 테스트 방법들을 이용해 조금씩 보완할 수 있다.
위와 같은 간단한 카운터 컴포넌트가 있다고 하자.
- HTML 구조 테스트(DOM 태그를 파싱해 문자열 비교)
import prettyHTML from 'diffable-html';
// .. 초기화
it('생성시 버튼과 초기값을 렌더링한다.', () => {
expect(prettyHTML($container.html())).toBe(
prettyHTML(`
<button type="button" class="btn btn-secondary btn-dec">-</button>
<span class="value">10</span>
<button type="button" class="btn btn-primary btn-inc">+</button>
`)
);
});
- 스냅샷 테스트(이전과 변경 사항 비교)
exports[`+ 버튼 클릭시 1 증가한다. 1`] = `
<button type="button"
class="btn btn-secondary btn-dec"
>`;
---
it('+ 버튼 클릭시 1 증가한다.', () => {
$el.find('.btn-inc').click();
expect($container.html()).toMatchSnapshot();
});
위의 두 방법은 모두 돔 종속적 테스트인데 class 이름이 변경되거나 tag 가 변경될 경우 문제가 된다. 또한 브라우저나 OS에 따라 렌더링 방식의 차이가 있기 때문에 기술적 한계가 있다.
변경된 부분을 정확히 감지하고 의도된 변경인지 파악하기가 어려운 구조.
그래서 dom-testing-library
와 jest-dom
을 소개해 주셨는데 이를 통해 UI 테스트를 더욱 쉽게 할 수 있었다.
import '@testing-library/jest-dom/extend-expect';
import { fireEvent, getByTestId } from '@testing-library/dom';
import { createUICounter } from '../../src/uiCounter/counter';
let container;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('생성시 버튼과 초기값을 렌더링한다.', () => {
createUICounter(container);
expect(getByTestId(container, 'btn-inc')).toBeVisible();
expect(getByTestId(container, 'btn-dec')).toBeVisible();
expect(getByTestId(container, 'value').textContent).toBe('0');
});
it('+ 버튼 클릭시 1 증가한다.', () => {
createUICounter(container);
fireEvent.click(getByTestId(container, 'btn-inc'));
expect(getByTestId(container, 'value').textContent).toBe('1');
});
jest-dom
을 사용해 data-testid
를 가져와 테스트를 하고 있다.
컴포넌트 자체를 만들었다 지웠다 하면서 테스트 하는게 인상적이었음.
Mocking
import '@testing-library/jest-dom/extend-expect';
jest.mock('../../src/backup/counter');
...
it('생성시 counter의 상태에 버튼과 초기값을 렌더링한다.', () => {
createCounter.mockImplementation(() => ({
val: () => 10,
isMin: () => false,
isMax: () => false
}));
createUICounter(container);
expect(getByText(container, '+')).toBeVisible();
expect(getByText(container, '-')).toBeVisible();
expect(getByText(container, '10')).toBeVisible();
});
맨 처음에 이야기 했듯 목데이터는 최대한 적게 쓰는게 좋기 때문에 어떻게 쓰는지만 코드로 적어놨다.
E2E Test with Cypress
오늘의 하이라이트이다. 내가 가장 흥미롭게 본 부분.
Cypress 를 처음 접했는데 굉장히 유용해 보였다.
import '@testing-library/cypress/add-commands';
beforeEach(() => {
cy.visit('/');
});
it('생성시 버튼과 초기값을 렌더링한다.', () => {
cy.get('.btn-inc').should('be.visible');
cy.get('.btn-dec').should('be.visible');
cy.get('.value').should('have.text', '10');
});
it('+ 버튼 클릭시 1 증가한다.', () => {
cy.get('.btn-inc').click();
cy.get('.value').should('have.text', '11');
});
it('- 버튼 클릭시 1 감소한다.', () => {
cy.get('.btn-dec').click();
cy.get('.value').should('have.text', '9');
});
it('Max값인 경우 + 버튼이 disabled 상태가 되며 클릭해도 증가하지 않는다.', () => {
cy.get('.btn-dec').click();
cy.get('.btn-dec').click();
cy.get('.btn-dec').should('have.attr', 'disabled');
});
코드를 작성하고 cypress 를 실행하면 위와 같이 테스팅 창이 뜨게 되고 테스트 코드 파일들을 불러와 보여준다.
내가 대박이라 생각했던 점은 테스트 코드의 중간단계를 모두 볼 수 있고 값들이 변경되는 지점이나 어떤 속성이 변하는지 before, after를 모두 볼 수 있다는 점이었다.
얘는 개인 프젝으로 한번 꼭 써봐야 겠다고 생각했다.
마무리하며
Storybook
으로 UI 개발 후 JEST
로 API 테스트하고 Cypress
로 E2E 테스트까지 하는 과정속에서 TDD 과정을 전반적으로 볼 수 있어서 좋았다.
아 그리구 엄청난 선물도 받았다. 내용물은 비밀~
그럼 이만!