React Testing

단위 테스트란?

단위테스트는 어플리케이션을 테스트 함에 있어 가장 작은 단위로 분리하여 테스팅을 진행하는것을 말합니다. 가장 작은 단위(함수 또는 메소드..)로 수행하는 단위테스트를 통해 프로그램의 잘못된 수행과 예기치 못한 버그를 미리 예측하여 수정할 수 있습니다. 사전에 테스트 코드가 작성 또는 수정이 될때 단위테스트를 통해 정상수행 여부를 확인하여 어플레케이션 아키텍처 및 유지보수성을 향상시킵니다.

예를 들자면, 이미 개발이 완료된 코드가 있다고 가정해봅시다. 개발 이후 유지보수시, 다른 개발자가 프로그램을 리팩토링 할 경우가 있을겁니다. 만약 이전 개발자가 단위 테스트를 작성하지 않았다면, 리팩토링하는 개발자는 처음부터 코드 분석을 해야 할 것입니다. 많은 시간을 들여 분석을 완료 후 리팩토링을 진행했다고 하더라도 처음 작성한 개발자와의 의도와는 다르게 수정되었다면, 이 리팩토링된 코드는 이후 잠재적인 오류를 발생시킬 수 있습니다. 하지만 단위테스트가 작성되어 있다면, 리팩토링 후 단위테스트를 진행하면서 예상한 결과와 다르게 수행되는것을 확인하고, 개발자가 수정할 부분을 비교적 쉽게 인지 할 수 있게 됩니다.

이제 단위테스트를 함께 준비하겠습니다. 우선 어디에 작성할지 위치를 정해야합니다. 단위테스트는 주로 순수한 메소드(함수)에 적용을 합니다. 순수한 메소드(함수)란, 매번 같은 매개변수(Parameters)가 입력된다면 매번 같은 출력값(Return)을 리턴하는 메소드(함수)를 말합니다. 이에 맞춰 개발자가 단위테스트를 생각하며 코드를 구현할때 몇가지 장점이 있습니다.

  1. 수행 목적에 따른 행위가 뚜렷한 메소드(함수)가 만들어지고, 이는 더 견고한 API를 생산할 수 있도록 해줍니다.
  2. 단위테스트를가 없다면 로그확인을 위해 콘솔창에  (console.log())을 사용하여 일일이 디버깅(Debugging)을 하겠지만, 단위 테스트에 메소드(함수)의 출력값도 확인할 수 있도록 작성했다면 코드 수정시 변경된 값을 추가 디버깅없이 테스트 중 출력이 가능합니다.
  3. 이벤트 역시  코드 수정 후 개발자가 이벤트를 직접 발생시키지 않아도 단위테스트에서 이벤트 발생시킬 수 있기 때문에 간편하고 테스트 시간 또한 대폭 줄일 수 있습니다.
  4. 테스트를 실행중인 상태에서 코드를 수정하게 되면, 자동으로 테스트를 변경된 코드로 다시 시작하므로 잘못 작성된 부분이 있다면 변경 즉시 알아낼 수 있습니다.

테스트 라이브러리

리액트 공식 사이트에서  React 에 사용되는 테스트 유틸 및 라이브러리는 아래와 같습니다.

  • Enzyme : React를 위한 JavaScript 테스트 유틸입니다.
  • Jest : React Application을 포함하는 JavaScript 테스팅 프레임워크입니다.
  • react-testing-library : 가벼운 React DOM 테스팅 유틸입니다.
  • React-unit :  React를 위한 가벼운 단위테스트 라이브러리입니다.
  • Skin-deep : 얕은 랜더링을 지원하는 React 테스팅 유틸입니다.
  • Unexpected-react : React컴포넌트와 이벤트 발생시켜주는 플러그인 입니다.

우리는 JestEnzyme을 통해 테스팅을 해보도록 하겠습니다.

Jest

주요 기능

Jest는 React 와 같이 Facebook 팀에서 만들어진 테스팅 라이브러리입니다. 독립적으로 실행이 가능하고, Javascript로 작성된 모든 코드의 테스트를 지원하고 있습니다.

