브라우저에서 Vue 컴포넌트 테스트하기
안녕하세요! 제가 이곳에서 오랫동안 진행해 온 프로젝트 중 하나는 Node 없이 프론트엔드 자바스크립트를 작성하는 방법 알아내기예요. 다른 서버용 JS 런타임을 사용하지 않고요.
프론트엔드 JS 프로젝트를 하다 보면 자주 부딪히는 문제가 있는데, 테스트를 어떻게 작성해야 할지 모르겠다는 거예요. 예전에 Playwright를 써 본 적이 있는데, 매번 새로운 브라우저 프로세스를 띄우는 게 느리고 다루기 번거롭게 느껴졌고, 테스트를 실행하려면 Node 코드를 좀 작성해야 하기도 했어요.
그래서 결국 프론트엔드 코드는 그냥 테스트를 안 하게 됐는데, 기분이 썩 좋진 않아요. 어차피 프로젝트를 자주 업데이트하지 않으니까 크게 문제가 되진 않았지만, 좀 더 자신 있게 변경할 수 있으면 좋겠다는 생각은 계속했어요! 그래서 마음에 드는 프론트엔드 테스트 방법을 찾는 게 오랫동안 위시리스트에 있었어요.
아이디어: 그냥 브라우저 탭에서 테스트 실행하기
Alex Chan이 얼마 전에 Testing JavaScript without a (third-party) framework라는 멋진 글을 썼어요. 이 시리즈에서 제가 이전에 쓴 글에 대한 답글이었는데, 브라우저 페이지 안에서 동작하는 아주 작은 단위 테스트 프레임워크를 만드는 방법을 설명한 글이었죠.
그 글을 정말 좋아했는데, 단위 테스트만 다루고 있어서 Vue 컴포넌트를 위한 종단 간 통합 테스트를 작성하고 싶었던 저는 방법을 알 수가 없었어요.
그러다 얼마 전 Marco와 이야기하는데, 그가 “Vue 컴포넌트 테스트 그냥 브라우저에서 바로 실행할 수 있어”라는 식으로 말하더라고요. 그래서 “오, 다시 한번 시도해 봐야겠다!!!” 싶었죠.
이 작업들을 모두 어제 막 끝낸 거라 당연히 개선할 점이 많겠지만, 잊어버리기 전에 과정에서 느낀 몇 가지를 적어 두려고 해요.
저한테는 이게 조금 까다로웠어요. Vue 사이트는 보통 빌드 과정 어딘가에서 Node를 사용한다고 가정하거든요(“1단계: npm install THING” 같은 내용이 많아요). 저는 Node나 Deno 등을 쓰고 싶지 않았는데, 막상 해 보니 그렇게 복잡하지는 않더라고요.
이번에 테스트 이야기를 할 프로젝트는 제가 2023년에 만든 zine 피드백 사이트예요.
테스트 프레임워크: QUnit
저는 QUnit을 사용했어요. 정말 잘 동작했지만, 동작 방식에 대해 특별히 할 얘기가 없어서 이 정도로 넘어갈게요. Alex가 제안한 “직접 테스트 프레임워크 만들기” 방식도 잘 동작했을 거라고 생각해요. 저는 이 가이드를 따라 했어요.
QUnit에 테스트 하나만 다시 실행하는 “rerun test” 버튼이 있어서 좋았어요. 제 테스트에는 네트워크 요청이 워낙 많다 보니, 테스트 하나만 실행할 수 있으면 디버깅할 때 훨씬 덜 헷갈리거든요.
1단계: 테스트를 위해 컴포넌트 준비하기
가장 먼저 해야 할 일은 Vue 컴포넌트를 테스트 환경에서 쓸 수 있도록 준비하는 거였어요.
메인 앱을 수정해서 모든 컴포넌트를 window._components에 넣었어요. 대략 이런 식이에요:
const components = {
'Feedback': FeedbackComponent,
...
}
window._components = components;그러고 나서 mountComponent 함수를 만들 수 있었는데, 이 함수는 평소 메인 앱이 하는 일(사용하려는 컴포넌트로 작은 템플릿을 렌더링하는 일)과 거의 똑같이 동작해요. 다른 점은 딱 두 가지예요:
- 필요하면 props로 사용할 추가 데이터를 넘길 수 있어요.
- 컴포넌트를 테스트가 끝나면 DOM에서 제거될 임시의 보이지 않는 div에 마운트해요. 이 div는 화면 밖에 위치(
position: absolute; top: -10000, ...)해서 보이지 않아요.
여기 mountComponent 함수를 사용하는 모습은 이렇게 생겼어요:
const {div} = mountComponent(
'<Page :feedbacks="feedbacks" id=2 />',
{feedbacks: [testFeedback]},
);그리고 코드는 이렇게 생겼어요:
function mountComponent(template, data) {
const app = Vue.createApp({
template: template,
data: () => data,
})
for (const [c, v] of Object.entries(window._components)) {
app.component(c, v);
}
const div = document.getElementById('qunit-fixture')
.appendChild(document.createElement('div'));
return div;
}결과로 얻은 div에서는 프로그래밍 방식으로 클릭하거나, 폼 데이터를 채우거나, 올바른 내용이 나타나는지 확인하는 등의 작업을 할 수 있어요.
2단계: 픽스처 데이터 추가하기
클라이언트 JS가 서버와 제대로 동작하는지 확인하는 종단 간 통합 테스트를 작성하는 거라, 데이터베이스에 테스트 데이터가 필요했어요. 그래서 데이터베이스에 테스트 데이터를 세팅하는 SQL을 25줄 정도 작성하고, 그 SQL을 실행해 테스트 데이터를 알려진 상태로 되돌리는 엔드포인트를 개발 서버에 추가했어요.
async function reset() {
return fetch('/api/reset_test_data', {method: "POST"})
}그러면 테스트 데이터가 필요한 테스트 시작 부분에서 그냥 await reset()을 실행하면 돼요.
사실 제 reset() 함수는 항상 모든 걸 완전히 초기화하지는 않아서 좀 아쉽지만, 시작하기에는 충분히 쓸 만했고 나중에 언제든 개선할 수 있어요.
3단계: 기본 테스트
기본 테스트는 이렇게 생겼어요! 기본적으로 div를 렌더링하고 대략 맞는 데이터가 들어 있는지 확인하는 거예요.
QUnit.test('renders feedback content', async function (assert) {
const {div} = mountComponent(
'<Page :feedbacks="feedbacks" id=2 image=2 page_hash=2 />',
{feedbacks: [testFeedback]},
);
assert.ok(div.textContent.includes('loved this section'));
})이게 기본 구성 요소의 전부예요! 이제 과정에서 부딪혔던 몇 가지 문제들을 이야기해 볼게요.
페이지 일부가 렌더링되기를 기다리기
제 테스트에는 네트워크 요청이 많아서, 요청이 끝나고 Vue 코드가 결과를 처리해 DOM을 업데이트하기까지 시간이 좀 걸려요.
테스트에 아무렇게나 sleep()을 넣고 타이밍이 맞기를 바라는 게 느리고 불안정하며 엄청나게 답답하다는 건 우리 모두 오래전에 배웠잖아요. 그래서 다른 방법이 필요했어요.
제가 알기로는 일반적인 해결책은 DOM을 보고 진행해도 되는지 판단할 방법을 찾는 거예요. 예를 들어 “이 버튼이 보이면 진행해도 된다” 같은 식이죠.
그래서 20ms마다 조건이 충족됐는지 폴링하는 작은 waitFor() 함수를 만들었어요. 2초가 지나면 타임아웃돼요.
사용하는 모습은 이렇게 생겼어요:
QUnit.test("click item", async function (assert) {
const {div} = mountComponent(
'<Feedback zine_id="test123" image_width="800px" />',
{});
const item = await waitFor(() => div.querySelector('.feedback-item'));
item.click();
// rest of test goes here...
})찾아보니 이 개념을 구현한 것들이 이미 많이 있고, 전부 제 것보다 훨씬 더 잘 고민된 것 같더라고요. (간단히 구글링해 보니: qunit-wait-for, playwright expect.poll)
무엇을 기다려야 할지 파악하는 건 간단하지 않아요
어떤 경우에는 DOM에서 기다려야 할 대상을 제대로 찾았다고 생각했는데(“이 textarea만 나타나면 되지!”), 프로그램 내부 동작 때문에 사실은 나중에 일어나는 다른 무언가를 기다려야 했고 그걸 특정하기가 어려웠어요.
결국 컴포넌트 하나를 수정해서 중요한 동작이 끝났을 때 DOM에 임의의 값을 추가하도록 했어요(예를 들어 data-this-thing-is-ready=true 같은 식으로요). 썩 마음에 드는 방법은 아니었어요.
이런 테스트 문제를 제대로 해결하는 방법은 앱을 사용자 입장에서도 더 안정적으로 만드는 리팩터링일 거라고 생각해요. DOM에 있는 요소가 실제로 사용자가 상호작용할 준비가 안 됐다면, 아직 보여주지 않는 게 맞을지도 모르니까요!
무언가를 식별하려고 CSS 클래스 추가하기 (과연 맞는 방법일까요?)
테스트에서 찾아야 하는 HTML 요소에 클래스를 몇 개 추가하게 됐어요. 클릭해야 하거나 DOM에 나타날 때까지 기다려야 하는 요소들이었죠.
나중에는 이 방식을 바꾸고 싶어요. 프론트엔드 테스트 프레임워크들은 CSS 클래스를 사용하는 걸 피하고 대신 getByRole 같은 방법을, 최후의 수단으로는 data-testid 같은 걸 사용하라고 권장하더라고요. 앱의 접근성을 높이면서 테스트도 더 쉽게 만들 수 있는 방법이 있을 것 같은 느낌이에요.
폼 채우기는 까다로워요
폼을 채울 때는 value만 설정해서는 안 되고, Vue에 요소가 변경됐다고 알려 주는 이벤트도 디스패치해야 해요. 예를 들어 checkbox와 textarea는 서로 다른 종류의 이벤트가 필요하거든요.
textarea.value = 'banana banana banana';
textarea.dispatchEvent(new Event('input'));checkbox.checked = true;
checkbox.dispatchEvent(new Event('change'));이게 좀 번거로운데, 그래서 UI 테스트 라이브러리를 쓰고 싶어질 수도 있겠다고 느꼈어요. 예를 들면:
테스트 커버리지
테스트 커버리지가 어느 정도인지 알고 싶었는데, 알고 보니 Chrome에 JS와 CSS를 위한 코드 커버리지 기능이 내장돼 있더라고요!
제 JS는 esbuild로 bundle.js라는 파일 하나로 번들링되어 있어서, 그냥 bundle.js를 보고 어떤 줄이 커버되지 않았는지 확인할 수 있었어요.
과정은 좀 까다로웠어요. 동작하게 하려면 Chrome 개발자 도구에서 소스맵을 꺼야 했고, 커버리지 데이터를 보려면 그다지 직관적이지 않은 특정 순서대로 조작해야 하더라고요.
정말 재미있었어요!
이 시리즈의 다른 글들과 마찬가지로, 저는 (제 자신을 위해서가 아니면) 프론트엔드나 백엔드 개발자로 제대로 일해 본 적이 없어서, 아주 기초적인 작업들을 하는 방법을 계속 배우고 있는 것 같은 기분이에요.
이번 작업은 정말 신나게 했어요. 제 프론트엔드 프로젝트들은 테스트가 없으니 항상 뭔가 불안하게 느껴졌는데, 언젠가는 자신 있게 내세울 수 있는 테스트 스위트를 갖게 될지도 모르겠네요!
아직 고민 중인 것들도 있어요:
- 이 글을 쓰는 동안 Testing Library라는 프론트엔드 테스트 라이브러리를 찾았는데, 테스트 작성법에 대한 가이드라인이 제 초기 아이디어와는 많이 다르더라고요. 모든 걸 Testing Library를 쓰도록 다시 작성해 봤는데 느낌이 꽤 괜찮았어요. 앞으로 어떻게 될지 지켜봐야겠죠. 이 라이브러리는 Node 없이 동작하는
.umd.js파일을 배포하고 있어요. - 이 테스트들을 커맨드라인에서 전혀 실행할 방법이 없다는 점에 대해서는 아직 어떻게 생각해야 할지 모르겠어요. 주로 브라우저에서 작업하면서도, 원하면 CI에서도 실행할 수 있는 간단한 방법이 있을지도 모르죠?
글을 무작위로 읽기