발단
최근, 회사 프로젝트에 유닛 테스트를 도입하기로 했습니다. 기존에는 Cypress
를 이용한 E2E 테스트만을 통해 데일리 빌드를 수행하고 있었는데요, Cypress
가 유닛 테스트의 영역까지 차지하고 있는 게 좀 어색하게 느껴졌기 때문이에요. 컴포넌트가 다른 환경에 의존하지 않고 그 자체만으로 제대로 동작하는지는 유닛 테스트를 통해 확인되어야 한다고 생각하거든요.
테스트 방법론이야 여러가지가 있겠지만, E2E는 유저의 입장에서 전체적인 어플리케이션의 Flow를 테스트하는 것이라고 봅니다. 그에 비해 유닛 테스트는 별개의 컴포넌트의 무결성을 체크하는 것이라고 보면 될 것 같아요. 각종 Validation checking이나 그에 따른 UI 상태의 변화 같은 것들이죠. 기존에는 Jest를 이용한 테스트가 있었지만, React v17부터는 공식적으로 testing-library
를 사용한 테스트가 권장되는 모양이라, 해당 라이브러리를 이용해서 테스트를 진행해보기로 했습니다.
전개
그런데 이미 충분히 규모가 커진 프로젝트에 새로운 유닛테스트를 도입하는 건 쉽지 않은 일이었습니다. 게다가 회사 프로젝트는 리액트만으로 이뤄지지 않고 다른 구형 라이브러리로 짜여진 Legacy code들도 잔뜩 있었습니다. 그것들까지 이걸로 커버를 할 수 있을지 없을지는 확신할 수 없었지만, 일단은 리액트 쪽 코드만이라도 테스트하기로 했습니다.
우선 앱의 얼굴과도 같은 로그인 폼을 먼저 테스트해보기로 했어요. 이곳이 제대로 테스팅된다면, 시간이 다소 소요되더라도 다른 컴포넌트들도 충분히 도입이 가능하리라는 기대가 있었거든요.
시작은 매우 좋았습니다. ID나, Password validation check 등의 부분들은 충분히 유닛 테스트로 빠르고 간편하게 수행할 수 있었어요. 기존의 Cypress
를 통한 테스트보다 속도면에서도 월등했습니다. 유닛 테스트로 진작에 수행했어야 했을 로직들이 Cypress
에 들어있었던지라, 테스트 케이스는 따로 작성할 필요도 별로 없었습니다. Cypress
에 있던 케이스들을 그대로 꺼내와서 사용하면 됐거든요. 순조롭게 테스트들을 작성해나가던 도중 한 가지 위기가 찾아오고야 말았습니다.
위기
우리 어플리케이션에서는 Invalid한 정보의 User로 로그인을 시도했을 때 Snackbar를 띄워줍니다. 그리고 Valid한 경우라면 아무런 메시지를 띄우지 않고 로그인이 진행되는데요, 여기서 Invalid한 User를 이용하는 경우 문제가 생기기 시작했습니다. 로그인 테스트를 하기 위해서 testing-library
에서 추천하는 msw
라는 mock-server
라이브러리를 사용했는데, 이것에서 문제가 나타나기 시작했습니다.
로그인 로직은 다음과 같습니다.
- 버튼 클릭
- RSA 인증키를 가지고 로그인을 시도하는 User인지 확인
3-1. 가지고 있다면 해당 키를 이용해 User 정보를 암호화한 뒤 Login 요청 전송
3-2. 가지고 있지 않다면 그냥 Login 요청 전송
이렇게 총 2회의 요청을 통해 로그인을 처리하고 있는데요, 이상하게 mock-server
에서 자꾸만 특정 에러를 뱉어냅니다. 처음에는 axios baseUrl
설정의 문제라고 생각했는데, 그 문제는 아니었습니다. 왜냐면 처음 RSA 인증키를 조회하는 요청을 빼고 테스트를 하면 정상적으로 성공했거든요. 팀원들 모두와 머리를 싸매고 고민을 해봤지만, 어떤 이유 때문인지 2번의 요청을 순차적으로 보내면 mock-server
가 닫힌다 라는 이상한 결론에 닿고 말았습니다. 그렇지만 저런 결론은 아무래도 이상하잖아요. testing-library
의 문제인지 msw
의 문제인지 확실히 하고 싶었습니다.
절정
우선은 라이브러리를 교체해보기로 했습니다. mock-server
를 만드는 axios-mock-adapter
가 바로 그것인데요, msw
에 비해서 좀 더 간결한 문법과 짧은 코드가 마음에 들었습니다.
그리고 놀랍게도, 단지 라이브러리만 교체했을 뿐인데 테스트가 정상적으로 작동하는 것을 볼 수 있었습니다. 완전히 동일한 로직의 테스트케이스를, 라이브러리만 다르게 사용하여 2벌 만들어두고 테스트를 돌리면 한쪽은 성공을, 한쪽은 실패를 뱉어냈습니다. 이것은 아무래도 라이브러리 내부의 문제라는 생각이 들어서 msw
측에 제보를 하기로 했습니다.
issue를 열었더니 하루가 채 안돼서 답변이 달렸습니다. 혹시 baseURL을 명시적으로 적어서 요청을 보내보았느냐는 답변이었는데, 사실 이미 여러번 적용을 해보았던 문제였습니다. 그런 문제는 아니라고 생각한다고 대답했더니, 재현 repository를 공유해줄 수 있겠느냐고 물었습니다.
그런데 이건 회사 코드고 private gitlab server에 구축되어 있는 거라서 공개가 불가능했습니다. 최대한 동일한 환경을 구축해서 공유해주는 방법 뿐이라는 생각이 들었고, 정말 최대한 동일하게 환경과 api를 맞춰보았습니다.
그런데 이상하게도 재현이 되지 않았습니다. 백엔드 로직이 문제인건가 하는 생각도 해봤지만, mock-server를 사용하는건데 백엔드 로직이 무슨 상관이겠어요. 애초에 백엔드 로직 떄문에 뭐가 되고 안되는 것이 정해진다면 그건 유닛 테스트라고 부르면 안되겠죠.
결말
결국 reproduction이 불가능하게 되어 담당자에게 이야기를 했습니다. 저도 개발자로서 상황을 설명하는 코드만 보고 뭐가 문제인지 파악하는 건 불가능하다고 생각하기 때문에, 어떻게든 재현을 시켜보고 싶었지만 그게 잘 안됐네요. 제가 가지고 있는 Mac과 회사 운영 체제인 Windows의 차이일지도 모르겠다는 생각도 들어서 Windows 환경과 Mac, Ubuntu 환경에서까지 모두 재현을 해봤지만 그것도 정답은 아니었고... 어쩌면 다른 쪽의 테스트 케이스를 작성하면서 우연히 문제가 재현되어 정확한 원인을 파악할 수 있을지도 모르겠다는 생각도 듭니다. 이렇게 해서까지 msw를 고집할 필요도 사실 없겠죠..
다소 찜찜한 결말이 되었지만, 어쨌든 testing-library
의 도입은 성공적입니다. 제가 그동안 잘못 쓰는 줄도 모르고 잘 못 쓰고 있던 코드 몇 개를 발견했거든요.
input element를 위한 onChange 함수를 작성할 때, 아주 많은 사람들은 아래와 같은 코드를 작성하고는 합니다.
const [userInfo, setUserInfo] = useState<UserInfo>({ id: '', password: '' })
// ...
const handleChange = (e) => {
setUserInfo((prevInfo) => ({ ...prevInfo, [e.target.id]: e.target.value }))
}
그런데 이렇게 작성하게 되는 경우, ID와 Password input 핸들러를 따로 쓰는 경우가 아니라면 UI에서는 정상적으로 변경 사항이 UserInfo에 반영되지만, 테스트에서는 나중에 오는 input의 변경 사항을 감지하지 못합니다.
분명히 정상적으로 ID와 Password를 둘 다 입력 받았는데, Password를 입력받지 않은 것처럼 행동한다는 것이죠.
/** 이렇게 쓰면 문제 없이 돌아가기는 합니다 */
const handleUserIdChange = (e) => {
setUserInfo((prevInfo) => ({ ...prevInfo, [e.target.id]: e.target.value}))
}
const handleUserPasswordChange = (e) => {
setUserInfo((prevInfo) => ({ ...prevInfo, [e.target.id]: e.target.value }))
}
그런데 위와 같이 쓸데없이 코드량을 늘리는 코드를 작성하고 싶은 개발자는 아무도 없을 거예요. 그러면 다음과 같이 작성하면 문제가 없습니다.
const handleChange = (e) => {
const { id, value } = e.target
setUserInfo((prevInfo) => ({ ...prevInfo, [id]: value }))
}
일단 한 번 value들을 destructuring한 뒤 값을 넣어주면 UI에서 정상적으로 돌아가는 것과 마찬가지로 테스트도 잘 진행되게 됩니다. 사실 생각해보면 값을 복사해두고 사용하는 게 안전한 일이라는 건 굉장히 자명한 사실이지만 대개 UI 상에서 User가 직접 조작할 때는 Race condition을 유발할 만큼 빠르게 진행이 되지 않기 때문에 놓치기 쉬운 부분이기도 합니다.
유닛 테스트의 도입을 통해서 좀 더 안전하고 정석에 가까운 문법을 강제로나마 사용하게 될 수 있어서 다행이라고 생각합니다.