리액트TDD - Jest와 테스팅 라이브러리

jonyChoiGenius·2023년 5월 22일
0

React.js 치트 시트

목록 보기
20/22

인프런 강의 따라하며 배우는 리액트 테스트를 보며 요약 및 실습한 내용.

Jest

설치

Facebook에서 만든 테스팅 프레임워크이다.

yarn add jest @types/jest

실행

설치 후 jest 명령어를 통해 테스트를 실행할 수 있다.

jest --watchAll --verbose

이때 --verbose는 테스트 결과를 장황하게 표현하라는 뜻이다. 테스트 케이스의 이름을 모두 보여주면서 통과-실패를 표시한다.

--watchAll은 모든 테스트 케이스를 실행하라는 뜻이다. 만일 해당 옵션을 주지 않으면 jest --o(jest --watch 옵션과 같음)를 통해 테스트를 실행하게 되고, git의 커밋 내역의 변동 사항만 테스트한다.

package.json 파일을 아래와 같이 수정하면 yarn test로 실행할 수 있다.

{
  "name": "javascript",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "@types/jest": "^24.0.13",
    "jest": "^24.8.0"
  },
  "scripts": {
    "test": "jest --watchAll --verbose"
  }
}

네이밍 규칙

Jest는 아래의 네이밍 규칙에 해당하는 파일을 찾아 실행시킨다.

