Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

Mocha v8.0.0의 병렬 테스트

Mocha v8.0.0의 병렬 테스트

이 글은 IBM 개발자 사이트에 Mocha의 리드 메인테이너인 Christopher Hiller가 Mocha V8에 추가된 병렬 테스트 모드에 대해서 소개한 글을 번역한 글입니다. 번역은 IBM과 Christopher Hiller의 허락을 받아 진행했습니다.


Mocha는 v8.0.0 릴리스에서 Node.js에서 병렬 모드를 지원하기 시작했다. Mocha로 병렬 모드로 테스트를 실행하면 멀티코어 CPU의 이점을 얻어서 대규모 테스트 스위트에서 속도를 크게 향상할 수 있다.

Mocha 문서의 병렬 테스트 부분을 읽어보길 바란다.

v8.0.0 이전까지 Mocha는 직렬로만 테스트를 실행했다. 다음 테스트로 넘어가기 전에 테스트가 반드시 종료되어야 한다. 적은 수의 테스트 스위트에서는 결정적이고 빠르다는 장점은 있지만, 대량의 테스트를 실행할 때는 병목이 될 수 있다.

실제 프로젝트에서 어떻게 Mocha의 병렬 모드를 사용해서 장점을 얻을 수 있는지 살펴보자.

설치

Node.js v8.0.0의 생명주기가 끝났으므로 Mocha v8.0.0는 Node.js v10, v12, v14가 필요하다.

Mocha 자체를 꼭 설치할 필요는 없지만, 원하면 해도 된다. Mocha v8.0.0 이상의 버전이 필요한데 다음과 같이 설치할 수 있다.

npm i mocha@8 --save-dev


--parallel 플래그의 사용

많은 경우 mocha를 실행할 때 --parallel 지정하면 병렬 모드를 활성화 할 수 있다.

mocha --parallel test/*.spec.js

아니면 Mocha의 설정 파일을 사용해서 명령행 플래그를 지정할 수도 있다. Mocha는 기본 설정인 .mocharc.yml YAML 파일을 가지고 있고 다음과 같이 생겼다.(간결하게 일부는 제외했다.)

# .mocharc.yml
require: 'test/setup'
ui: 'bdd'
timeout: 300

병렬 모드를 활성화하려고 이 파일에 parallel: true를 추가할 것이다.

# .mocharc.yml w/ parallel mode enabled
require: 'test/setup'
ui: 'bdd'
timeout: 300
parallel: true

이후 예제에서는 명확하게 --parallel--no-parallel를 사용한다.

npm test를 실행하고 무슨 일이 벌어지는지 보자!

스포일러: 처음엔 동작하지 않는다

앗! 유닛테스트에서 기본 timeout 값(위에 나왔던 300ms)을 사용한 "timeout" 예외가 다수 발생했다.

  2) Mocha
       "before each" hook for "should return the Mocha instance":
     Error: Timeout of 300ms exceeded. For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves. (/Users/boneskull/projects/mochajs/mocha/test/node-unit/mocha.spec.js)
      at Hook.Runnable._timeoutError (lib/runnable.js:425:10)
      at done (lib/runnable.js:299:18)
      at callFn (lib/runnable.js:380:7)
      at Hook.Runnable.run (lib/runnable.js:345:5)
      at next (lib/runner.js:475:10)
      at Immediate._onImmediate (lib/runner.js:520:5)
      at processImmediate (internal/timers.js:456:21)

이상한 결과가 나왔다. 다시 테스트를 실행하자 다른 테스트가 "timeout" 예외를 던졌다. 왜 그런가?

Mocha에서부터 Node.js, OS, CPU에 걸친 다양한 변수로 인해 병렬 모드는 주어진 테스트에 대해 훨씬 더 넓은 범위의 타이밍을 보여준다. 이러한 timeout 예외는 새로운 성능 이슈를 보여주는 것이 아니라 자연스럽게 더 높아진 시스템 부하와 비결정적 실행 순서의 증상이다.

이를 해결하기 위해 기본 테스트 타임 만료 시간을 300ms(0.3초)에서 1,000ms(1초)로 올렸다.

# .mocharc.yml
# ...
timeout: 1000

Mocha의 "timeout" 기능은 벤치마킹용이 아니라 의도치 않게 실행에 오래 걸리는 코드를 잡아내기 위해 만들어진 것이다. 테스트 실행이 더 오래 걸릴 수 있음으로 안심하고 timeout 값을 증가시킬 수 있다.

이제 테스트가 성공했으므로 더 많은 테스트를 성공시키려고 한다.

병렬 모드 최적화

기본적으로 Mocha의 최대 잡 개수는 n-1이고 여기서 n은 기기의 CPU 코어 수이다. 이 기본값이 모든 프로젝트의 최적값은 아닐 것이다. 잡 개수도 운영체제에 따라 다르므로 "Mocha가 n-1 CPU 코어를 사용한다"를 의미하지는 않는다. 하지만, 이 값이 기본값이고 보통 기본값이 하는 일을 한다.

"최대 잡 개수"를 언급할 때 이는 Mocha가 필요하다면 이 개수만큼의 워커 프로세스를 생성할 수 있고 이는 테스트 파일의 수와 실행 시간에 따라 다르다는 의미이다.

성능을 비교해 보기 위해 익숙한 벤치마크 도구인 hyperfine을 사용할 것이다. 이를 통해 다양한 설정에 어떻게 동작하는지 알 수 있을 것이다.

hyperfine 사용법에 따라 아래 예제에서 hyperfine에 두 가지 옵션을 전달하고 있다. -r 5는 명령어를 5번 실행하라는 의미이고 기본값은 10이지만 이는 기다리기에 너무 느리다. 지정한 두 번째 옵션은 --warmup 1인데 이는 한 번의 "warmup" 실행을 뜻한다. 이 실행 결과는 버려진다.

warmup 실행은 첫 k번의 실행이 이어진 실행보다 확연히 느려서 최종 결과를 왜곡하는 것을 줄여준다. 이런 일이 벌어지면 hyperfine이 이에 관해 경고해 줄 것이다. 그래서 여기서 이 옵션을 사용하는 것이다!

직접 실행해 보려면 환경에 따라 bin/mochanode_modules/.bin/mochamocha로 바꿔야 한다. bin/mocha는 작업 폴더에서 mocha 실행 파일의 상대 경로이다.

Mocha의 통합 테스트(약 55개 파일의 260 테스트)는 보통 mocha 실행 파일의 출력을 assertion 한다. 유닛테스트보다 더 긴 timeout 값이 필요하고 아래에서 10초의 timeout을 사용한다.

직렬 모드로 통합 테스트를 실행했다. 말도 안 되는 속도에 아무도 불만을 얘기하지 않았다.

$ hyperfine -r 5 --warmup 1 "bin/mocha --no-parallel --timeout 10s test/integration/**/*.spec.js"
Benchmark #1: bin/mocha --no-parallel --timeout 10s test/integration/**/*.spec.js
  Time (mean ± σ):     141.873 s ±  0.315 s    [User: 72.444 s, System: 14.836 s]
  Range (min … max):   141.447 s … 142.296 s    5 runs

