JSDOM 환경에서 AudioContext 통합 테스트 구축하기

뮤돔면·2025년 11월 16일

썸네일

@uturi/sonification을 정식 릴리즈하기 위해서는 이전보다 더 신뢰도 높은 테스트가 필요했습니다. 알파·베타 단계에서는 AudioContext 중심의 유닛 테스트와 브라우저 환경에서 workspace:를 활용한 수동 테스트만 진행했기 때문에 라이브러리 전체의 안정성을 확인하기에는 한계가 있었습니다. 정식 배포를 위해서는 최소한 전체 동작이 하나의 흐름으로 유기적으로 수행되는지 검증하는 단계가 필요했습니다.

물론 브라우저 환경을 그대로 재현하는 일은 E2E 테스트의 영역이지만 오디오 관련 동작이 기대한 순서와 인터랙션으로 실행되는지는 통합 테스트만으로도 충분히 확인할 수 있다는 판단이었습니다(브라우저 호환성 측면에서는 이후 E2E 테스트도 필요하다고 보고 있습니다).

이번 글에서는 JSDOM 환경에서 오디오 생성 통합 테스트를 구축하는 과정에서 겪은 문제들과 해결 방법을 공유해보려 합니다. 비슷한 고민을 하고 있는 분들께 현실적인 안내서가 되기를 기대합니다.

1차 시도: Polyfill을 활용한 환경 구축 실패

“음성 출력의 전체 동작을 수행할 브라우저가 없다면 브라우저를 흉내내면 되지 않을까?”라는 가장 단순한 접근부터 시작했습니다. 그래서 standardized-audio-context를 이용해 JSDOM 환경에 AudioContext, OscillatorNode, GainNode 등을 폴리필 형태로 주입했고, 실제 브라우저와 최대한 유사한 오디오 그래프를 구성하는 것을 목표로 삼았습니다.

👉 standardized-audio-context(NPM)

// vitest.setup.ts 중
import {
  AudioContext as PolyfillAudioContext,
  OscillatorNode as PolyfillOscillatorNode,
  GainNode as PolyfillGainNode,
} from 'standardized-audio-context';

(globalThis as any).AudioContext = PolyfillAudioContext;
(globalThis as any).OscillatorNode = PolyfillOscillatorNode;
(globalThis as any).GainNode = PolyfillGainNode;

단위 테스트와의 충돌

단위 테스트 환경에서는 AudioContext의 프로퍼티들을 Vitestvi.fn()로 직접 모킹하고 있었기 때문에 통합 테스트의 폴리필 방식과 충돌할 가능성이 있었습니다. 실제 별도 처리 없이 통합 테스트를 실행하니 통합 테스트를 위해 주입한 폴리필 객체들이 단위 테스트 환경의 모킹 함수들을 가려버렸고, 단위 테스트가 정상 동작하지 않았습니다.

간단하게 통합 테스트 코드에서 beforeAll, afterAll 을 활용해 폴리필 객체들을 주입하고 해제하도록 수정했고, 해당 처리는 별도의 파일, 별도의 함수로 관리하여 통합 테스트 환경에서만 적용될 수 있도록 수정하였습니다.

// tests/integration/integration.setup.ts 중
export function applyAudioPolyfill(): void {
  ...
  (globalThis as any).AudioContext = MockAudioContext;
  (globalThis as any).webkitAudioContext = MockAudioContext;
  (globalThis as any).AudioBuffer = MockAudioBuffer;
  (globalThis as any).AudioBufferSourceNode = MockAudioBufferSourceNode;
  (globalThis as any).OscillatorNode = MockOscillatorNode;
  (globalThis as any).GainNode = MockGainNode;
}

export function restoreAudioPolyfill(): void {
  ...
  delete (globalThis as any).AudioContext;
  delete (globalThis as any).AudioBuffer;
  delete (globalThis as any).AudioBufferSourceNode;
  delete (globalThis as any).OscillatorNode;
  delete (globalThis as any).GainNode;
  delete (window as any).AudioContext;
  delete (window as any).webkitAudioContext;
}