Jest 의 주요 기능은 다음과 같습니다.

  • Matcher : 단위테스트시 예상된 값과 실제 함수 호출후 리턴값이 비교하여 실패/성공 여부를 알려줍니다.
  • 비동기 코드 테스팅 : 비동기로 처리되는 함수의 결과값 비교가 가능합니다.
  • Setup Teardown 설정 : 테스트 및 테스트 그룹의 선 / 후행 처리를 지원합니다.
  • 목 함수 사용(Mock) : 목 함수 및 객체 생성을 지원하여 테스트를 가능하게 합니다.
  • Jest 유틸 함수들 : Jest 라이브러리에서 제공하는 유틸성 함수입니다.
  • Snapshot 비교 : 이전 버전과 같은 값을 랜더링 하는지 확인을 해주는 라이브러리입니다.

아래 샘플 코드와 함께 알아보도록 하겠습니다.

준비

아래와 같은 준비물로 프로젝트 예를 들어 설명하겠습니다.

  • Yarn(패키지 매니저)
  • RCA(ReactCreateApp)
  • VS CODE(Visual Studio Code)

다음 환경설정을 완료하면 JS 테스팅을 위한 준비가 완료됩니다.

  • 우리는 샘플을 사용하여 테스트를 진행할것이지만, 새로 진행할땐 VSCODE 를 실행하고, Terminal을 열어 프로젝트가 위치할 디렉터리로 이동합니다.  그 다음 아래와 같이 입력합니다.
create-react-app '프로젝트명'
  • 우선 JS(자바스크립트)의 테스팅 프레임워크인 Jest를 설치해줍니다.
yarn add jest
  • VS CODE 내 Jest 인텔리센스를 추가합니다.
yarn add @types/jest
  • 테스트를 실행하기 앞서, RCA(react-create-app) 의 경우 테스트 실행 스크립트가 이미 존재 하므로 실행시 다음과 같이 입력합니다.
yarn test

샘플

위와 같이 React로 작성된 간단한 계산기를 샘플로 하여 테스트를 진행해보겠습니다. 테스트 코드를 작성한 위치는 src/test 아래이며, 파일 이름은 UnitTest.test.js 와 같이 마지막에 *.test.js 형태로 작성하면 테스트 실행시 해당 파일을 읽습니다.

다른 명명규칙을 가진 파일과 폴더도 존재합니다.

  • __test__ 폴더 내 확장자가 .js 인 파일들
  • *.test.js 형태의 파일
  • *.spec.js 형태의 파일

샘플 코드는 글 맨 아래 Git Repository를 참조해 주시길 바랍니다.

Matcher

단순 비교

가장 기본적인 리턴값 비교 테스트입니다. 문자, 숫자, 객체, 배열 등을 비교할 수 있으며, 원하는 값 또는 범위, 소수점까지 비교가 가능합니다.

아래는 src/test/UnitTest.test.js 의 예시입니다.

import * as calculation from '../calculator/Calculation';
import Calculator from '../calculator/Calculator';

...
    it('1. Addtion Test', () => {
        expect(calculation.applyAddition(3, 5)).toEqual(8);
    });
    it('2. Subtraction Test', () => {
        expect(calculation.applySubtraction(5, 3)).toBeLessThanOrEqual(2);
    });
    it('3. Multiplication Test', () => {
        expect(calculation.applyMultiplication(5, 3)).toBeGreaterThanOrEqual(15);
    });
    it('4. Division Test', () => {
        expect(calculation.applyDivision(6, 4)).toBeCloseTo(1.5);
    });
