JSDOM은 진짜 DOM이 아니다


필자는 현재 NHN Cloud Console 서비스의 프론트엔드 코드 개발을 담당하고 있고, 프론트엔드 코드의 안전한 리팩토링을 위해 통합 테스트를 진행하고 있다. 현재 테스트는 Node.js 환경에서 Testing Library를 이용하여 진행되며, DOM을 확인하기 위해서 JSDOM을 사용하고 있다. 이 과정에서 발생한 문제를 공유하고자 한다.

팝업 동작 테스트에서 오류 발생

필자는 NHN Cloud Console의 사용자 관리 페이지에서 팝업 UI 동작을 테스트하고 있었다.

modal

파트너 콘솔에서 사용자의 가입 또는 해지 신청을 거부할 때 이 팝업을 사용한다. 팝업을 열고 입력란에 텍스트를 입력 후 저장 버튼을 누르면 팝업이 닫히고 사용자의 상태가 변경되는지를 테스트하는 코드를 작성했지만, 이 테스트가 통과하지 못하는 상황이 발생했다.

test_fail

Node.js 환경에서 테스트는 어떻게 실패했는가

터미널에서 무엇이 실패의 원인이었는지 확인해보니 아래 구간에서 문제가 발생한 것을 알 수 있었다.

test_fail_log

테스트에서 해당 단언이 만족해야 다음으로 계속 진행하는데 여기서 막힌 모습이다. screen.queryByRole('dialog')는 팝업 요소를 가져오는 함수인데 팝업이 문서에 존재하지 않아야 하는 조건을 만족하지 않아 테스트가 실패했다. 테스트가 실패하면 터미널에서는 이 상황에서 페이지의 HTML 코드를 모두 출력해서 보여준다. 하지만 단순히 코드를 보는 것만으로는 상황을 파악하기가 어렵다. 그래서 실패한 상황을 편하게 분석하기 위해 screen.logTestingPlaygroundURL() 함수를 사용해 보았다. screen.logTestingPlaygroundURL()는 Testing Library가 제공하는 함수로써 테스트에서 이 함수가 호출되는 시점에 문서 내에 어떤 요소를 그리고 있는지 직접 확인할 수 있도록 페이지 URL을 제공한다.

testing_playground

함수를 호출하고 제공받은 URL을 통해 페이지에 접속하면 그림의 맨 아래와 같이 입력란과 저장 버튼이 있는 팝업 요소가 닫히지 않고 살아있다는 것을 확인할 수 있었다. 왜 팝업이 닫히지 않은 걸까? 저장 버튼을 누르면 VeeValidate를 사용해서 입력란에 있는 텍스트에 대해서 유효성 검사를 하는데, 이를 통과하지 못했기 때문이다. 유효성 검사를 통과하지 못하면 입력란 아래에 경고 문구가 나타나고 팝업은 닫히지 않는다. screen.logTestingPlaygroundURL()의 결과에서도 경고 문구가 나타난 걸 확인할 수 있다.

브라우저 환경에서 직접 테스트는 성공

하지만 Node.js 환경이 아닌 브라우저에서 직접 개발 중인 페이지에 접속해서 팝업을 띄우고 동작을 실험해본 결과 매번 팝업은 잘 닫혔고 팝업이 닫히지 않는 문제는 발견하지 못했다.

browser_test

브라우저와 Node.js 비교

1. VeeValidate

Node.js 환경이 브라우저 환경과 다르다는 것을 알았으니 정확히 어디서 차이가 발생했는지를 찾아낼 필요가 있었다. 브라우저에서는 유효성 검사를 통과하지만 Node.js 환경의 테스트에서는 유효성 검사에 실패하였으니 이를 담당하는 VeeValidate 도구를 디버깅해보기로 했다. 팝업에서 입력란을 처리하는 템플릿 코드는 textareaVeeValidate의 'required' 규칙(v-validate="'required'")을 추가해서 입력란에 공백을 제외하고 텍스트가 있을 경우 유효성 검사를 통과하도록 작성되어 있다.

<textarea
  ...
  v-validate="'required'"
  ...
></textarea>

먼저 VeeValidate의 기능을 담당하는 vee-validate.js 파일에서 검증을 시도하는 코드에 로그를 남겨서 어떤 규칙이 적용되고 있는지를 살펴보았다.