// tests/integration/Sonifier.integration.ts 중
describe('Sonifier Integration Test - Web Audio API Polyfill', () => {
  ...
  beforeAll(() => {
    applyAudioPolyfill();
  });
  afterAll(() => {
    restoreAudioPolyfill();
  });
  ...
});

존재하지 않는 생성자와 프로퍼티들

통합 테스트의 정상적인 동작을 기대한 것도 잠시, 통합 테스트를 실행하자 에러가 쏟아졌습니다.

Error: Missing the native AudioContext constructor. ❯ new AudioContext ../../node_modules/.pnpm/standardized-audio-context@25.3.77/node_modules/standardized-audio-context/build/es5/bundle.js:1046:19

standardized-audio-context가 JSDOM 환경에서도 동작한다고 파악했으나(라이브러리의 동작 범위는 커버 가능하다고 확인했었음), 실제로 생성자와 일부 메서드들이 존재하지 않았던 것입니다. 구체적으로 buffer.copyToChannel, audioContext.close 등 일부 핵심 프로퍼티가 확인되지 않아 테스트가 실패하고 있었습니다.

대안으로 web-audio-api 라이브러리를 추가로 활용해 standardized-audio-context가 제공하지 못하는 일부 프로퍼티를 포함해 native AudioContext를 사용할 수 있는 환경을 구축했습니다.

👉 web-audio-api(NPM)

// tests/integration/Sonifier.integration.ts 중
import { AudioContext as WebAudioApiAudioContext } from 'web-audio-api';

// Node 환경용 native AudioContext 먼저 설정
(globalThis as any).AudioContext = WebAudioApiAudioContext;
if (typeof window !== 'undefined') {
  (window as any).AudioContext = WebAudioApiAudioContext;
}

export async function applyAudioPolyfill(): Promise<void> {
  // sac의 객체들이 먼저 해석되어 web-audio-api의 폴리필과 충돌하는 상황을 회피하고자 동적으로 임포트 처리
  const sac = await import('standardized-audio-context');
  ...
}

그럼에도 통합 테스트는 여전히 빨간 에러를 내뱉고 있었습니다.

1. TypeError: this.audioContext.close is not a function 
2. Error: you need to set outStream to send the audio somewhere ❯ AudioInput.<anonymous> ../../node_modules/.pnpm/web-audio-api@0.2.2/node_modules/web-audio-api/build/AudioContext.js:64:36 ❯ AudioInput.proto$0.connect ../../node_modules/.pnpm/web-audio-api@0.2.2/node_modules/web-audio-api/build/audioports.js:26:10 ❯ AudioInput.proto$0.connect ../../node_modules/.pnpm/web-audio-api@0.2.2/node_modules/web-audio-api/build/audioports.js:81:31 ❯ AudioOutput.proto$0.connect ../../node_modules/.pnpm/web-audio-api@0.2.2/node_modules/web-audio-api/build/audioports.js:25:15 ❯ AudioBufferSourceNode.proto$0.connect ../../node_modules/.pnpm/web-audio-api@0.2.2/node_modules/web-audio-api/build/AudioNode.js:76:27 ❯ Sonifier.play src/core/Sonifier.ts:60:12 58| const source = audioContext.createBufferSource(); 59| source.buffer = audioBuffer; 60| source.connect(audioContext.destination); | ^ 61| source.start(); 62|

폴리필: 네이티브 API의 불완전한 구현체

계속된 삽질 끝에 결국 폴리필 만으로는 브라우저 환경을 완전히 대체하기 어렵다는 사실을 인정할 수밖에 없었습니다. JSDOM 위에서 AudioContext를 그대로 흉내내는 접근은 생각보다 훨씬 복잡하고 불안정한 전략이었고, 이 시도는 완벽한 실패였습니다.