...
  1. Addition Test : 계산기의 더하기 함수를 호출하여 두개의 파라미터를 받아 그 결과값을 테스트합니다. 결과 값은 toEqual() 또는 toBe() 로 확인이 가능합니다. 같은 타입을 비교할 경우 toBe()를 사용하고, 객체를 비교할땐 toEqual()을 사용합니다. 숫자의 경우 toBe()와 to Equal이 동일하게 동작하므로 둘중 어느것을 사용해도 무관합니다.
  2. Substraction Test : 계산기의 뺄셈 함수입니다. 위의 경우 2의 결과 값이 나와야 합니다. 테스트는 toBeLessThanOrEqual(2)로  2보다 같거나 작은 값인지 비교하게 되어있습니다.
  3. Multiplication Test : 계산기의 곱셈 함수입니다. 위의 경우 15의 결과 값이 나와야 합니다. 테스트에는 toBeGreaterThanOrEqual(15) 로 15보다 같거나 큰 값인지 비교하게 되어있습니다.
  4. Division Test : 계산기의 나눗셈 함수입니다. 위의 경우 1.5의 결과 값이 나와야 합니다. 테스트에는 toBeCloseTo(1.5) 로 사용되고 있습니다. 소수점의 자리수가 클 경우(예시 : 0.33333333 ≒ 0.33333334) 예기치 못한 반올림 오류를 미리 방지하기 위함입니다.

참과 거짓

참 또는 거짓임을 확인하는 테스트입니다. 대표적으로 확인이 가능한 값은 아래와 같습니다.

  • null | toBeNull() | not.toBeNull()    |
  • undefined | toBeDefined() | not.toBeDefined() |
  • true | toBeTruthy() | not.toBeTruthy()  |
  • false | toBeFalsy() | not.toBeFalsy()   |
...

describe('Truthiness Test', () => {
    const calculator = new Calculator();
    calculator.handleClickOperator('+')
    it('1. OperatorButtonClick("+")', () => {
        expect(calculator.isOperationButtonClicked).toBeTruthy();
    });
    it('2. ReadyToCalculate', () => {
        expect(calculator.readyToCalculate).not.toBeFalsy();
    });
});

...
  1. OperatorButtonClick("+") : 계산기의 '+' 버튼을 눌렀을때, 이 버튼이 숫자버튼인지 기능버튼인지 확인하는 프로퍼티입니다. 위의 코드에서는 '+' 버튼을 눌렀으므로 참의 값을 리턴하여 참인지 확인하는 테스트( toBeTruthy())는 성공하게 됩니다.
  2. ReadyToCalculate : 계산이 준비가 되었는지 확인하는 프로퍼티입니다. 계산기의 계산 준비가 완료 되었으므로, 참의 값을 리턴합니다. 이에 거짓이 아닌지 확인하는테스트(not.toBeFalsy())는 성공하게 됩니다.

문자열

점검할 문자열과 예측한 문자열이 같은지 확인하는 테스트입니다.

...

describe('RemoveLastWord Method Test', () => {
    //
    it('1. Remove Last Word Test', () => {
        let calculator = new Calculator();

        let removedSentence = calculator.removeLastWord('Words : Remove a Last Words', 'Word');

        expect(removedSentence).toMatch('Words : Remove a Last s');
    })
});

...
  1. Remove Last Word Test : removeLastword(string): string 메소드는 문자열 내 찾을 문자열이 여러개일 경우 가장 마지막에 있는 문자열만 제외하는 메소드입니다.  예측한 'Words : Remove a Last s' 문자열과 일치하는지 확인하는 toMatch(string) 메소드를 통해 테스트를 진행합니다. toMatch(string) 메소드는 매개변수로 정규표현식도 지원하기 때문에 정규 표현식 사용시 처음과 끝부분에 / ... / 를 붙여서 아래와 같이 사용도 가능합니다.
expect(removedSentence).toMatch(/^[a-zA-Z]+ : [a-zA-Z]+ a [a-zA-Z]+ s/);

Setup Teardown

다음으로 테스트를 진행함에 있어 선,후행 처리가 필요함에 따른 setup, teardown 함수들, 그리고 선후행 처리가 필요한 테스트끼리 모아 범위를 지정할 수 있는 Scope 함수를 소개하겠습니다.