2분 이상이 걸렸다. 이를 병렬 모드로 실행해 보자. 내 환경에서는 8코어 CPU(n = 8)를 쓰고 있음으로 Mocha는 기본적으로 7개의 워커 프로세스를 사용한다.

$ hyperfine -r 5 --warmup 1 "bin/mocha --parallel --timeout 10s test/integration/**/*.spec.js"
Benchmark #1: bin/mocha --parallel --timeout 10s test/integration/**/*.spec.js
  Time (mean ± σ):     65.235 s ±  0.191 s    [User: 78.302 s, System: 16.523 s]
  Range (min … max):   65.002 s … 65.450 s    5 runs

병렬 모드를 사용해서 1분 이상인 76초를 줄였다. 거의 53%의 속도 향상이 있었다. 여기서 다 향상할 수 있는가?

Mocha가 얼마나 많은 워커 프로세스를 사용할지 정확히 지정하려고 --jobs/-j 옵션을 사용할 수 있다. 이 숫자를 4로 줄이면 무슨 일이 벌어지는지 살펴보자.

$ hyperfine -r 5 --warmup 1 "bin/mocha --parallel --jobs 4 --timeout 10s test/integration/**/*.spec.js"
Benchmark #1: bin/mocha --parallel --jobs 4 --timeout 10s test/integration/**/*.spec.js
  Time (mean ± σ):     69.764 s ±  0.512 s    [User: 79.176 s, System: 16.774 s]
  Range (min … max):   69.290 s … 70.597 s    5 runs

안타깝게도 더 느려졌다. 대신 이 숫자를 늘리면 어떻게 되는지 살펴보자.

$ hyperfine -r 5 --warmup 1 "bin/mocha --parallel --jobs 12 --timeout 10s test/integration/**/*.spec.js"
Benchmark #1: bin/mocha --parallel --jobs 12 --timeout 10s test/integration/**/*.spec.js
  Time (mean ± σ):     64.175 s ±  0.248 s    [User: 80.611 s, System: 17.109 s]
  Range (min … max):   63.809 s … 64.400 s    5 runs

