1ilsang

Developer who will be a Legend

TOAST FORWARD 프런트엔드 테스팅 기초부터 실전까지 참여

2020-01-15 1ilsangSeminar

cover

요즘 테스트에 큰 관심을 가지고 있는데 NHN TOAST 에서 좋은 세미나를 해서 참여하게 되었다.

백엔드 코드들은 단위테스트라도 꾸준히 짜려고 노력하고 있는데 프론트에서 테스트 코드를 작성하는게 좀 애매했기 때문에 이번 기회에 많이 배울 수 있겠다 싶어 추첨되길 엄청 기대했다 ㅠㅠ.

index

다행히 추첨에 성공해 들을 수 있었다!!

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 는 기존의 것부터 처음부터 다 테스트를 해야할까? 이런 비용을 테스트 코드로 상당히 줄일 수 있게 된다.

테스트 코드 작성시 중요한 점에서 크게 공감했던 부분이다.


  1. 속도가 빨리야 한다.
  2. 내부 구현 변경 시 실패하지 않아야 한다.
    • 인터페이스를 기준으로 테스트 작성, 모델과 뷰를 분리: 내부 종속적 코드를 작성하지 말라; private 변수 검출 등
  3. 라이브 버그를 검출할 수 있어야 한다.

    • 테스트 더블: Mock;의 사용을 최소화 한다.
  4. 커버리지 100%를 목표로 하지 마라.

    • 모든 API를 다 통과해야할까? 비용과 타협의 적절한 선이 중요하다.
  5. 나는 제품 코드를 작성해 돈을 받는거지 테스트 코드를 작성해 돈을 받는게 아니다.

    • 테스트 코드를 맹신하지 말라.

UI 테스트

내가 전혀 모르던 것들을 볼 수 있는 기회라서 엄청 좋았다.

프론트의 경우 시각적 요소가 강하기 때문에 테스트에 어려운 점들이 많다. 페이지 로딩시 돔이 어디까지 그려졌는지 등은 결국 눈으로 봐야 하는데, 아래의 테스트 방법들을 이용해 조금씩 보완할 수 있다.

counter box

위와 같은 간단한 카운터 컴포넌트가 있다고 하자.

  1. 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>
  `)
  );
});
  1. 스냅샷 테스트(이전과 변경 사항 비교)
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-libraryjest-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 overview

코드를 작성하고 cypress 를 실행하면 위와 같이 테스팅 창이 뜨게 되고 테스트 코드 파일들을 불러와 보여준다.

cypress overview2

내가 대박이라 생각했던 점은 테스트 코드의 중간단계를 모두 볼 수 있고 값들이 변경되는 지점이나 어떤 속성이 변하는지 before, after를 모두 볼 수 있다는 점이었다.

얘는 개인 프젝으로 한번 꼭 써봐야 겠다고 생각했다.

마무리하며

Storybook 으로 UI 개발 후 JEST 로 API 테스트하고 Cypress로 E2E 테스트까지 하는 과정속에서 TDD 과정을 전반적으로 볼 수 있어서 좋았다.

gift

아 그리구 엄청난 선물도 받았다. 내용물은 비밀~

그럼 이만!