{filename}.test.js
{filename}.spec.js
tests/*.js

Jest의 공식문서에서 권장하는 방법은 첫번째 방법이다. 테스트하고자 하는 컴포넌트와 .test.js파일을 같은 폴더에 둠으로써 연관성을 강조한다.

test, it, describe

//sum.js
function sum(a, b) {
  return a + b;
}

module.exports = sum; // 내보내기
//sum.test.js
const sum = require('./sum');

test(‘the addition of 1 and 2, () => {
  expect(sum(1, 2)).toBe(3);
});

test라는 키워드는 'it'과 완전히 동일한 기능을 한다. 단, 해당 키워드의 첫번째 name 인자를 어떻게 기입하냐에 따라 다르다

//sum.test.js
const sum = require('./sum');

it(‘returns 3 when adding 1 and 2, () => {
  expect(sum(1, 2)).toBe(3);
});

test 키워드를 사용할 때 name은 명사형으로 작성한다. it을 사용할 때에는 동사로 시작되는 문장형으로 작성한다.

어느 방향을 사용하던 상관없지만, 일관된 방식이 중요하다.
추천하는 방법은 test 키워드를 사용하고, 한글로 이름을 짓는 것이다.

//sum.test.js
const sum = require('./sum');

test(1 더하기 2, () => {
  expect(sum(1, 2)).toBe(3);
});

describe 키워드를 통해 여러개의 test를 묶을 수 있다. 이렇게 묶인 test케이스들을 '테스트 슈트'라 부른다.

describe('sum', () => {
  it('calculates 1 + 2', () => {
    expect(sum(1, 2)).toBe(3);
  });

  it('calculates all numbers', () => {
    const array = [1, 2, 3, 4, 5];
    expect(sumOf(array)).toBe(15);
  });
});


이미지 출처

expect, Matchers

expect API는 값을 테스트할 때 항상 사용된다.
expect는 주로 Matcher와 함께 사용된다.

Matchers

.toBe(value) : 함수의 결과값이 value인지를 확인한다.
.toHaveReturned() : 함수가 값을 반환했는지 여부를 확인한다.
.toHaveLength(number) : 함수의 반환값이 length 프로퍼티를 가지고 있고, 그 값이 number인지를 확인한다.
.toMatch(regexp | string) : 함수의 반환값이 특정 문자열을 가지고 있는지를 확인한다.

그 밖에 truthy, falsy 등 다양한 Matcher가 있다.
공식문서 참조

Modifiers

expect와 Matchers 사이에 수식어를 넣을 수 있다.

.not
.resolves - resolved된 Promise 값을 벗겨낸다.
.rejects - rejected된 Promise 값을 벗겨된다.

이때 resolves, rejects는 비동기로 처리해줘야 한다.

  it('calculates 1 + 2', () => {
    expect(sum(1, 2)).not.toBe(0);
  });
  

  it('calculates 1 + 2' async () => {
  await expect(Promise.resolve(1 + 2).resolves.toMatchObject({ id : 1 });

  it('returns User Object with userId 1' async () => {
  await expect(Axios.get('/user/1').((res)=> res.data).resolves.toMatchObject({user : { id : 1 }});
});

@testing-library/react vs Enzyme vs react-dom/test-utils

리액트 컴포넌트를 테스트하는 가장 기본적인 방법은 react-dom/test-utils의 유틸함수들을 사용하는 것이다. 그리고 보다 간편한 방법을 위한 테스티용 라이브러리들이 있다.

기존에는 2015년, Airbnb에서 주도적으로 개발한 Enzyme이 많이 쓰였다. Enzyme은 컴포넌트의 Props, State 등을 직접 접근하고 확인한다. 코드가 제대로 작성되었는지, 컴포넌트가 제대로 구현되었는지에 초점을 맞추며, API를 통해 리액트의 구성 요소를 직접 조작하는 방식이다. 클래스형 컴포넌트에서 특히 유용한 방식으로 알려져 있다.

그에 반해 react-testing-library는 컴포넌트가 어떠한 요소를 가지고 있고, 무엇을 렌더링하는지에 초점을 맞춘다. FireEvent를 통해 이벤트를 발생시키고, 이에 의한 요소의 변화를 테스트한다. State나 Props에 직접 접근하지 않는다. 컴포넌트가 어떻게 동작하는지에 관심사를 둔다.

react-testing-library는 사용자의 관점에 조금 더 초점을 둔다. 사용자의 행동에 따라 변화하는 화면을 테스트한다.
Enzyme은 개발자 관점에 초점을 둔다. 컴포넌트가 원하는 상태와 Props를 갖는지, 클래스와 메서드 수준에서 테스트한다.

react-testing-library는 통합 테스트에 초점을 둔다. 여러 모듈들이 서로 올바르게 상호작용하는지에 초점을 두며, 테스트를 통과하기 위한 리팩토링에 조금 더 주안점을 둔다.
Enzyme은 단위 테스트에 중점을 둔다. 각각의 컴포넌트별로 접근하여 고립된 테스트를 진행할 수 있다.

react-testing-library가 Enzyme보다 선호되는 이유로는
1. TDD에 유리하기 때문이다. 화면 구성에 필요한 내용에 대해 react-testing-library로 테스트 코드를 작성한다. 해당 테스트 코드를 통과하기만 한다면, 어떠한 로직으로 작동하고, 얼마나 좋은 코드품질로 리팩토링하는지는 개발자의 몫이다.
2. 가볍고 일관적이다. react-testing-library의 목적은 명확하며, 이에 따라 적은 수의 기능만 지원한다. 배우고 사용하기에 가벼우며,일관된 테스트 코드 작성을 유도한다.

react-testing-library 기초

아래는 Enzyme으로 클래스형 컴포넌트를 테스트하는 모습이다. wrapper로 컴포넌트를 감싼 후, 컴포넌트의 메서드에 접근하여 메서드를 실행시킨 후, state의 값을 확인한다.

describe('<Counter />', () => {
  it('matches snapshot', () => {
    const wrapper = shallow(<Counter />);
    expect(wrapper).toMatchSnapshot();
  });
  it('has initial number', () => {
    const wrapper = shallow(<Counter />);
    expect(wrapper.state().number).toBe(0);
  });
  it('increases', () => {
    const wrapper = shallow(<Counter />);
    wrapper.instance().handleIncrease();
    expect(wrapper.state().number).toBe(1);
  });
  it('decreases', () => {
    const wrapper = shallow(<Counter />);
    wrapper.instance().handleDecrease();
    expect(wrapper.state().number).toBe(-1);
  });
});

클래스형 컴포넌트가 아닌 함수형 컴포넌트에서는 state가 없으므로 dom의 값을 조회하게 된다.

describe('<HookCounter />', () => {
  it('matches snapshot', () => {
    const wrapper = mount(<HookCounter />);
    expect(wrapper).toMatchSnapshot();
  });
  it('increases', () => {
    const wrapper = mount(<HookCounter />);
    let plusButton = wrapper.findWhere(
      node => node.type() === 'button' && node.text() === '+1'
    );
    plusButton.simulate('click');
    plusButton.simulate('click');

    const number = wrapper.find('h2');

    expect(number.text()).toBe('2');
  });
  it('decreases', () => {
    const wrapper = mount(<HookCounter />);
    let decreaseButton = wrapper.findWhere(
      node => node.type() === 'button' && node.text() === '-1'
    );
    decreaseButton.simulate('click');
    decreaseButton.simulate('click');

    const number = wrapper.find('h2');

    expect(number.text()).toBe('-2');
  });
});

메서드, state, DOM을 모두 활용하는 Enzyme과 달리 react-testing-library는 모든 테스트를 DOM 위주로 테스트한다. 리팩토링을 하다보면 기능은 같으나 변수명이나 로직은 달라지는 경우가 많다. react-testing-library는 이러한 리팩토링을 중요시 여기는 것이다.

describe('<Counter />', () => {
  it('matches snapshot', () => {
    const utils = render(<Counter />);
    expect(utils.container).toMatchSnapshot();
  });
  it('has a number and two buttons', () => {
    const utils = render(<Counter />);
    // 버튼과 숫자가 있는지 확인
    utils.getByText('0');
    utils.getByText('+1');
    utils.getByText('-1');
  });
  it('increases', () => {
    const utils = render(<Counter />);
    const number = utils.getByText('0');
    const plusButton = utils.getByText('+1');
    // 클릭 이벤트를 두번 발생시키기
    fireEvent.click(plusButton);
    fireEvent.click(plusButton);
    expect(number).toHaveTextContent('2'); // jest-dom 의 확장 matcher 사용
    expect(number.textContent).toBe('2'); // textContent 를 직접 비교
  });
  it('decreases', () => {
    const utils = render(<Counter />);
    const number = utils.getByText('0');
    const plusButton = utils.getByText('-1');
    // 클릭 이벤트를 두번 발생시키기
    fireEvent.click(plusButton);
    fireEvent.click(plusButton);
    expect(number).toHaveTextContent('-2'); // jest-dom 의 확장 matcher 사용
  });
});
profile
천재가 되어버린 박제를 아시오?

0개의 댓글