기본값인 7보다는 12로 지정했을 때 약간 빨라졌다. 테스트 환경이 8코어임을 생각해 봐라. 왜 더 많은 프로세스를 만드니까 성능이 향상되었을까?

이는 테스트가 CPU에 의존하지 않기 때문으로 추측하고 있다. 테스트는 대부분 비동기 I/O를 수행하고 있음으로 CPU는 테스트를 완료되기를 기다리는 여유 사이클을 가지게 된다. 이 테스트의 500ms를 더 줄이려고 시간을 들일 수 있지만, 이 글의 목적은 아니다. 완벽함은 좋음의 적이지 않은가? 핵심은 각자의 프로젝트에 이 전력을 어떻게 적용하고 만족할 설정이 무엇인지 보여주는 것이다.

언제 병렬 모드를 피해야 하는가?

테스트를 항상 병렬로 돌리는 게 적절치 않는다고 말한다면 놀랄 것인가? 병렬 모드가 항상 좋은 것은 아니다. 두 가지를 이해해야 한다.

  1. Mocha는 개별 테스트를 병렬로 실행하지 않는다. Mocha는 테스트 파일을 병렬로 실행한다.
  2. 워커 프로세스를 생성하는 것은 공짜가 아니다.

즉 하나의 Mocha 테스트 파일이 있다면 하나의 워커 프로세스를 생성하고 이 프로세스가 파일을 실행할 것이다. 테스트 파일이 딱 하나만 있다면 병렬 모드에서 불이익을 받게 됩니다. 이렇게 하면 안 된다.

"하나의 파일"을 사용하는 일반적이지 않은 경우 외에도 테스트나 소스의 고유한 특정이 결과에 영향을 미칠 것입니다. 직렬로 테스트를 실행하는 것보다 병렬로 실행하는 것이 느린 변곡점이 있다.

실제로 Mocha 자체의 테스트(약 35개 파일의 740개 테스트)가 좋은 예시입니다. 좋은 유닛 테스트와 마찬가지로 I/O 없이 독립적으로 빠르게 실행하려고 한다. 기준선을 위해 직렬로 Mocha의 유닛테스트를 실행했다.

$ hyperfine -r 5 --warmup 1 "bin/mocha --no-parallel test/*unit/**/*.spec.js"
Benchmark #1: bin/mocha --no-parallel test/*unit/**/*.spec.js
  Time (mean ± σ):      1.262 s ±  0.026 s    [User: 1.286 s, System: 0.145 s]
  Range (min … max):    1.239 s …  1.297 s    5 runs

이제 병렬로 실행해 보자. 내 기대와 달리 결과는 다음과 같이 나왔다.

$ hyperfine -r 5 --warmup 1 "bin/mocha --parallel test/*unit/**/*.spec.js"
Benchmark #1: bin/mocha --parallel test/*unit/**/*.spec.js
  Time (mean ± σ):      1.718 s ±  0.023 s    [User: 3.443 s, System: 0.619 s]
  Range (min … max):    1.686 s …  1.747 s    5 runs

객관적으로 Mocha의 유닛 테스트를 병렬로 실행하는 것이 약 0.5초 느리다. 이는 워커 프로세스를 생성하는 부하이다.(그리고 프로세스 간의 통신을 위한 직렬화도 필요하다.)

매우 빠른 유닛 테스트를 가진 다수의 프로젝트는 Mocha의 병렬 모드의 혜택을 받지 못할 것으로 예상한다.

앞에서 작성한 .mocharc.yml를 기억하는가? 설정 파일에서 parallel: true를 제거했다. 대신 Mocha는 Mocha의 통합 테스트를 실행할 때만 병렬 모드를 사용한다.

보통 이러한 형식의 테스트와 맞지 않는 것 외에도 병렬 모드는 다른 제약사항이 있다. 아래에서 이에 관해 얘기하겠다.

주의 사항, 고지 사항, 발견한 내용

기술적인 제약(예: "이유")때문에 몇몇 기능은 병렬 모드와 호환되지 않는다. 이를 사용하면 Mocha가 예외를 던질 것이다.

추가적인 정보와 (있다면)우회방법은 문서를 확인해 봐라.

지원하지 않는 보고서

markdown, progress, json-stream 보고서를 사용하고 있다면 병렬 모드에서는 사용할 수 없다. 이 보고서들은 얼마나 많은 테스트를 실행할지 미리 알아야 한다. 병렬 모드는 이러한 정보를 가지고 있지 않다.

나중에는 달라질 수 있지만 이러한 보고서는 하위호환이 안 되는 변경을 하게 될 것이다.

단독 테스트