브라우저 환경 Node.js 환경
vee_validate_rules_browser vee_validate_rules_nodejs

브라우저 환경에서는 의도한대로 required 규칙 하나를 검증하고 있는데, Node.js 환경의 테스트에서는 required 규칙 외에 max 규칙이 추가된 것을 발견했다. 의도하지 않은 max 규칙은 어떤 상황에서 추가될까? VeeValidate 도구가 입력란에 규칙을 추가하는 상황을 정리하면 다음과 같다.

  1. 입력란 태그의 v-validate 디렉티브에 전달한 값을 가져와서 규칙으로 추가한다.
  2. v-validate 디렉티브 외에 type처럼 입력란에 들어갈 텍스트를 제한하는 속성을 읽고 자동으로 규칙을 추가한다.

max 규칙이 들어간 것은 규칙을 추가하는 상황 중 두 번째에 해당하고 이때 textareamaxLength 프로퍼티 값을 보고 판단하는 것을 코드를 통해 알 수 있었다.

// vee-validate.js
var fillRulesFromElement = function (el, rules) {
  ...
  // 524288 is the max on some browsers and test environments.
  if (el.maxLength >= 0 && el.maxLength < 524288) {
    rules = appendRule(("max:" + (el.maxLength)), rules);
  }
  ...

그렇다면 이제 브라우저와 Node.js 환경의 테스트에서 이 프로퍼티 값은 어떻게 나오는지 확인해야 할 차례이다. textareamaxLength 프로퍼티 값을 로그로 남기고 테스트를 실행한 결과는 다음과 같았다.

브라우저 환경 Node.js 환경
maxLength_browser maxLength_nodejs

브라우저 환경에서는 textarea 요소의 maxLength 프로퍼티 값이 -1이지만, Node.js 환경의 테스트에서는 -1이 아닌 0이었다.

2. JSDOM

브라우저와 다르게 Node.js 환경은 기본적으로 DOM이 존재하지 않기 때문에 테스트에서 textarea와 같은 DOM 요소를 사용하기 위해서 JSDOM과 같은 도구를 활용한다. JSDOM에서 사용하는 textarea 구현체는 maxLength 프로퍼티를 갖게 되지만 DOM 요소의 속성을 찾아서 없으면 최종적으로 0을 반환하도록 설정된 것을 확인했다.

// HTMLtextareaElement.js
get maxLength() {
  ...
  let value = esValue[implSymbol].getAttributeNS(null, 'maxLength')
  if (value === null) {
    return 0
  }
  ...

왜 JSDOM은 브라우저의 DOM과 다르게 maxLength 프로퍼티 값을 0으로 반환하고 있을까? JSDOM GitHub 이슈 페이지에서 같은 문제를 제기한 글을 찾았고 여기서 개발자의 답변도 확인할 수 있었다.

github_issue_comment

IDL은 인터페이스 정의 언어의 줄임말이다. JSDOM은 내부에서 IDL을 사용해서 DOM 인터페이스를 정의하고 있고 이를 적용한 구현체를 생성해서 Node.js 환경의 테스트에서 사용하고 있다. JSDOM 개발자는 maxLength 명세 일부를 인용하면서 0으로 반환하는 문제는 JSDOM의 버그가 맞고 새로운 반영 기반을 적용하는 것으로 이 문제를 해결할 수 있다고 댓글을 남겼다. 함께 인용된 공통 DOM 인터페이스 명세 일부를 살펴보면 여기에는 signed integer 타입을 갖는 IDL 속성을 구현하는 것을 일반적인 경우와 음이 아닌 정수로 제한한 경우로 나누어 정의하고 있다.

반영하는 IDL 속성이 부호 있는 정수 타입(long)일 경우, 속성을 가져올 때는 부호 있는 정수를 구문 분석하는 규칙에 따라서 콘텐츠 속성을 구문 분석해야 하며, 이를 성공하고 그 값이 IDL 속성의 타입 범위 내에 있으면 결괏값을 반환해야 합니다. 반면에 실패하거나, 범위를 벗어난 값을 반환하거나, 또는 속성이 없으면 기본값을 대신 반환해야 하며, 기본값이 없는 경우 0을 반환해야 합니다. 속성을 설정할 때는 주어진 값을 유효한 정수로 숫자를 나타내는 가장 짧은 문자열로 변환한 다음 해당 문자열을 새 콘텐츠 속성값으로 사용해야 합니다.

반영하는 IDL 속성이 음이 아닌 수로 제한되는 부호 있는 정수 타입(long)을 가질 경우, 속성을 가져올 때는 음이 아닌 정수를 구문 분석하는 규칙에 따라서 콘텐츠 속성을 구문 분석해야 하며, 이를 성공하고 그 값이 IDL 속성의 타입 범위 내에 있으면 결괏값을 반환해야 합니다. 반면에 실패하거나, 범위를 벗어난 값을 반환하거나, 또는 속성이 없으면 기본값을 대신 반환해야 하며, 기본값이 없는 경우 -1을 반환해야 합니다. 속성을 설정할 때는 주어진 값이 음수이면 사용자 에이전트는 "IndexSizeError" DOMException 오류를 발생시켜야 합니다. 주어진 값이 음수가 아니면 유효한 정수로 숫자를 나타내는 가장 짧은 문자열로 변환한 다음 해당 문자열을 새 콘텐츠 속성값으로 사용해야 합니다.

두 가지 명세는 프로퍼티를 가져올 때 DOM 요소에 지정한 속성이 있고 그 값이 조건에 맞으면 구문 분석 후 정수로 반환하고, 속성이 없거나 값이 조건에 맞지 않으면 기본값을 반환한다는 것까지는 동일하다. 그러나 기본값이 없으면 반환하는 값이 각각 0과 -1로 차이가 있다. maxLength IDL 속성은 음이 아닌 정수로 제한되기 때문에 둘 중 아래의 명세를 따르는 것이 맞다. 브라우저 환경은 textarea에 속성을 지정하지 않으면 -1을 반환하는 것으로 보아 이를 잘 따르는 것으로 보인다. 반면 JSDOM에서 구현체를 생성하는 스크립트를 보면 음이 아닌 정수로 제한한 경우의 명세가 조건으로 구현되어 있지 않았고 일반적일 때의 명세를 따라 textarea가 구현된 것을 확인할 수 있었다. 브라우저 환경과 Node.js 환경에서 테스트의 결과가 왜 달라졌는지 근본 원인을 조사한 결과 필자는 각 환경에서 DOM 요소가 명세에 맞게 구현이 되고 있는지 없는지의 차이로부터 나왔다는 결론을 내렸다.

지금까지 원인을 분석한 내용을 정리하면 다음과 같다.

  • JSDOM 도구를 통해 Node.js에서 구현된 DOM 요소는 명세에 맞게 구현되지 않아 브라우저 환경의 DOM 요소와 모두 동일한 프로퍼티 값을 갖지 않았다.
  • 텍스트의 입력이 유효한지 검증하는 VeeValidate 도구는 DOM 요소의 프로퍼티를 이용해서 자동으로 검증 규칙을 추가한다.
  • 이때 Node.js 환경의 테스트에서 maxLength의 프로퍼티 값이 0이었기 때문에 입력란에 쓸 수 있는 텍스트의 최대 길이는 0으로 제한되었다.
  • 결국 유효성 검증에 실패하면서 테스트 또한 실패하게 되었다.

문제 해결

입력란의 텍스트를 VeeValidate 도구로 검증할 때 직접 max 규칙을 추가하여 텍스트의 최대 길이를 검증하면 환경과 관계없이 동일한 유효성 검사를 만들 수 있다. 이를 활용해 v-validate 디렉티브에 max:Infinity를 추가하면 현재 JSDOM으로 인해 발생하는 문제를 해결할 수 있다.

<textarea
  ...
  v-validate="'required|max:Infinity'"
  ...
></textarea>

마치며

일반적으로 JSDOM을 활용한 통합 테스트는 실제 브라우저 환경보다 빠르게 UI 테스트를 진행할 수 있다는 장점이 있다. 하지만 JSDOM은 어디까지나 DOM을 흉내 낸 객체에 불과하다. 브라우저 환경에서 적용되었지만 JSDOM에서 반영하지 못한 요구사항을 활용하는 코드나 라이브러리가 있다면 Node.js 환경과 브라우저 환경에서 테스트는 다르게 동작할 수 있다. 크게 문제가 없는 통합 테스트의 결과가 브라우저와 다르면 때로는 JSDOM에서 DOM 요소의 처리를 어떻게 하는지 의심해볼 필요가 있을 것이다.

김진배2022.06.24
Back to list