Setup과 Teardown 함수의 종류와 설명은 아래와 같습니다.

  • 각 테스트 후 : afterEach(name, function, [timeout])
  • 모든테스트 후 : afterAll(name, function, [timeout])
  • 각 테스트 전 : beforeEach(name, function, [timeout])
  • 모든테스트 전 : beforeAll(name, function, [timeout])

매개변수로 입력되는 function은 선,후행 처리에 수행되어야 할 함수가 들어갑니다. timeout의 경우 옵션으로, 수행할 함수의 timeout을 설정할 수 있습니다. timeout을 설정하지 않을 경우 기본 5초가 설정됩니다. 사용시 주의해야 할 것은 4개의 함수들은 파일 단위로 설정이 되므로, 선후행 처리가 잘못되지않도록 해야합니다.

Test Scope

한 파일에 테스트가 많아질 경우 비슷한 성격을 가진 테스트끼리 묶고, 이에 따른 선후행처리가 따로 필요하게 됩니다. 이에 필요한것이 각 테스트 간 Scope를 따로 지정하는 방법입니다. 사용법은 간단합니다. describe(name, function) 을 사용한 테스트 그룹핑과 처리 순서를 아래에 예시를 들어보겠습니다.

beforeAll('beforeAll', () => {
    ...
});

afterAll('AfterAll', () => {
   ... 
});

beforeEach('beforeEach', () => {
    ...
});

afterEach('afterEach', () => {
    ...
});

describe('Test Group 1', () => {
    beforeAll('beforeAll', () => {
    ...
    });

    afterAll('AfterAll', () => {
       ... 
    });
        
    beforeEach('beforeEach', () => {
    ...
    });

    afterEach('afterEach', () => {
        ...
    });
        
    it('Test 1-1', () => {
        ...
    });
});

describe('Test Group 2', () => {
    beforeAll('beforeAll', () => {
    ...
    });

    afterAll('AfterAll', () => {
       ... 
    });
        
    beforeEach('beforeEach', () => {
    ...
    });

    afterEach('afterEach', () => {
        ...
    });
        
    it('Test 2-1', () => {
        ...
    });    
}); 

코드로 보면 쉽게 순서를 파악하기 힘들지만 그림으로 보면 다음과 같습니다. 순서는 위에서 부터 순서대로 호출이 됩니다.

./sample_scope.PNG

Snapshot 비교

스냅샷 테스트는 컴포넌트의 DOM 구조가 예기치 않게 변경되는것을 알려주는 테스트입니다. 컴포넌트를 랜더링하여 트리구조의 json형태로 등록을 하게되고, 이후 수정사항 반영후 스냅샷테스트시 이전 컴포넌트 DOM구조와 다르다면 테스트는 실패를 출력하여 개발자에게 알려줍니다. 기존에 사용하는 리액트 랜더러를 사용하지않고 테스트 렌더러를 사용하여 스냅샷 테스트를 진행합니다.

우선 테스트렌더러 모듈을 추가합니다.

yarn add react-test-renderer

설치후, 아래와 같이 스냅샷테스트를 진행할 컴포넌트를 import 하여 컴포넌트 트리구조를 JSON 포멧으로 변경하여 스냅샷에 등록합니다. 아래와 같이 작성하면Calculator 컴포넌트를 스냅샷 테스팅을 진행하게 되고, 처음으로 테스팅을 진행하면 현재의 컴포넌트 트리구조를 등록하게 됩니다. 이후 변경된 구조와 비교하여 테스트를 진행합니다.

import React from 'react';
import Calculator from '../calculator/Calculator';
import renderer from 'react-test-renderer';

it('renders correctly', () => {
    const tree = renderer
      .create(<Calculator/>)
      .toJSON();
    expect(tree).toMatchSnapshot();
  });

스냅샷을 마치면, 스냅샷 파일은 테스트 코드가 있는 디렉터리의 동일한 레벨에 '__snapshots__ ' 디렉터리 아래에 '테스트파일명.snap' 으로 생성됩니다.

Enzyme

