얼마 전, 지인으로부터 원티드 프리 온보딩 프론트엔드 과정이 있다는 얘기를 들었고, 곧바로 사전과제를 완료하고 제출했었는데 최종 합격했다고 연락이 왔다! 지난 24일 월요일에 OT가 시작되었고, OT라서 대충 넘어갈 줄 알았지만 그 날 팀 편성이 완료되고 수요일까지 해야될 과제도 주어졌다.
첫 번째 과제는 실시간 환율 데이터를 받아와서 송금액(USD)을 입력하면 선택한 나라의 화폐로 얼마인지 알려주는 간단한 환율 계산기를 제작하는 것이었다. 구현해야 될 화면은 이렇다.
여기에 필요한 기능은 다음과 같다.
첫 번째 과제라 그런지 비교적 간단한 기능 구현과제였고, unit test 하기 적절한 과제라고 생각하여 react-testing-library
와 jest
를 이용해보기로 했다. yarn
으로 create react-app
하였다면, jest
가 자동으로 설치된다.
환율정보는 https://currencylayer.com/ 의 무료 서비스를 이용해서 실시간으로 가져와야 되었고, axios
로 데이터를 받아왔다. 해당하는 코드를 다른 팀원도 사용할 수 있게 utils 폴더에 작성했다.
// utils/api.js
export const getExchangeRate = async () => {
const response = await axios.get(EXCHANGE_RATE_API_ENDPOINT);
return response.data;
};
데이터를 잘 받아오는지 test하기 위해 utils 안에 __test__
폴더를 생성하고, api.test.js 파일을 만들었다. 일단 GET 요청이 잘 되는지 확인해보자.
// utils/__test__/api.test.js
test("GET 성공 시 Status 는 200 을 반환한다.", async () => {
const response = await fetch(EXCHANGE_RATE_API_ENDPOINT, { method: "GET" });
expect(response.status).toBe(200);
});
test
글로벌 함수의 첫 번째 인자는 테스트 설명으로 해당 테스트를 누구나 알아 볼 수 있도록 자세하게 작성하는 것이 원칙이다. 두 번째 인자로는 테스트를 실행하는 함수를 받는다. it
으로 하는 동작과 완벽히 같은 일을 수행하며, it
보다 더 직관적으로 알아보기 쉽도록 하기위해 test
명령어를 따로 만들어 주었다고 한다.
두 번째로 볼 코드는 expect
이다. 기대한 결과가 성공인지 실패인지 확인해준다. 작성한 코드를 예로 들면, expect
안의 값(response.status)과 toBe
의 값(200)이 같으면 passed
, 다르면 failed
가 출력된다. toBe
는 matcher 메소드로 이외에도 toEqual, toBeNull, toHaveTextContent, toHaveStyle
등 아주 많은 종류의 method가 있다. jest
는 css도 체크가능한 이유가 toHaveStyle
같은 메소드를 지원하기 때문이다.
더 자세한 내용은 https://jestjs.io/docs/expect 에서 확인할 수 있다.
코드 작성 후 터미널에 yarn test
를 타이핑하면 jest가 watch모드로 동작하고 passed !
라는 문구가 나오면서 GET 요청이 정상적으로 되는 것을 알수 있다. watch모드는 파일에 수정 사항이 감지될 경우 자동으로 테스트를 실행해주는 상태를 말한다. 여기서 status는 서버 응답에 대한 HTTP 상태 코드를 의미하며 간단하게 나타내면 다음과 같다.
흔히 접하는 404 Not Found 페이지도 client 에러인 것. 자세한 정보는 다음 링크를 참고하면 좋을 것 같다.
이제 요청은 잘 되는걸 확인했으니, mock 데이터 파일을 생성해서 받아온 데이터가 mock 데이터 파일과 일치하는지 확인해보자. mock 데이터 파일은 위의 getExchangeRate
함수의 response를 json 파일로 생성했다.
// utils/__test__/api.test.js
test("mock데이터와 실제 api에서 받아온 데이터가 비슷하니?", async () => {
const response = await getExchangeRate();
expect(response).toBeCloseTo(mockData);
});
api에서 받아온 데이터가 매 초마다 바뀌기 때문에, 하드코딩된 mock 데이터의 값과 실제 받아온 데이터가 일치할 확률이 매우 낮았다. 따라서 toBeCloseTo
를 이용해 근사치인지 확인해 보려 했으나, 해당 메소드는 number만 처리할 수 있어서 object인 데이터를 비교할 수 없다고 failed
를 출력해주었다. object를 비교해주는 toMatchObject
는 정확한 값일 때만 성공이기 때문에 마찬가지로 제대로 동작하지 않았다. 시간이 부족해 결국 해결하지 못했고, mockData.json
에 필요한 데이터만 넣고, 받아온 데이터도 필요한 데이터만 찾아내서 하나씩 toBeCloseTo
로 비교하면 될 것 같다 정도로만 생각하고 다음으로 넘어가기로 했다.
필요한 정보를 가진 object를 constants.js
파일에 선언하고, select box를 만들어보자.
// utils/constants.js
export const FIRST_CALCULATOR_OPTIONS = [
{
key: "KRW",
value: "KRW",
name: "한국(KRW)",
},
{ key: "JPY",
value: "JPY",
name: "일본(JPY)",
},
{
key: "PHP",
value: "PHP",
name: "필리핀(PHP)",
},
];
//components/FirstCalculator.js
...
const [exchangeRate, setExchangeRate] = useState({
USDKRW: 0,
USDJPY: 0,
USDPHP: 0,
});
const [selectedExchangeRate, setSelectedExchangeRate] = useState({
rate: 0,
nation: "",
});
const getData = async () => {
try {
const { quotes } = await getExchangeRate();
const { USDKRW, USDJPY, USDPHP } = quotes;
setExchangeRate({ USDKRW, USDJPY, USDPHP });
setSelectedExchangeRate({
rate: USDKRW.toFixed(2),
nation: "KRW",
});
} catch (e) {
console.error(e)
}
};
useEffect(() => {
getData();
}, []);
const onChangeOption = (e) => {
const rate = exchangeRate[`USD${e.target.value}`].toFixed(2);
const nation = e.target.value;
setSelectedExchangeRate({
rate,
nation,
});
};
...
return (
...
<CalculatorBlock>
<CalculatorText>수취국가: </CalculatorText>
<select onChange={onChangeOption}>
{FIRST_CALCULATOR_OPTIONS.map((option) => {
const { key, value, name } = option;
return (
<option key={`option-${key}`} value={value}>
{name}
</option>
);
})}
</select>
</CalculatorBlock>
<CalculatorBlock>
<CalculatorText>환율: </CalculatorText>
{selectedExchangeRate.rate} {selectedExchangeRate.nation}/USD
</CalculatorBlock>
...
useEffect
로 처음 렌더링될 때, 데이터를 받아와서 exchangeRate
에 저장해주었다. option이 변경되면(클릭) selectedExchangeRate
에 변경된 국가명과 환율을 저장하고, 그 값을 보여준다.
일단 처음 렌더링될 때, 보여주는 국가명이 한국인지 확인해보기로 했다.
// components/__test__/FirstCalculator.test.js
import { render, screen } from "@testing-library/react";
import FirstCalculator from "../FirstCalculator";
test("수취국가의 기본 값은 한국인가?", async () => {
render(<FirstCalculator />);
const option = await screen.findByRole("option", { selected: true });
expect(option).toHaveTextContent("한국(KRW)");
});
render
와 screen
이 import된 것을 볼 수 있다. 여기서 render
는 인자로 받는 JSX의 가상돔을 생성하는 함수이다. 앞서 작성한 FirstCalculator
컴포넌트를 전달받는다. screen
은 생성된 가상돔에 접근하기 위한 전역 객체이다.
findByRole
은 role
에 해당하는 요소를 찾아주는 메소드이다. role
은 aria에서 사용하는 요소의 역할을 의미하는 속성이며 tag name과는 다르다. <button> <option> <a>
처럼 기본적으로 role
을 가진 요소도 있다. 이들의 기본 role
은 각각 button option link
이다. 더 자세한 정보는 아래 링크에서 확인할 수 있다.
https://www.w3.org/TR/html-aria/#docconformance
https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles
결과적으로 해당 코드는 FirstCalculator
가상돔을 생성하고, 거기서 role
이 option
인 것들 중 선택된 것은 '한국(KRW)' 텍스트를 가지고 있나? 라는 의미이다. 결과는 passed
다음 테스트로 option이 잘 바뀌는지 확인해야하는데 거기까지 하진 못하고 다음으로 넘어갔다.
환율과 송금액은 소수점 둘째 자리까지 나와야하고, 3자리 마다 콤마(,)를 나타내주어야 한다. 먼저 콤마 찍는 함수부터 만들어 보자.
// utils/comma.js
export const comma = (x) => x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
해석하면,
\B
: 앞에 경계문자가 있다. (여기서는 숫자)
(?=regex)
: positive lookahead. 뒤에 오는 것들 중에 해당하는 패턴이 있다.
(\d{3})+
: 숫자가 세개씩 있는데 이런 문자열이 1개 이상 반복된다.
(?!regex)
: negative lookahead. 뒤에 오는 것들 중에 해당하는 패턴이 없다.
(?!\d)
: 뒤에 숫자가 없다.
결과적으로 앞에는 숫자가 있으며, 뒤에는 세 자리 숫자로 구성된 하나 이상의 그룹이 있고, 그것들은 모두 뒤에 다른 숫자가 있어서는 안된다는 뜻이다. 해당하는 것들을 전부 찾아서 ','로 변경한다.
콤마가 제대로 찍히는지 test 해보자.
// utils/__test__/comma.test.js
import { comma } from "../comma";
test("정수 입력했을 때 콤마 잘 찍히나?", () => {
expect(comma(123456789)).toBe("123,456,789");
});
passed
.
소수도 잘 찍히는지 확인해보자.
// utils/__test__/comma.test.js
...
test("정수만 입력해도 소수점 잘 나오나?", () => {
const num = 123456789;
expect(comma(num.toFixed(2))).toBe("123,456,789.00");
});
test("소수 입력했을 때 콤마 잘 찍히나?", () => {
expect(comma(123456.78)).toBe("123,456.78");
});
마찬가지로 passed
. 이제 이 함수를 활용해서 결과값을 출력함으로써 마무리 해보자.
입력 값이 없거나 숫자가 아닐경우, 0이하, 10000 초과 일 경우에 송금액이 유효하지 않는다는 문구를 나타내주어야 한다. 정상 범위일 경우는 수취 금액은 얼마 입니다 라는 문구가 나타나야한다.
// components/FirstCalculator.js
const [remittance, setRemittance] = useState(0); // 송금액
const [receviedAmount, setReceviedAmount] = useState(0); // 수취금액
const [isResult, setIsResult] = useState(false); // 결과값이 있는지
const [isValidAmount, setIsValidAmount] = useState(true); // 입력이 정상 범위인지
...
const onSubmit = () => {
const parsedRemittance = parseFloat(remittance);
setIsResult(true);
if (
parsedRemittance <= 0 ||
parsedRemittance > 10000 ||
isNaN(parsedRemittance)
) {
setIsValidAmount(false);
return;
}
setIsValidAmount(true);
setReceviedAmount(
(parseFloat(remittance) * selectedExchangeRate.rate).toFixed(2)
(parsedRemittance * selectedExchangeRate.rate).toFixed(2)
);
};
const amountMessage = (isValidAmount) => {
return isValidAmount
? `수취금액은 ${comma(receviedAmount)} ${selectedExchangeRate.nation}
입니다.`
: "송금액이 바르지 않습니다";
};
...
return (
...
{isResult && (
<CalculatorBlock>
<CalculatorText error={!isValidAmount}>
{amountMessage(isValidAmount)}
</CalculatorText>
</CalculatorBlock>
...
)
submit 버튼을 클릭하면, isResult
가 true
로 바뀌고, amountMessage
가 동작하는데, 입력 값의 valid
여부에 따라서 amountMessage
의 값이 바뀌도록 코드를 작성했다. 처음 작성한 코드는 valid
에 따라서 다른 결과값을 보여 주려면 submit 정상 동작을 한 번 시켜주었어야 했어서 처리하는 데 애 좀 먹었다.
먼저 송금액이 0일 경우 테스트 해보자.
// components/__test__/FirstCalculator.test.js
import { render, screen, fireEvent } from "@testing-library/react";
test("송금액 입력이 0일 경우 Submit 클릭 했을 때 수취금액 유효성 검사", async () => {
render(<FirstCalculator />);
const submitButton = await screen.findByRole("button");
const input = screen.getByPlaceholderText("송금액을 입력해주세요");
fireEvent.change(input, {
target: {
value: "0",
},
});
fireEvent.click(submitButton);
const text = screen.getByTestId("amountText");
expect(text).toHaveTextContent("송금액이 바르지 않습니다");
앞에서 확인했던 render
, screen
외에 fireEvent
가 추가되었다. 이것은 가상돔과의 상호작용이 가능하도록 하는 객체로 이벤트를 발생시킬 수 있다.
fireEvent
로 찾은 input의 value를 0으로 변경시키고, 버튼을 클릭하게 하여 가상돔에 결과 문구가 출력이 되도록 만들어주었다. 출력된 결과 문구를 가져오기 위해 data-testid
라는 속성을 주었다.
...
<CalculatorText error={!isValidAmount} data-testid="amountText">
{amountMessage(isValidAmount)}
</CalculatorText>
...
이제 getByTestId
로 해당 문구를 가져와서 toHaveTextContent
로 기대값이 맞는지 확인해보면 passed
가 잘 나온다.
마찬가지로 10001 일때도 test 해보자
test("송금액 입력이 10001일 경우 Submit 클릭 했을 때 수취금액 유효성 검사", async () => {
render(<FirstCalculator />);
const submitButton = await screen.findByRole("button");
const input = screen.getByPlaceholderText("송금액을 입력해주세요");
fireEvent.change(input, {
target: {
value: "10001",
},
});
fireEvent.click(submitButton);
const text = screen.getByTestId("amountText");
expect(text).toHaveTextContent("송금액이 바르지 않습니다");
});
결과는 passed
이제 정상 입력 범위일 때를 test 해보자.
test("송금액 입력이 0초과 10000 이하일 경우 Submit 클릭 했을 때 수취금액은 어떻게 되지?", async () => {
render(<FirstCalculator />);
const submitButton = await screen.findByRole("button");
const input = screen.getByPlaceholderText("송금액을 입력해주세요");
const exchangeRate = screen.getByTestId("exchangeRateText");
fireEvent.change(input, {
target: {
value: "100",
},
});
fireEvent.click(submitButton);
const text = screen.getByTestId("amountText");
expect(text).toHaveTextContent(
`수취금액은 ${comma(
(+input.value * +exchangeRate.textContent).toFixed(2)
)} KRW 입니다.`
);
});
마찬가지로 passed
. 이로써 첫 번째 과제를 끝냈고, 리팩토링과 배포만 남았다.
// hooks/useExchangeRateLoad.js
import { useState, useEffect } from "react";
import { getExchangeRate } from "../utils/api";
export default function useExchangeRateLoad() {
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [data, setData] = useState(null);
const getData = async () => {
try {
setIsLoading(true);
const { quotes } = await getExchangeRate();
setData(quotes);
} catch (e) {
setIsError(true);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
getData();
}, []);
return {
isLoading,
isError,
data,
};
}
getData 함수를 custom hooks로 만들고 데이터와 로딩 중인지 확인하는 state, 에러 발생시 state를 리턴해주었다.
// components/FirstCalculator.js
import useExchangeRateLoad from "../hooks/useExchangeRateLoad";
...
const { isLoading, isError, data } = useExchangeRateLoad();
...
if (isError) return <div>로딩 중 에러가 발생 했습니다.</div>;
if (isLoading) return <div>환율 정보 로딩중...</div>;
return (
...
)
hooks를 import 해서 에러와 로딩 중을 처리하고, 데이터를 사용했다.
기존에 작성했던 JSX 내부 함수는 state가 변경되었을 때, 새로 생성되었다. 이를 useCallback으로 막아주자.
const onChangeOption = useCallback((e) => {
...
});
const onSubmit = useCallback(() => {
...
}, [remittance, rate]);
const amountMessage = useCallback((isValidAmount) => {
...
},[receviedAmount, nation]);
컴포넌트 분리는 굳이 안해도 될 것 같아서 리팩토링은 여기서 끝.
netlify에 손쉽게 배포했다. 근데 데이터가 받아지지 않는다...? 찾아보니 api 서버는 http인데, 배포 서버는 https 라서 안됐던 것. 이를 해결하기 위해 index.html에 메타태그를 추가해 주었다.
<meta
http-equiv="Content-Security-Policy"
content="upgrade-insecure-requests"
/>
하지만 api 서버에서 free plan은 http 요청이 안되도록 설정이 되어있어 상황은 그대로였다. 방법은 http로 배포하거나, 유로 plan을 구매하는 것이었고 우리는 http로 배포하기로 했다.
결과는 ? heroku로 http 배포 성공 !
좋은 팀원을 만나서 과제를 잘 마무리 할 수 있었다. 페어프로그래밍으로 진행했었는데, React 코드 작성, 리팩토링, 배포에 대해서 정말 많은 것을 배웠다. 테스트 코드도 배우기만 했고 직접 사용한 적은 이번이 처음이었는데 테스트 주도 개발이 아닌 개발 주도 테스트(?)가 된 것 같아 아쉽다. 사용 해본 거로 만족하자. 다음 과제부턴 더 어려워 질 것이고, 테스트 코드 작성하기도 빠듯할 것 같다.
아쉬운 점은 과제 api free plan에서 http 요청이 안되는 것, 그리고 월간 request가 최대 250회라 개발 중간에 자주 막혀서 새로 가입해야 했던 것 정도가 있겠다. 과제 제출 직전에 또 새로 가입했다.
heroku 배포 전에 AWS S3로 배포했었는데, 잘 안되어서 유료 plan을 사야되나 싶었는데 띠용? 자고 일어나니 킹갓 팀원 분께서 heroku로 http 배포 성공해주셨다. 만쉐이 !
킹갓기영님 파이팅입니다