단독 테스트(.only())는 동작하지 않는다. 단독 테스트를 사용하면 Mocha는 .only()를 사용한 곳까지는 테스트를(.only()를 사용하지 않았으므로)를 실행하고 .only()를 만나면 중단하고 실패할 것이다.

단독 테스트는 보통 하나의 파일에서 하나의 파일에서 사용되므로 병렬 모드는 이 상황에 적합하지 않다.

지원하지 않는 옵션

--sort, --delay 옵션 특히 --file 옵션은 호환되지 않는다. 간단히 말하면 테스트를 지정한 순서로 실행할 수 없기 때문이다.

이 옵션 중 --file이 아마 가장 많은 수의 프로젝트에 영향을 줄 것이다. Mocha v8.0.0 이전에는 "루트 훅"을 정의할 때 --file을 사용하도록 추천했다. 루트 훅(beforeEach(), after(), setup() 등과 같은)은 다른 모든 파일이 상속받는다. 이 접근은 루트 훅을 이 파일에 정의하는 것으로 예를 들어 hooks.js로 Mocha를 다음과 같이 실행한다.

mocha --file hooks.js "test/**/*.spec.js"

모든 --file 파라미터는 테스트 파일로 간주하여 다른 테스트 파일(이 경우에는 test/**/*.spec.js)보다 먼저 실행될 것이다. 이를 보장해주므로 Mocha는 hooks.js에 정의된 훅으로 "부트스트랩"을 수행하고 이는 이어진 모든 테스트 파일에 영향을 준다.

이는 Mocha v8.0.0에서도 여전히 동작하지만, 직렬 모드에서만 동작한다. 하지만! 강력히 사용하지 말기를 권한다.(결국은 완전히 폐기될 것이다.) 대신 Mocha는 Root Hook 플러그인을 도입했다.

루트 훅 플러그인

루트 훅 플러그인은 mochaHooks라는 이름으로 익스포트 된 모듈(CJS나 ESM)로 이 모듈에서 사용자가 자유롭게 훅을 정의할 수 있다. 루트 훅 플러그인 모듈은 Mocha의 --require 옵션으로 로드된다.

루트 훅 플러그인 사용에 관한 문서를 읽어봐라.

위에 링크한 문서에 자세한 설명과 예시가 있지만 여기서 간단히 설명하겠다.

--file hooks.js로 로드되는 루트 훅을 가진 프로젝트가 있다고 해보자.

// hooks.js
beforeEach(function() {
  // do something before every test
  this.timeout(5000); // trivial example
});

이를 루트 훅 플러그인으로 바꾸려면 hooks.js를 다음과 같이 바꾼다.

// hooks.js
exports.mochaHooks = {
  beforeEach() {
    this.timeout(5000);
  }
};

팁: 이를 ESM 모듈로 작성할 수도 있다. 예를 들면 hooks.mjs로 만들어서 네임드 익스포트 mochaHooks를 사용하면 된다.

mocha를 실행할 때 --file hooks.js--require hooks.js로 바꾸면 된다. 간단하다!

병렬 모드 트러블 슈팅

많은 프로젝트에서 병렬 모드가 잘 동작하지만, 문제를 겪고 있다면 테스트를 준비하면서 다음 체크리스트를 참고해라.

  • 지원하는 보고서를 사용하는지 확인.
  • 지원하지 않는 플래그를 사용하지 않는지 확인.
  • 설정 파일을 다시 확인. 설정 파일에 설정된 옵션은 다른 커맨드라인 옵션과 합쳐진다.
  • ✅ 테스트에서 루트 훅을 찾는다.(문서에 나온 형태이다.) 이를 루트 훅 플러그인으로 바꾼다.
  • ✅ 사용하는 assertion, mock, 그 외 테스트 라이브러리에서 루트 훅을 사용하는가? 병렬 모드와 호환되려면 마이그레이션을 해야 한다.
  • ✅ 테스트가 예상과 달리 타임 만료가 된다면 기본 테스트 타임 만료 시간을 늘려야 할 것이다. (--timeout 사용)
  • ✅ 테스트가 특정 순서로 실행되지 않아도 괜찮은지 확인
  • ✅ 테스트가 스스로 정리하도록 해라. 임시 파일, 핸들, 소켓 등 삭제하고 테스트 파일 간에 상태나 리소스를 공유하면 안 된다.

다음은?

병렬 모드는 새 기능이고 아직 완전하지 않다. 개선할 부분이 많이 있지만 이를 위해서 Mocha는 여러분의 도움이 필요합니다. 피드백을 Mocha 팀에 보내주세요. Mocha v8.0.0을 사용해서 병렬 모드를 활성화하고 루트 훅 플러그인을 사용해 본 뒤 의견을 공유해 주세요.

2020/07/24 20:05 2020/07/24 20:05