Jest는 자바스크립트의 테스트 프레임워크이며, 자바스크립트를 사용한 모든 코드의 테스트를 지원합니다. Enzyme은 React를 위한 테스트 라이브러리입니다. 독립적으로 사용 하지못하고, Jest와 함께 사용하여 테스트를 진행할 수 있습니다. Enzyme을 사용하여 테스트를 하는 이유는 DOM 테스트를 하기 위함이며, 컴포넌트를 조작 및 이벤트 호출, 랜더링된 자신의 컴포넌트나 하위에 포함된 컴포넌트의 props나 state값을 비교가 가능합니다.

Enzyme에서는 컴포넌트 랜더링에 있어 크게 두가지를 제공합니다.

  • Shallow Rendering : 랜더링하는 컴포넌트만 랜더링합니다.
  • Full Rendering : 랜더링하는 컴포넌트가 참조하는 모든 자식 컴포넌트를 랜더링합니다.

준비

Enzyme을 사용하기 위해서 2개의 모듈을 추가해야합니다.(리엑트 버전 16기준)

  • yarn add enzyme
  • yarn add enzyme-adapter-react-16

추가가 완료되면, 프로젝트의 src와 같은 디렉토리 레벨에 setupTest.js 파일을 생성하고, 아래와 같이 작성해줍니다.

import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({ adapter: new Adapter() });

이제 Enzyme을 사용할 준비를 마쳤습니다.

Shallow Rendering

ShallowRendering 은 말그대로 얕은 랜더링이라는 말로 해석됩니다. 얕은 랜더링은 지정한 한 컴포넌트만 랜더링이 이루어지며, 컴포넌트가 참조하는 어떤 인스턴스나 자식 컴포넌트의 영향없이 테스팅을 진행할 수 있습니다.

Shallow Rendering을 할 컴포넌트는 shallow(<Calculator/>) 과 같이 랜더링이 가능합니다. 아래와 같은경우 Calculator 컴포넌트만 랜더링하여 테스트를 합니다.

Method Call

아래는 버튼이벤트 발생시 호출되는 메소드입니다. 메소드 호출시 해당 인스턴스내에서 변경되는 변수값과 state의 변경된 값을 비교하여 테스트합니다.

import React from 'react';
import {mount, shallow} from 'enzyme';
import Calculator from '../calculator/Calculator';

describe('Shallow Rendering - Button Event Testing', () => {
    //
    const wrapper = shallow(<Calculator/>);

    beforeAll(() => {
        wrapper.instance().handleClickNumber(1);
    })
    
    it('1. handleClickNumber Test', () => {

        expect(wrapper.instance().formulaExpression).toBe('1')
    })

    it('2. Display State Component Test',  () => {
        expect(wrapper.state().displaiedValue).toBe('1');
    })
})
  1. handleClickNumber Test : 버튼이벤트가 호출할 메소드를 매개변수1과 함께 호출하면 계산기식을 임시로 저장할 변수의 값이 1로 변경되었는지 확인하는 테스트입니다.
  2. Display State Component Test : 메소드 호출시 매개변수 1과 할께 호출하면, 계산기 화면에 표시될 값이 state = {displaidValue : 1} 로 변경되었는지 확인하는 테스트입니다.

Method Flow

아래는 여러 메소드들의 흐름에 따른 결과 값이 일치하는 테스트입니다. 계산기의 값이 입력되어 표시될 값과 계산을 마친 후 결과 기록에 제대로 추가가 되었는지 확인합니다.

describe('Shallow Rendering - Input Testing', () => {
    //
    beforeAll(() => {
        wrapper.instance().handleClickNumber(1);
        wrapper.instance().handleClickOperator('+');
        wrapper.instance().handleClickNumber(3);
        wrapper.instance().handleClickOperator('=');
    })

    afterEach(() => {
    const wrapper = shallow(<Calculator/>);
        wrapper.instance().handleClickOperator('CE');
    })

    it('1. Calculation Result Test', () => {
        expect(wrapper.instance().formulaExpression).toMatch('1+3=');
    });

    it('2. Calculation Reslt Set Test', () => {
        expect(wrapper.state().formulas[0]).toMatch('1+3=4');
    });
});

