-
Notifications
You must be signed in to change notification settings - Fork 5
Frontend(특히 React)의 Test에 대하여
- 작성자: 김성빈
- 작성일: 2018-11-24 PM 9:00~, 11-25 AM 6:20~, 11-30 PM 2:30~
-
결함을 찾아내기 위해 소프트웨어를 작동시키는 일련의 행위와 절차
-
테스트는 프로그램이나 시스템이 예상대로 작동할 것이라는 확신을 증진시키는 과정이다.
-
설계 : 개발 : 테스트 = 30 : 30 : 40 정도의 노력이 들어가야 한다고 함
-
테스트마다 목표가 다르다. 크게 둘로 나뉜다.
-
단위테스트: 의미있는 코드 단위(묶음)이 적절히 기능을 수행할 수 있는지 확인
-
인수테스트/통합테스트: 전체 컴포넌트가 조합된 애플리케이션이 실제로 특정 기능을 잘 수행하는지 확인
-
-
테스트를 효과적으로 진행하기 위해, 코드로 테스트를 작성한다. 덕분에 이후 테스트를 자동화시킬 수 있다.
-
테스트 케이스에는
입력 값(when)
,실행 환경(given)
,예상된 결과와의 비교(then)
등이 포함된다. -
예시
// 개발 코드 function sum(a, b) { return a + b; } // 테스트 코드 (Jasmine) describe('sum()', function() { it('두 인자의 합을 반환.', function() { expect(sum(3, 5)).toBe(8); }); });
-
코드로 작성한 테스트 케이스가 없으면 변경을 일으키는 코드의 side effect를 알기 위해 수동으로 검사해야 한다.
-
(1)
의 공수가 커도 너무 크고, 지식 노동의 형태보다는 단순 노동 측면이 큰 작업이며 이를 매번 변경이 있을 때마다 수행해야 한다. -
당연하게도
(2)
의 세 가지의 부작용 모두 프로젝트 일정 지연에 기여한다. -
(3)
에 의해 테스트의 효율을 높이기 위해 테스트 케이스가 필요하다. -
추가적으로, 코드로 작성된 테스트는 수동 테스트보다 신뢰도가 높고 중복 실행할 확률도 낮으며 테스트의 조합과 순서를 바꾸는 데도 비용이 거의 들지 않는다.
테스트를 쉽게 할 수 있도록 여러 테스트를 실행하고, 테스트용 함수 및 Hook(Setup, Teardown, Before, After 등)을 제공하고, 단언(assert) 함수를 제공한다.
-
Jasmine은 BDD(behavior-driven development) 프로세스를 지원하는 JS 테스트 프레임워크이다.
// 출처: jasmine 공식 홈페이지 describe("A suite is just a function", function() { var a; it("and so is a spec", function() { a = true; expect(a).toBe(true); }); });
설정 방법은 아래와 같다.
npm install --save-dev jasmine # 설치 node node_modules/jasmine/bin/jasmine init # Jasmine 초기화..? (잘모름) "scripts": { "test": "jasmine" } # Jasmine을 테스트 스크립트로 지정 npm test # 테스트 실행
-
Mocha는 비동기 테스트를 쉽게 할 수 있다는 슬로건을 걸고 있다. (안써봐서 모름)
// 출처: Mocha 공식 홈페이지 beforeEach(function() { return db.clear() .then(function() { return db.save([tobi, loki, jane]); }); }); describe('#find()', function() { it('respond with matching records', function() { return db.find({ type: 'User' }).should.eventually.have.length(3); }); });
다른 프레임워크에서 비동기 테스트를 어떻게 지원하는지 안봐서 모르겠지만, 확실히 편하긴 할 것 같다. [추가]: Chai의 기능인 것 같다. Chai는 BDD/TDD을 위한 단언(assert) 라이브러리로 기존의 Test Framework과 같이 사용되어 확장 기능을 제공하는 듯 하다. [추가]: 이건 Chai의 기능에서 플러그인으로 확장된 기능인 Chai As Promised의 기능이다.
놀랍게도 async, await도 가능하다. 이건 편한듯. 인정
beforeEach(async function() { await db.clear(); await db.save([tobi, loki, jane]); }); describe('#find()', function() { it('responds with matching records', async function() { const users = await db.find({ type: 'User' }); users.should.have.length(3); }); });
-
Jest (기다렸던?ㅋㅋ)
- Jest는 아래의 기능을 지원한다고 홈페이지에 나와있다.
- 스냅샷 테스트: 이전 UI와 비교하는 듯
- 무설정(zero-config)
- 병렬 테스트
- 빌트-인 코드 커버리지 리포트 제공
- mock 생성 (자동 생성도 있음)
- made by facebook
아래는 테스트 예시이다.
// sum.js function sum(a, b) { return a + b; } module.exports = sum; // sum.test.js const sum = require('./sum'); test('adds 1 + 2 to equal 3', () => { expect(sum(1, 2)).toBe(3); });
Jest에서도 비동기 테스트를 지원한다. 여러 방법이 있다.
// 방법 1 // Promise를 반환하고 then에서 단언 test('the data is peanut butter', () => { expect.assertions(1); return fetchData().then(data => { expect(data).toBe('peanut butter'); }); }); // 방법 2 // expect를 반환하고, resolves, rejects 를 사용해서 단언 test('the data is peanut butter', () => { expect.assertions(1); return expect(fetchData()).resolves.toBe('peanut butter'); }); // 방법 3 // async, await도 쓸 수 있다. test('the data is peanut butter', async () => { expect.assertions(1); const data = await fetchData(); expect(data).toBe('peanut butter'); }); // 방법 3-1 // async + resolves/rejects test('the data is peanut butter', async () => { expect.assertions(1); await expect(fetchData()).resolves.toBe('peanut butter'); });
아래는 Hook 함수들이다. 밖에 것은 global, 안에 것은 local이다.
beforeAll(() => console.log('1 - beforeAll')); afterAll(() => console.log('1 - afterAll')); beforeEach(() => console.log('1 - beforeEach')); afterEach(() => console.log('1 - afterEach')); test('', () => console.log('1 - test')); describe('Scoped / Nested block', () => { beforeAll(() => console.log('2 - beforeAll')); afterAll(() => console.log('2 - afterAll')); beforeEach(() => console.log('2 - beforeEach')); afterEach(() => console.log('2 - afterEach')); test('', () => console.log('2 - test')); });
아래는 특정 테스트만 실행하고 싶을 때 쓰는 방법이다.
.only
를 붙이면 된다. 비슷한 것으로skip
과each
등이 있다.// 실행 O test.only('this will be the only test that runs', () => { expect(true).toBe(false); }); // 실행 X test('this test will not run', () => { expect('A').toBe('A'); });
Jest에서도
describe
을 사용할 수 있다. describe은 여러 test를 묶는 단위인 테스트 스위트를 의미한다.const myBeverage = { delicious: true, sour: false, }; // describe은 테스트 스위트 describe('my beverage', () => { // 내부에 test를 여러 개 가질 수 있다. test('is delicious', () => { expect(myBeverage.delicious).toBeTruthy(); }); test('is not sour', () => { expect(myBeverage.sour).toBeFalsy(); }); }); // describe은 중첩할 수 없다 describe('binaryStringToNumber', () => { // NO describe('given an invalid binary string', () => { // ... }); });
[작성중] Jest는 Mock 생성을 지원한다.
Mock
이란 프로그래머가 제어할 수 없는 것을 제어할 수 있는 것으로 바꿔치는 것(replace)을 말한다. Mock 함수가 하는 일은 아래와 같다.-
호출이 됐는지 확인
test("mock은 기본적으로 undefined를 반환한다", () => { // jest.fn은 mock을 생성하는 함수이다. const mock = jest.fn(); // mock을 "foo"라는 매개변수를 주어 호출한다. let result = mock("foo"); expect(result).toBeUndefined(); // mock은 기본적으로 undefined를 반환 expect(mock).toHaveBeenCalled(); // 호출이 됐는지 확인 expect(mock).toHaveBeenCalledTimes(1); // 호출이 됐는지 확인 expect(mock).toHaveBeenCalledWith("foo"); // 입력값 확인 });
-
반환 값 설정하기
test("mock의 반환값을 설정할 수 있다.", () => { const INPUT_VAL = "foo"; // 입력값 상수 const RET_VAL = "bar"; // 반환값 상수 const mock = jest.fn(); // returnValue = 함수 호출 시 반환값 mock.mockReturnValue(RET_VAL); expect(mock(INPUT_VAL)).toBe(RET_VAL); expect(mock).toHaveBeenCalledWith(INPUT_VAL); }); test("mock의 프로미스 반환값도 설정할 수 있다.", () => { const INPUT_VAL = "입력값"; const RET_VAL = "Promise의 반환값"; const mock = jest.fn(); // resolvedValue = Promise가 성공하면 then에 전달되는 값 mock.mockResolvedValue(RET_VAL); expect(mock(INPUT_VAL)).resolves.toBe(RET_VAL); expect(mock).toHaveBeenCalledWith(INPUT_VAL); });
-
구현 변경하기
test("mock에 구현을 제공할 수 있다.", () => { // 구현을 삽입: () => "bar": 즉, 무조건 bar 반환하도록 구현 // once가 붙어서, 이후는 undefined를 반환함 (mock의 기본은 undef임) const mock = jest.fn().mockImplementationOnce(() => "bar"); expect(mock("foo")).toBe("bar"); expect(mock).toHaveBeenCalledWith("foo"); expect(mock("baz")).toBe(undefined); expect(mock).toHaveBeenCalledWith("baz"); });
- Jest는 아래의 기능을 지원한다고 홈페이지에 나와있다.
Javascript 기반의 프로젝트를 테스트하는 방법을 소개합니다.
- 온라인 에디터 목록 - 공통적으로 간편하게 의존성 추가가 가능
- JSBin
- JSFiddle
- Codepen
- CodeSandBox - 가장 추천 - 미리 설정된 여러 환경을 선택 가능
- React TestUtils(react-addons-test-utils)
리액트 컴포넌트를 테스트하는 데는 기본적으로 두 가지 방법이 있다.
-
얕은 렌더링(
shallow
)- 컴포넌트를 중첩된 React Component는 렌더링하지 않고 한 단계 깊이만 렌더링하고 그 결과를 반환
- 자식 JSX 요소(div, button, ...) 혹은 자식 React 컴포넌트를 찾을 때
.find()
를 사용할 수 있음-
.exists()
JSX 요소나 컴포넌트가 존재하는 지 확인할 수 있음 -
.simulate()
로 이벤트를 임의 데이터로 시뮬레이션할 수 있음.shallow
으로만 가능 (Event Bubbling / Propagation은 없음)
-
-
.prop()
으로특정 React 컴포넌트나 JSX 요소가 어떤prop
을 받았는지 확인할 수 있음
-
컴포넌트를 DOM에 마운팅하는
mount
- 실제 DOM에 컴포넌트를 렌더링한다.
Hoc
혹은 모든 자식 컴포넌트를 테스트해야 하는 경우 사용한다. - 모든 브라우저 API가 필요하므로 jsdom을 기반으로 테스트를 작동시킨다. JSDOM은 JS로 구현한 DOM 구현체이다.
- 아직
.getDOMNode()
을 활용하기 위해서만 사용해봄 -
jest-dom
의extend-expect
를 테스트에서 활용함으로써expect(domNode).toHaveStyle()
과 같이 사용할 수 있다.it('버튼은 중앙에 있어야 한다.', () => { const wrapper = mount(<QRScanner />); const pictureBtn = wrapper.find('#picture-btn'); const pictureBtnDomNode = pictureBtn.getDOMNode(); expect(pictureBtnDomNode).toHaveStyle('display: block'); expect(pictureBtnDomNode).toHaveStyle('margin: 0 auto'); });
- 실제 DOM에 컴포넌트를 렌더링한다.
Kent Beck이 소개한 개발 방법론으로, 테스트를 항상 실행 코드보다 먼저 작성하는 방법이다.
직접 쓰는 설명보다 Robert Martin의 클린 코더의 TDD 챕터 내용을 인용하겠습니다.
1998년 테스트 우선 개발(Test First Programming)이라는 말을 들었을 때는 회의적인 입장이었다. 누군들 안 그랬겠나? 단위 테스트를 먼저 만들라니? 그런 얼빠진 짓을 하는 사람은 도대체 누구란 말인가?
그래서 1999년 켄트를 만나 TDD 훈련을 받기 위해 오레곤 메드포트로 떠났다.
켄트와 나는 사무실에 앉아 작고 단순한 문제를 자바로 풀었다. 나는 어리석게도 곧바로 코딩하려 달려들었다. 하지만 켄트는 이를 막고 처리 과정에 따라 한걸음 한걸음 나를 이끌었다. 처음에는 단위 테스트 일부분을 만들었는데, 간신히 소스코드로 봐줄 만한 정도의 코드였다. 그리고 나서 테스트가 컴파일될 만큼만 실제 코드를 만들었다. 그리고는 테스트를 조금 더 만들고 나서 다시 실제 코드를 더 만들었다.
그 반복 주기는 난생 처음 겪는 경험이었다. 나는 보통 한 시간 정도 코드를 만든 후에야 컴파일하거나 실행하곤 했다. 하지만 켄트는 글자 그대로 대략 30초에 한 번씩 코드를 돌렸다. 놀라 자빠질 지경이었다!
일방적이고 귀에 거슬리는 말이란 걸 알지만, 연구자료를 봤을 때 외과의사가 꼭 손을 씻어야 하듯이 프로그래머도 TDD를 꼭 적용해야 한다고 생각한다.
작성한 코드가 전부 잘 돌아가는지 알지 못한다면 어찌 프로라 말할 수 있겠나? 변경할 때마다 테스트하지 않는다면 코드가 전부 잘 돌아가는지 어찌 알까? 자동화된 단위 테스트를 만들어 커버리지를 높이지 않는다면 변경할 때마다 어떻게 테스트를 할 수 있을까? TDD를 사용하지 않는다면 높은 커버리지를 가진 자동화된 단위 테스트를 어떻게 만들 수 있을까?
마지막 문장은 곱씹어볼 필요가 있다. 도대체 TDD란 무엇인가?
TDD의 세 가지 법칙
- 실패한 단위 테스트를 만들기 전에는 제품 코드를 만들지 않는다.
- 컴파일이 안 되거나 실패한 단위 테스트가 있으면 더 이상의 단위 테스트를 만들지 않는다.
- 실패한 단위 테스트를 통과하는 이상의 제품 코드는 만들지 않는다.
이 세 가지 법칙을 지키면 반복 주기는 대략 30초 길이를 유지한다. 처음에는 작은 단위 테스트를 만들며 시작한다. 하지만 몇 초 지나지 않아 아직 만들지도 않은 클래스나 함수의 이름을 써야 하고, 그 때문에 단위 테스트는 컴파일되지 않는다. 따라서 테스트가 컴파일되도록 제품 코드를 만들어야 한다. 하지만 그 이상의 제품 코드를 만들면 안 되기 때문에 단위 테스트를 더 만들기 시작한다.
거듭해서 주기를 반복한다. 테스트 코드를 조금 추가한다. 제품 코드도 조금 추가한다. 두 가지 코드 흐름이 동시에 자라나 상호 보완하는 컴포넌트가 된다. 항체와 항원처럼 테스트와 제품 코드가 딱 들어맞는다.
수많은 혜택
확신
TDD를 프로다운 규칙으로 받아들이면 테스트를 하루에 십여 개, 일주일에 수백 개, 일년에는 수천 개를 만든다. 또한 테스트를 손 닿는 데 두고 코드를 바꿀 때마다 실행한다.
나는 자바로 만든 인수 테스트 코드인 FitNesse의 주요 저자이자 운영자다. FitNesse의 코드는 64,000줄인데, 그중 28,000줄은 약 2,200개의 단위 테스트 코드다. 테스트는 적어도 제품 코드의 90% 이상을 감당하며 실행에는 90초가 걸린다.
FitNesse의 어떤 부분이라도 바꾸게 되면 별 생각 없이 단위 테스트를 돌린다. 통과하면 내가 만든 변경이 다른 부분을 망가뜨리지 않았다고 거의 확신할 수 있다. '거의 확신'은 어느 정도의 확신일까? '거의 확신은' 제품을 출시할 정도로 충분한 확신이다.
결함 주입 비율
FirNesse는 사용자가 수천명이고 한 해에 2만 줄 이상이 추가됨에도 불구하고 오류 목록에는 17개의 오류만 존재한다(사실상 이 중 대부분은 겉모습인 UI에 관련된 문제다).
용기
왜 나쁜 코드를 봐도 고치지 않을까? 지저분한 함수를 본 첫 번째 반응은 "지저분하군. 정리 좀 해야겠네."다. 두 번째 반응은 "난 안 건드릴 거야!"다. 왜 이럴까? 손을 대면 뭔가 망가뜨릴지도 모른다는 위험을 무릅써야 한다는 사실을 알기 때문이다. 뭔가가 망가지면 자신이 감당해야 한다.
하지만 말끔히 정리해도 무엇도 망치지 않는다고 확신할 수만 있다면 어떨까? 이런 확신을 가진다면 과연 어떻게 될까? 클릭 한 번으로 90초 내에 방금 바꾼 내용이 아무것도 망가트리지 않고 오직 도움만 됐다는 사실을 알 수 있다면 어떨까?
이는 TDD의 강력한 이점이다. 믿음직한 테스트 묶음(suite)이 있으면 변경에 대한 두려움이 모두 사라진다. 나쁜 코드가 보이면 그저 그 자리를 깨끗이 치우면 된다. 코드는 찰흙처럼 부드러워 단순하고 즐거운 구조로 안전하게 조각할 수 있다.
두렵지 않은 프로그래머는 코드를 바로 정리한다! 깔끔한 코드는 이해하기 쉽고 바꾸기도 쉬우며 확장하기도 쉽다. 코드가 단순해져 오류가 생길 가능성이 줄어든다. 기반 코드는 지속적으로 좋아지는데, 이는 시간이 흐르면 기반 코드가 썩어 들어가는 업계의 일반적인 현상과 반대다.
문서화
세 가지 법칙에 따라 만든 각 단위 테스트는 코드로 만든 예제이며 시스템을 어떻게 사용하는지 알려준다. 세 가지 법칙에 따라 만든 단위 테스트는 시스템의 각 객체를 어떻게 만들어야 하는지, 또 각 객체를 만드는 데는 어떤 방법이 있는지 알려준다. 단위 테스트는 시스템의 모든 함수를 유용하게 호출하는 모든 방법을 알려준다. 어떤 일을 처리하는 법을 알고 싶다면 해당되는 단위 테스트를 찾아 세부사항을 보면 된다.
단위 테스트는 문서다. 시스템의 가장 낮은 단계의 설계를 알려준다. 명확하고 정확하며 독자가 이해하는 언어로 만들어져 실행 가능한 형식을 갖춘다. 낮은 단계에 대한 문서 중 가장 훌륭한 형태다.
설계
세 가지 법칙에 따라 테스트를 먼저 만들다 보면 딜레마에 빠진다. 어떤 코드를 만들어야 하는지 정확히 아는 데도 불구하고, 법칙을 따르려다 보니 제품 코드가 없어 실패하는 단위 테스트를 우선 만들어야 한다! 이는 만들려는 코드를 반드시 테스트해야 한다는 뜻이다.
테스트 코드를 만들려면 코드의 의존관계를 고립시켜야 한다는 어려움이 있다. 다른 함수를 호출하는 함수는 테스트하기 어려운 경우가 많다. 이 경우 테스트를 만들려면 함수를 다른 부분에서 떨어뜨리는 방법을 찾아야 한다. 다른 말로 표현하면 테스트를 먼저 만들기 위해서는 좋은 설계를 고민해야 한다.
테스트를 먼저 만들지 않으면 여러 함수를 테스트 불가능한 덩어리로 뭉치는 일을 막아줄 방어막이 사라진다. 테스트를 나중에 만들면 전체 덩어리의 입력과 출력은 테스트할지 몰라도 각각의 함수를 테스트하기는 힘들 것이다.
따라서 세 가지 법칙에 따라 테스트를 먼저 만드는 일은 의존성이 낮은 좋은 설계를 만드는 힘이 된다.
"근데 나는 테스트를 나중에 만들 수 있어요."라는 말은 들렸다. 사실이 아니며 만들 수도 없다. 물론 테스트 일부분은 나중에 만들 수 있다. 심지어는 주의를 기울이면 높은 커버리지를 달성하기도 한다. 하지만 일이 벌어진 후에 만드는 테스트는 먼저 만든 테스트만큼 예리하지 않는다.
TDD와 관련 없는 사실
TDD는 수많은 장점이 있지만 종교나 마법이 아니다. 세 가지 법칙을 따라도 모든 이점을 보장받지 못한다. 테스트를 먼저 만들어도 형편없는 코드가 나오기도 한다. 어쩌다 보면 테스트 코드 자체를 형편없이 만들기도 한다.
TDD를 연습할 때는 웹 기반보다 간단한 순수 JS(혹은 어느 언어든)를 테스트해보는 게 좋다.
import React from "react";
import TestUtils from "react-addons-test-utils";
import Button from "./button";
test("text와 함께 버튼을 렌더링한다.", () => {
const text = "text";
const renderer = TestUtils.createRenderer();
renderer.render(<Button text={text} />);
const button = renderer.getRenderOutput();
expect(button.type).toBe("button");
expect(button.props.children).toBe(text);
});
이를 통과시키기 위해
// button.js
import React, { Component } from "react";
class Button extends Component {
render() {
return <button>{this.props.text}</button>;
}
}
export default Button;