근본적으로 생각해보면 통합 테스트가 꼭 브라우저와 유사한 환경 위에서 동작될 필요는 없었습니다. 브라우저를 재현하는 대신에 테스트가 가능한 형태로 오디오 동작을 통제할 수만 있다면 통합 테스트 환경이 구축될 수 있었습니다. (1)Sonifier의 내부 로직이 모두 실행되고, (2) 오디오 관련 객체들이 정상적으로 생성되며, (3) 파이프라인에 따라서 결과물이 진짜 오디오 데이터(AudioBuffer) 형태로 생성될 수 있다면 모킹 방식도 문제는 없는 상황이었습니다.

그래서 유사 브라우저 환경을 구현하고자 했던 집착을 버리고, 모킹하여 테스트 가능성을 확보하는 방향으로 선회했습니다.

2차 시도: Mock 라이브러리를 통한 우회적 성공

사용하지 않는 라이브러리의 모킹

standardized-audio-context가 Web Audio API를 안정적으로 추상화한 라이브러리이기 때문에 이를 테스트하기 위한 모킹 도구 역시 존재할 것이라 판단했습니다. 그래서 관련 생태계를 살펴본 끝에 standardized-audio-context-mock 라이브러리를 확인했습니다.

👉 standardized-audio-context-mock(NPM)

비록 @uturi/sonification이 내부적으로 standardized-audio-context를 직접 사용하지는 않지만 핵심적으로 Web Audio API 기반으로 오디오를 생성한다는 점은 동일합니다. 따라서 Web Audio API의 핵심 객체들을 모킹할 수 있는 도구는 곧 @uturi/sonification의 통합 테스트 환경을 구축할 수 있는 실질적인 도구로서도 활용될 수 있다는 의미이기도 했습니다.

문서와 구현 코드를 검토한 결과, 이 가설은 타당했고 해당 라이브러리가 통합 테스트 환경의 기반으로 활용될 수 있다는 확신을 얻을 수 있었습니다.

비동기 이벤트 제어

하지만 모킹 도구를 도입했다고 해서 모든 검증이 순조롭게 진행된 것은 아니었습니다. 특히 @uturi/sonificationplay() 메서드와 같이 오디오 재생 완료 시점을 기준으로 resolve되는 비동기 로직은 테스트 환경에서 타임아웃을 발생시키며 멈춰버렸습니다.

Play 구간에서 발생한 타임아웃
play 동작 구간에서 발생한 타임아웃

이유는 명확했습니다. play 내부에서는 source.onended 이벤트가 호출되기를 기다리고 있었지만 JSDOM 환경에서는 실제 오디오가 재생되지 않기 때문에 이 이벤트가 절대 발생하지 않는 것이었습니다. 즉, Promise가 영원히 종료되지 않는 상태에 빠져 있던 것입니다.

이 문제를 해결하기 위해 테스트에서는 onended 콜백을 수동으로 호출해 재생 완료 흐름을 강제로 종료하는 방식을 선택했습니다.

// tests/integration/Sonifier.integration.ts 중 
expect((audioBufferSourceNodeMock.connect as any).called).toBe(true);
expect((audioBufferSourceNodeMock.start as any).called).toBe(true);

// onended를 수동으로 호출하여
if (
  audioBufferSourceNodeMock.onended &&
  typeof audioBufferSourceNodeMock.onended === 'function'
) {
  (audioBufferSourceNodeMock.onended as any)();
}

await expect(playPromise).resolves.not.toThrow();

또한, 오디오 노드 초기화가 비동기적으로 처리되는 특성 때문에 테스트가 너무 빠르게 진행되면 내부 레지스트리가 아직 생성되지 않아 AudioNodes에서 undefined가 반환되는 문제가 있었습니다. 이를 방지하기 위해 짧은 지연(setTimeout(100ms))을 두어 초기화가 완료될 시간을 확보했습니다. 물론 이 방식은 완전한 해결책이라고 보기는 어렵습니다. 더 견고하게 처리하려면 폴링과 같은 전략이 필요하지만 테스트 복잡도가 지나치게 커진다는 점에서 단순한 지연 처리로 타협을 보았습니다.