우선 테스트를 진행하기전 계산기의 기능을 beforeAll()을 통해 setup 해줍니다. 이는 describe('Shallow Rendering - Input Testing',(){…} 안에 있는 두개의 테스트를 진행하기전에 수행되어집니다.

  1. Calculation Result Test : 앞서 setup을 통해 입력된 계산기 수식이 제대로 입력이 되었는지 확인합니다.
  2. Calculation Reslt Set Test : 첫번째 테스트가 끝난 후 afterEach() 가 수행을 시작하고, 'CE' 라는 매개변수를 가진 handleClickOperator 메소드가 수행됩니다. 이에 따라 state의 formulas 배열에 계산기의 계산결과식이 제대로 추가가 되었는지 확인할 수 있습니다.

Full Rendering

Full Rendering 은 DOM을 다루거나 참조하는 모든 컴포넌트들을 랜더링을 하기위해 사용합니다. 이는 얕은 랜더링과는 달리 컴포넌트와 하위컴포넌트 모두를 실제 DOM에 올려서 테스트를 진행합니다.

Child Component

아래는 Calculator의 state 값을 변경 후 변경된 값이 제대로 적용되었는지 확인하고, 바뀐 state값에 따른 자식컴포넌트의 props 값이 변경되었는지 확인하는 2개의 테스트 코드입니다.

describe('Full DOM Rendering - Child Component Testing', () => {
    const wrapper = mount(<Calculator/>);

    let tempFormulas = [];
    tempFormulas.push('TestFormulas');
    
    beforeAll(() => {
        //
        wrapper.instance().setState({formulas: tempFormulas});
        wrapper.update();
    })

    it('1. ParentComponent setState Test', () => {
        //
        expect(wrapper.state().formulas[0]).toMatch('TestFormulas');
    })

    it('2. ChildComponent props Check', () => {
        const childWrapper = wrapper.find('ResultBoard');
        const formulas = childWrapper.props().formulas;
        expect(formulas[0]).toMatch('TestFormulas')
    });

})

사전에  beforeAll을 통해 랜더링된 컴포넌트의  state값을 변경해줍니다.

  1. ParentComponent setState Test : 변경된 state값이 제대로 변경되었는지 확인하는 테스트입니다.
  2. ChildComponent props Check : 변경된 state값에 따라 자식 컴포넌트인 ResultBoard 컴포넌트의 props값에 제대로 반영되었는지 확인하는 테스트입니다.

Event Simulation

아래는 FullRendering을 통해 자식컴포넌트에서 발생한 이벤트가 부모 컴포넌트의 이벤트 메소드를 호출하는지 확인합니다.

describe('Full DOM Rendering - Event Simulation', () => {
    const wrapper = mount(<Calculator/>);

    let tempFormulas = [];
    tempFormulas.push('TestFormulas');
    
    beforeAll(() => {
        //
        wrapper.instance().setState({formulas: tempFormulas});
        wrapper.update();
    })

    it('1. Clear History Event Test within ChildComponent', () => {
        let childWrapper = wrapper.find('ResultBoard');
        childWrapper.find('Icon').simulate('click');
        
        childWrapper = wrapper.find('ResultBoard');
        expect(childWrapper.props().formulas).toHaveLength(0);
    })
});

beforeAll() 을 통해 호출한 컴포넌트의 state값을 변경합니다.

  1. 자식 컴포넌트인 ResultBoard 컴포넌트의  Icon을 클릭하는 시뮬레이션을 호출하여 모든 결과값을 초기화하는 버튼 이벤트를 발생시킵니다. 이벤트 발생이후 자식 컴포넌트의 props값이 초기화가 되었는지 확인하는 테스트입니다.

참고

Jest

https://jestjs.io

Enzyme

https://airbnb.io/enzyme/

React

https://reactjs.org/

Regular Expression

https://regexr.com/

Git

아래는 본문의 예제 Git Repository 입니다. 참고바랍니다.

https://github.com/namoosori/react-blog