---
title: "JSDOM 환경에서 AudioContext 통합 테스트 구축하기"
description: "JSDOM 환경에서 AudioContext를 통합 테스트한 삽질기"
date: 2025-11-16
tags: [a11y, javascript, testing]
sections: [technical]
draft: false
---

![썸네일](https://velog.velcdn.com/images/ksr20612/post/4717559b-31aa-400e-aae1-4da833943b91/image.png)

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

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

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

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

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

[👉 standardized-audio-context(NPM)](https://www.npmjs.com/package/standardized-audio-context)

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

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

```typescript
// 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();
 });
 ...
});
```

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

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

```bash
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)](https://www.npmjs.com/package/web-audio-api)

```typescript
// 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 {
 // sac의 객체들이 먼저 해석되어 web-audio-api의 폴리필과 충돌하는 상황을 회피하고자 동적으로 임포트 처리
 const sac = await import('standardized-audio-context');
 ...
}
```

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

```bash
1. TypeError: this.audioContext.close is not a function 
2. Error: you need to set outStream to send the audio somewhere ❯ AudioInput. ../../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)](https://www.npmjs.com/package/standardized-audio-context-mock)

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

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


### 비동기 이벤트 제어

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

| Play 구간에서 발생한 타임아웃 |
| :-: |
| ![play 동작 구간에서 발생한 타임아웃](https://velog.velcdn.com/images/ksr20612/post/8b3a5a68-d876-49a9-8622-0a60bdba6f4b/image.png) |


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

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

```javascript
// 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()` 메서드가 `Vitest`의 `toHaveBeenCalled()`로 검증되지 않았던 것입니다.**

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

[👉 Sinon.js](https://sinonjs.org/releases/latest/stubs)

따라서 호출 여부를 검증하기 위해서는 `Vitest`가 아닌 `Sinon`이 제공하는 `.called` 프로퍼티를 사용해야 했습니다([참고](https://sinonjs.org/releases/v20/stubs/)).

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

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


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

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

| 57개의 테스트, 마침내 통과하다 |
| :-: |
| ![마침내 통과된 57개의 테스트](https://velog.velcdn.com/images/ksr20612/post/fc71f0f1-d12f-46fc-9bdd-c197fe73f840/image.png) |

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

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

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

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

[👉 테스트 코드 살펴보기(uturi.github)](https://github.com/ksr20612/uturi/tree/main/packages/sonification/tests)