Vitest 테스트 도구의 호환성 문제

모킹 기반 테스트가 어느 정도 안정화되는 듯했지만 또 다른 문제가 남아 있었습니다. 재생 과정에서 호출되는 connect()start() 메서드가 VitesttoHaveBeenCalled()로 검증되지 않았던 것입니다.

조금 더 들여다보니 문제의 핵심은 의외로 명확했습니다. standardized-audio-context-mock은 내부적으로 Sinon.jsstub이라는 모킹 도구를 사용하고 있었고, 해당 도구는 Vitest의 호출 검증 매커니즘 위에서 정상적으로 동작하지 않았습니다. 즉, 두 도구의 모킹 방식이 서로 호환되지 않았던 것입니다.

👉 Sinon.js

따라서 호출 여부를 검증하기 위해서는 Vitest가 아닌 Sinon이 제공하는 .called 프로퍼티를 사용해야 했습니다(참고).

expect((audioBufferSourceNodeMock.connect as any).called).toBe(true);
expect((audioBufferSourceNodeMock.start as any).called).toBe(true);

두번째 시도는 브라우저를 재현하는 대신 동작을 통제할 수 있는 테스트 환경을 확보하려는 시도였다고 정리할 수 있겠습니다. 비록 여러 시행착오가 있었지만 모킹 기반 접근은 실제 오디오 재생 없이도 생성, 연결, 종료로 이어지는 흐름을 끝까지 검증할 수 있게 해주었고, 이는 통합 테스트의 목표와 일치했습니다.

마침내 구축한 통합 테스트 환경, 그리고 얻은 깨달음

몇 차례의 삽질을 통해 유닛 테스트뿐만 아니라 복잡한 오디오 생성 로직이 담긴 통합 테스트까지 성공적으로 통과되었습니다. 그리고 마침내 @uturi/sonification의 첫번째 릴리즈도 무사히 배포될 수 있었습니다! 🎉 (NPM👩‍🦯🧑‍🦽👨‍🦽).

57개의 테스트, 마침내 통과하다
마침내 통과된 57개의 테스트

브라우저가 아닌 JSDOM 환경에서 오디오를 통합 테스트한다는 것은 생각보다 더 까다로운 일이었습니다. 하지만 이번 경험을 통해 테스트 커버리지를 넓히는 과정이 단순히 오류를 잡는 수준을 넘어 라이브러리의 동작 방식을 더 깊이 이해하고 설계를 단단하게 만드는 과정임을 다시 한 번 느꼈습니다.

만약 오디오처럼 브라우저 의존성이 강한 기능을 테스트해야 한다면 처음부터 완벽한 해답을 찾으려 하기보다 작은 실패를 빠르게 검증하는 접근을 추천하고 싶습니다. JSDOM 환경에서 AudioContext의 모킹이 실패하면서 폴리필이 문제인지, DOM 시뮬레이션이 문제였는지, 이벤트 모델이 문제였는지, Vitest 도구의 문제였는지 단번에 알기는 어려웠고, 문제를 작게 쪼개가면서 원인을 좁혀나간 것이 큰 도움이 되었기 때문입니다.

비슷한 고민, 비슷한 삽질을 하고 계신 분들에게 도움이 되시길 바라며 글을 마칩니다.

JSDOM 기반 오디오 테스트 시 반드시 고려해야 할 5가지 지점

  1. 오디오 관련 폴리필은 브라우저를 완벽하게 대체하지는 못한다.
  2. 재생 완료 이벤트는 수동으로 트리거해야 한다.
  3. 모킹 도구 간 호출 검증 방식이 다를 수 있다.
  4. 초기화 타이밍을 제어해야 한다.
  5. 테스트 목표는 “재생”이 아니라 “동작”이다.

👉 테스트 코드 살펴보기(uturi.github)

profile
스크립트가 중심이 되는 프론트엔드에서 개발하고 있습니다. 서비스의 철학을 고민합니다. 배려하고 포용하는 모든 것들을 사랑합니다.

0개의 댓글