Testing Vue components in the browser

Julia Evans

브라우저에서 Vue 컴포넌트 테스트하기

원문은 Julia Evans님이 에 게재했습니다. 이 블로그 구독하기

안녕하세요! 이곳에서 제가 오랫동안 진행해 온 프로젝트 중 하나는 Node나 다른 서버 JS 런타임을 사용하지 않고 프론트엔드 JavaScript를 작성하는 방법을 알아내는 것입니다.

프론트엔드 JS 프로젝트를 하면서 자주 부딪히는 문제 중 하나는 테스트를 어떻게 작성해야 할지 모르겠다는 점입니다. 예전에 Playwright를 써 본 적이 있는데, 매번 새로운 브라우저 프로세스를 띄워야 해서 느리고 다루기 번거롭게 느껴졌고, 테스트를 orchestration하려면 Node 코드가 필요했습니다.

결국 프론트엔드 코드는 그냥 테스트하지 않게 되는데, 기분이 썩 좋지는 않습니다. 보통 프로젝트를 자주 업데이트하지도 않아서 크게 문제가 되진 않지만, 좀 더 자신 있게 변경할 수 있으면 좋겠다는 생각은 늘 했습니다! 그래서 마음에 드는 프론트엔드 테스트 방법을 찾는 건 오랫동안 위시리스트에 있던 일이었습니다.

아이디어: 그냥 브라우저 탭에서 테스트 실행하기

얼마 전 Alex Chan이 이 시리즈의 이전 글에 대한 답글로 Testing JavaScript without a (third-party) framework라는 훌륭한 글을 썼습니다. 브라우저 페이지 안에서 동작하는 아주 작은 유닛 테스트 프레임워크를 만드는 방법을 설명하는 글이었습니다.

당시 그 글이 정말 마음에 들었지만, 유닛 테스트만 다루고 있어서 저는 Vue 컴포넌트를 위한 end-to-end 통합 테스트를 작성하고 싶었고, 그 방법을 알 수 없었습니다.

그러다 얼마 전 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 함수를 만들 수 있었습니다. 차이점은 다음과 같습니다:

  1. props로 사용할 추가 데이터를 선택적으로 전달할 수 있다.
  2. 컴포넌트를 테스트가 끝나면 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가 서버와 제대로 동작하는지 확인하기 위한 end-to-end 통합 테스트를 작성하는 것이었기 때문에, 데이터베이스에 테스트 데이터가 필요했습니다. 그래서 데이터베이스에 테스트 데이터를 설정하는 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에 알리기 위해 이벤트를 디스패치해야 합니다. 예를 들어 checkboxtextarea는 서로 다른 종류의 이벤트가 필요합니다.

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에서도 실행할 수 있는 간단한 방법이 있을지도 모르겠네요.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글