예제 소스 :
https://github.com/lacomaco/React-Component-Test
환경 설정은 예제 소스를 참고해 주세요.
React에서 공식적으로 추천하는 Jest 라이브러리를 사용할 예정입니다.
https://testing-library.com/docs/dom-testing-library/intro
https://testing-library.com/docs/react-testing-library/intro
프론트엔드 환경에서 입력값은 정수나 문자같은 데이터 형식이 아닌
클릭, 드래그, 엔터 이벤트와 같이 이벤트의 형식으로 입력값이 들어오는 경우가 많습니다.
또 실행 결과가 함수처럼 값을 리턴하는것이 아닌 DOM의 변경으로 함수 실행 결과가 반영되는 경우가 대부분 입니다.
일반적인 프로그램 테스팅 환경과 조금 다르기 때문에 라이브러리의 힘을 빌려야 수월한 테스트가 가능해집니다.
이 글에서는 React팀에서 추천하는 React-testing-Library을 이용해서 테스트를 진행할 예정입니다.
https://mswjs.io/docs/recipes/query-parameters
프론트 엔드 테스트가 어려운 많은 이유중 하나는 서버와 통신하기 때문입니다.
위처럼 서버를 모킹하지 않는다면 FrontEnd에서 BackEnd로 통신하는 시간으로 인해 테스트 수행시간이 늦어지고
BackEnd에서 DB을 조회하여 FrontEnd에 데이터를 전달하는 구조라면 DB의 상태에 따라 테스트
값이 달라지기 때문에 현실적으로 테스트 하기 어렵습니다.
이를 해결하기 위해서 axios와 Fetch등을 모킹하곤 하지만 MSW을 사용하면 더 쉽게 모킹하는것이 가능합니다.
MSW 라이브러리는 FrontEnd에서 BackEnd로 가는 요청을 가로채 지정한 값을 리턴하도록 해주는 라이브러리입니다. 사용법도 굉장히 간단합니다.
import "@testing-library/jest-dom/extend-expect";
import { setupServer } from "msw/node";
import { rest } from "msw";
const handlers = [
rest.get("/load", (req, res, ctx) => {
return res(
ctx.json({
number: 50,
}),
ctx.status(200),
ctx.delay(10)
);
})
];
// Setup requests interception using the given handlers.
const server = setupServer(...handlers);
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
export default server;
setupServer을 통해 모킹할 가상의 서버를 만든후
handlers에 모킹할 API들을 작성하고 어떤 값을 리턴해야할지 지정만 해주면 테스트 환경에서는 MSW가 서버를 대체하게 됩니다.
모킹할 API를 선언하는 코드도 Express와 비슷하죠?
더 자세한 부분은 MSW 공식 홈페이지를 참고해 주세요!
https://ko.reactjs.org/docs/testing.html
https://github.com/lacomaco/React-Component-Test
저희가 만들 프로그램의 모양은 대략 이렇게 생겼습니다.
이 프로그램은 처음 컴포넌트 렌더링 서버의 /load에 요청을 서버에 보내 저장 되어 있는 숫자를 가져옵니다.
서버 응답이 오기 전엔 숫자 0을 렌더링 하고 있고 이내 응답이 도착하면 요청 값에 맞게 숫자를 변경합니다.
+,- 버튼을 누르면 숫자가 변경됩니다.
각 버튼을 누르면 서버의 /changeNumber 에 요청을 보내 서버내에 저장 되어 있는 숫자를 변경시키고 컴포넌트를 업데이트 해줍니다.
하지만 요청에 실패하면 컴포넌트를 업데이트 하지 않고 에러 메시지를 화면에 뿌려줍니다.
눈치 채시겠지만 저희는 이 2가지 요소를 보고 프로그램이 정상 작동하는지 테스트할 수 있습니다.
컴포넌트의 소스코드는 아래와 같습니다.
function Number() {
const [number, setNumber] = useState(0);
const [errorMessage, setErrorMessage] = useState("");
useEffect(async () => {
const message = await axios.get("/load");
if (message.status === 200) {
setNumber(message.data.number);
}
}, []);
return (
<>
<NumberView>{number}</NumberView>
{errorMessage !== "" ? <ErrorMessage>{errorMessage}</ErrorMessage> : null}
<MinusButton
setNumber={setNumber}
number={number}
setErrorMessage={setErrorMessage}
/>
<PlusButton
setNumber={setNumber}
number={number}
setErrorMessage={setErrorMessage}
/>
</>
);
}
function PlusButton({ number, setNumber, setErrorMessage }) {
const plusNumber = () => {
axios
.post("/changeNumber", {
number: number + 1,
})
.then(() => {
setNumber(number + 1);
setErrorMessage("");
})
.catch(e => {
setErrorMessage("PlusButton Error");
});
};
return <Button onClick={plusNumber}>+</Button>;
}
function MinusButton({ number, setErrorMessage, setNumber }) {
const subNumber = () => {
axios
.post("/changeNumber", {
number: number - 1,
})
.then(() => {
setNumber(number - 1);
setErrorMessage("");
})
.catch(e => {
setErrorMessage("MinusButton Error");
});
};
return <Button onClick={subNumber}>-</Button>;
}
이제 테스트를 하러 가볼까요?
테스트 코드를 작성하기 전에
우선 /load 와 [post] /changeNumber API를 모킹해야합니다.
이 환경설정은 테스트 코드 실행전에 실행되어야 하기 때문에 jest "setupFileAfterEnv" 속성에 Test 설정 파일을 지정해 줘야합니다.
// package.json ...
{
...
"jest": {
"setupFilesAfterEnv": [
"./src/setUpTest.js"
]
},
...
}
그후 setUpTest 파일에 API를 모킹합시다.
import "@testing-library/jest-dom/extend-expect";
import { setupServer } from "msw/node";
import { rest } from "msw";
const handlers = [
rest.get("/load", (req, res, ctx) => {
return res(
ctx.json({
number: 50,
}),
ctx.status(200),
ctx.delay(10)
);
}),
rest.post("/changeNumber", (req, res, ctx) => {
return res(
ctx.json({
message: "good to go",
}),
ctx.status(200),
ctx.delay(10)
);
}),
];
// Setup requests interception using the given handlers.
const server = setupServer(...handlers);
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
export default server;
handlers 부분에 /load,/changeNumber API를 모킹합니다.
/load에서는 항상 50값을,changeNumber는 항상 status 200을 리턴하도록 하였습니다.
(resetHandlers는 테스트가 실행되는 도중 등록된 모킹 API를 초기화 시켜주는 함수입니다. )
https://mswjs.io/docs/api/setup-server/reset-handlers
이제 본격적으로 테스트 코드를 작성해 봅시다.
우선 페이지가 업로드 되었을때 값이 정상적으로 로드 되는지 확인해야 합니다.
저희는 테스트 환경에서 /load 요청시 항상 50을 리턴하도록 설정한거 기억 하시나요?
그럼 페이지에서 50이 나오는걸 확인하면 테스트가 끝나겠군요.
import React from "react";
import Number from "../components/Number";
import { render, fireEvent, waitFor, screen } from "@testing-library/react";
test("Number 컴포넌트가 첫 로드시 서버로 부터 숫자를 가져와 뷰에 반영하는가?", async () => {
render(<Number />);
await waitFor(() => screen.getByText("50"));
});
지금은 document.body.div에 컴포넌트를 렌더링 한다고 이해하셔도 됩니다.
더 자세한 옵션은
https://testing-library.com/docs/react-testing-library/api#render
링크를 확인해주세요.
현재 Number 컴포넌트는 렌더링되고 바로 서버에 통신을 보내는데 이는 비동기이기 때문에 언제 응답이 돌아오는지 저희가 알 방법이 없습니다.
그래서 waitFor 함수를 통해서 스크린내 값이 50이 생길 때 까지 기다리고, 오지 않았다면 테스트가 틀린것으로 간주합니다.
import { render, fireEvent, waitFor, screen } from "@testing-library/react";
...
describe("버튼이 눌렸을때 응답이 200인 경우", () => {
let minusButton;
let plusButton;
beforeEach(async () => {
render(<Number />);
await waitFor(() => screen.getByText("50"));
minusButton = screen.getByText("-");
plusButton = screen.getByText("+");
});
test("minusButton이 눌리면 -1이 반영되는가", async () => {
fireEvent.click(minusButton);
await waitFor(() => screen.getByText("49"));
});
test("plusButton이 눌리면 +1이 반영되는가", async () => {
fireEvent.click(plusButton);
await waitFor(() => screen.getByText("51"));
});
});
페이지 로드시 바로 서버로 통신하는 부분은 이벤트가 없었지만
버튼 테스트는 누군가 버튼을 눌러줘야 서버로 통신하고 결과값을 반영합니다.
이 이벤트의 발행은 testing-library가 제공해주는 fireEvent 함수를 사용해서 할 예정입니다.
렌더링후 , getByText로 버튼을 찾은후
fireEvent.click(버튼)
하면 이벤트가 발행됩니다. 정말 간단하죠?
남은건 이제 서버에서 응답이 돌아온후 화면에 반영되는가를 waitFor 함수를 통해 기다리는것 뿐입니다.
이번엔 실패를 해야하는 상황이기 때문에 다시 서버 모킹을 해줄 필요성이 있습니다.
import server from "../setUpTest";
import { rest } from "msw";
...
describe("버튼이 눌렸을때 응답이 400이 오는경우", () => {
let minusButton;
let plusButton;
beforeEach(async () => {
const { getByText } = render(<Number />);
await waitFor(() => screen.getByText("50"));
minusButton = getByText("-");
plusButton = getByText("+");
server.use(
rest.post("/changeNumber", (req, res, ctx) => {
return res.once(
ctx.json({
message: "you fail",
}),
ctx.status(400),
ctx.delay(20)
);
})
);
});
test("plusButton이 눌리면 에러 메시지가 떠야한다.", async () => {
fireEvent.click(plusButton);
await waitFor(() => screen.getByText("PlusButton Error"));
});
test("minusButton이 눌리면 에러 메시지가 떠야한다.", async () => {
fireEvent.click(minusButton);
await waitFor(() => screen.getByText("MinusButton Error"));
});
});
각 테스트를 실행하기 전에
msw를 이용해서 생성한 가짜 서버에 에러를 일으키는 API로 변경합니다.
beforeEach(async () => {
const { getByText } = render(<Number />);
await waitFor(() => screen.getByText("50"));
minusButton = getByText("-");
plusButton = getByText("+");
server.use(
rest.post("/changeNumber", (req, res, ctx) => {
return res.once(
ctx.json({
message: "you fail",
}),
ctx.status(400),
ctx.delay(20)
);
})
);
});
그후 나머지는 위와 동일합니다. 버튼을 찾은후 이벤트를 누르고
정상적으로 에러 메시지가 화면에 표시되는지 확인하면 됩니다.
test("plusButton이 눌리면 에러 메시지가 떠야한다.", async () => {
fireEvent.click(plusButton);
await waitFor(() => screen.getByText("PlusButton Error"));
});
test("minusButton이 눌리면 에러 메시지가 떠야한다.", async () => {
fireEvent.click(minusButton);
await waitFor(() => screen.getByText("MinusButton Error"));
});
Testing-Library의 철학은 테스트에 구현 세부사항을 적지 않도록 하는 철학이 묻어져 있습니다.
구현 세부사항이란 Props,State,UseEffect,useCallback,...
컴포넌트의 기능을 이루고 있는 세부 기능들을 의미합니다. props,state,useEffect,useCallback와 같이 컴포넌트에만 존재하고 컴포넌트에서만 사용 가능한 Hooks들은 테스트할 방도가 없습니다.
오로지 컴포넌트의 인터페이스를 통해서 접근하고 컴포넌트가 어떤 변화를 보이는지만 테스트하도록 하고 있다는 느낌을 많이 받았습니다.
그래서 테스트 코드를 짜면서 사용자 중심의 사고를 해야합니다.
예를 들어서 만약 예제 프로그램에서 Error메시지 출력 부분이 없다면 이 프로그램은 테스트 하기 정말 까다로워집니다. 비교할 대상이 없기 때문이에요.
이를 사용자 중심의 사고로 다시 생각하면 이 테스트 라이브러리의 철학을 조금 이해할 수 잇습니다.
유저는 PlusButton을 눌렀는데 프로그램 내부적로는 에러가 발생하고 옳바르게 처리했습니다. 하지만 유저는 그 에러가 잘 처리됬는지 알 방도가 없습니다. 에러 메시지가 화면에 출력이 안되니까요! 서버로가는게 오래 걸린건지 아니면 프로그램이 엉망인건지 유저 입장에서는 모르게 됩니다.
그래서 하염없이 기다리다가 옳바르게 에러가 났고 잘 처리했음에도 프로그램이 엉망이다 라고 생각할 수 도 있게 되겠죠.
이렇게 내부 구현 요소를 테스트할 수 없게 가려놓음으로써 사용자 중심의 사고를 하게 하는 과정이 조금 신기했습니다.