반주 엔진을 수정하면 화면이나 오류 메시지에 드러나지 않는 타이밍 변화가 생길 수 있습니다. 화면은 멀쩡하고 오류도 없는데, 스윙을 넣은 뒤로 어떤 리듬의 두 번째 음이 0.02초 늦게 나오는 식입니다. 귀로 확인하기에는 리듬이 너무 많고, 단위 테스트 몇 개로는 모든 조합을 지킬 수 없습니다.
Guitar Auto-Strum은 이 문제를 골든 테스트로 풀었습니다. 실제 엔진이 예약한 모든 음을 기록해 두고, 코드가 바뀔 때마다 그 기록과 한 줄씩 비교합니다. (테스트 파일은 web-app/js/test/transport-golden.mjs이고, 실행 명령은 npm run test:golden입니다.)
무엇을 기록하나: 예약된 모든 음
이 앱의 반주 엔진(트랜스포트)은 재생 중에 “몇 초에 어떤 음을 낼지”를 오디오 장치에 예약합니다. 테스트는 진짜 오디오 장치 대신 가짜 오디오 하네스를 꽂고, 예약되는 모든 요청을 한 줄 문자열로 기록합니다.
"bossaNova:strum": [
"t=0.0000 click accent",
"t=1.0000 click",
"t=2.0000 click",
"t=3.0000 strum v=0.75 stagger=0 strings=4",
"t=3.3750 strum v=0.55 stagger=12 strings=2,1,0",
"t=3.7500 strum v=0.55 stagger=12 strings=2,1,0",
...
]
한 줄에는 시각(t), 종류(카운트 클릭, 스트럼, 아르페지오의 한 줄 뜯기), 세기(v), 훑는 간격(stagger), 실제로 울리는 줄이 들어 있습니다. 위 예에서는 3초(카운트인 세 박이 끝난 자리)에 첫 박이 나오고, 이후 박자와 세기가 어떻게 흐르는지가 그대로 보입니다.
어떻게 만드나
케이스는 코드가 만듭니다. 시스템 리듬 카탈로그의 모든 리듬을 스트럼과 아르페지오 두 가지 모드로 돌리고, 각각 두 마디(FIGURES = 2)를 BPM 120으로 재생합니다.
for (const id of Object.keys(SYSTEM_RHYTHMS).sort()) {
const rhythm = SYSTEM_RHYTHMS[id];
for (const mode of [PlayMode.strum, PlayMode.arpeggio]) {
if (!figureForMode(rhythm, mode).strokes.length) continue;
out[`${id}:${mode}`] = await captureRun(rhythm, mode, C);
}
}
여기에 특수한 경우도 더합니다.
- D 코드로 두 케이스: D 코드는 낮은 E줄과 A줄을 치지 않기 때문에, 저음 담당 스트로크가 다른 줄로 물러나는 경로(폴백)가 실행됩니다.
- feel 케이스 네 개: 스윙(triplet), 휴머나이즈(loose), 스윙+휴머나이즈(medium+subtle), 아르페지오+휴머나이즈. 스윙과 휴머나이즈가 실제로 하는 일을 고정합니다.
검토 중 npm run test:golden을 실행한 결과는 178개 케이스, 총 3726개 이벤트였고 모두 통과했습니다.
하네스의 시계는 harness.advanceTo(...)로 원하는 시각까지 진행합니다. 실제 재생 시간을 기다리지 않고 같은 시각의 예약 요청을 비교합니다.
const result = await transport.start();
const figureSec = rhythmFigureSeconds({ ...rhythm, figure }, BPM);
harness.advanceTo(COUNT_IN_SECONDS + figureSec * FIGURES);
transport.stop();
return formatEvents(harness.events);
비교와 갱신 규칙
테스트가 돌면 방금 만든 기록을 저장된 골든 파일과 한 줄씩 비교합니다.
- 골든에 있던 케이스가 사라졌다 → 실패
- 골든에 없던 케이스가 생겼다 → 실패(새 케이스는 의식적으로 기록해야 한다)
- 이벤트 줄 수가 다르다 → 실패
- 줄 내용이 다르다 → 어느 케이스의 몇 번째 이벤트인지, 기대값과 실제값을 함께 출력
아래는 실제 출력이 아니라 형식을 보이기 위한 예시입니다.
FAIL: bossaNova:strum event 4
want t=3.3750 strum v=0.55 stagger=12 strings=2,1,0
got t=3.3900 strum v=0.55 stagger=12 strings=2,1,0
박자가 의도치 않게 15ms 밀렸다면 이렇게 어느 음이 어떻게 밀렸는지 바로 보입니다. 실패 메시지의 끝에는 이렇게 적힙니다. “타이밍 변경이 의도한 것이라면 npm run golden:update.”
갱신은 사람이 의식적으로 합니다. 파일 첫머리 주석의 규칙은 이렇습니다(번역). “타이밍 변경이 의도한 것일 때만 다시 기록하고, 커밋하기 전에 차이를 읽어라.” 골든 테스트가 위험해지는 순간은 실패했을 때 무심코 갱신으로 덮는 것입니다. 갱신을 별도 명령으로 분리하고 차이를 읽으라는 규칙을 코드에 남겨 두는 이유입니다.
기본 동작이 안 바뀐 것도 증거가 된다
feel(스윙·휴머나이즈) 관련 테스트 주석에는 기존 케이스와 추가 케이스의 역할이 이렇게 적혀 있습니다(번역).
위 케이스들은 모두 곧게, 흔들림 없이 돌아간다. 앱의 기본 상태이며, feel을 추가해도 이 기록들이 그대로였다는 점을 확인한다. 아래 케이스는 각 설정의 동작을 고정한다.
기존 기록이 통과했다는 것은 이 테스트가 다룬 입력에서 예약 요청이 같았다는 뜻입니다. 모든 사용자 입력이나 실제 기기의 오디오 출력까지 같다는 증거는 아닙니다.
스냅샷만으로는 부족하다: 성질 검사
스냅샷은 “지난번과 같다”만 말합니다. 지난번이 처음부터 틀렸다면 같은 오류를 영원히 지킵니다. 그래서 테스트는 스냅샷과 별개로, 기록에 대해 항상 성립해야 하는 성질도 검사합니다.
| 성질 | 이유 |
|---|---|
| 카운트인 클릭이 정확히 정해진 횟수이고 첫 클릭은 강세가 있다 | 시작 신호가 흔들리면 모든 것이 밀린다 |
| 카운트인이 끝나기 전에는 클릭 외에는 아무 음도 나지 않는다 | 첫 박보다 앞선 음이 카운트로 들린다 |
| 이벤트 시각이 뒤로 가지 않는다 | 예약은 받은 순서대로 되므로 |
| C 코드에서는 낮은 E줄(5번)을 치지 않는다 | C 코드는 낮은 E줄을 뮤트한다 |
이 검사들은 “기록이 갱신되어도 깨지지 않아야 하는 규칙”입니다. 실수로 갱신을 눌러 잘못된 기록을 저장해도, 이 성질을 어기면 여전히 실패합니다. 새 기록을 받아들이기 전에도 카운트인과 이벤트 순서 같은 조건은 확인해야 합니다.
소리에도 같은 방식
같은 발상을 소리의 파형에도 적용했습니다(timbre-assert.mjs, 명령은 timbre:update). 트랜스포트 골든은 언제 음이 예약되는지를 고정하고, 소리 골든은 어떤 파형이 만들어지는지를 고정합니다. 소리 쪽 이야기는 줄을 계산해 기타 소리를 만드는 글에서 다뤘습니다. 두 검사는 예약 이벤트와 합성 파형의 변화를 따로 확인합니다. 트랜스포트 골든의 가짜 하네스만으로는 실제 신디사이저의 소리를 검사할 수 없습니다.
실제 오디오 장치는 별도 확인
- 골든은 무엇이 옳은지 말하지 못합니다. “달라졌다”만 알려 줍니다. 그래서 위의 성질 검사를 곁들입니다.
- 갱신 규율에 기댑니다. 사람이 차이를 읽지 않고 갱신을 누르면 안전망이 구멍이 됩니다.
- 결과가 넓게 흔들리는 변경은 차이가 큽니다. 스윙 알고리즘을 바꾸면 많은 케이스의 여러 줄이 한꺼번에 바뀌어서, 차이를 읽는 일이 고됩니다. 이 때문에 케이스를 나누고 이름을 붙여 두었습니다.
- 실제 오디오 장치는 검증하지 않습니다. 가짜 하네스가 기록한 예약 요청을 지킬 뿐, 브라우저의 오디오 지연이나 기기별 차이는 다룰 수 없습니다.