테스트가 한 번 더 돌면 실패했습니다: VS Code Extension Host 수명주기 고치기

테스트를 한 번 돌렸습니다. 통과했습니다.

같은 코드를 한 번 더 돌렸습니다. 이번에는 copy-code-as-snippet.copy command가 이미 존재한다며 실패했습니다.

코드는 그대로인데 결과만 바뀌면 의심 목록이 아주 풍성해집니다.

  • dependency가 바뀌었나?
  • 빌드 결과가 오래됐나?
  • VS Code 버전이 달라졌나?
  • CI가 오늘만 저기압인가?

마지막 항목은 늘 설득력이 있지만, 보통 고칠 수가 없습니다. (CI에게 따뜻한 차를 건넬 수도 없고요.)

제가 만든 VS Code extension인 copy-code-as-snippet의 실제 원인은 테스트 데이터가 아니었습니다. 테스트가 공유하던 Extension Host의 수명주기였습니다.

테스트가 지운 것과 host에 남은 것은 달랐습니다

문제가 있던 suite는 각 테스트의 setup()에서 activate(context)를 직접 호출했습니다.

activate()는 설정값을 계산하고 끝나는 순수 함수가 아닙니다. 실제 command를 Extension Host에 등록합니다.

const disposable = vscode.commands.registerCommand("copy-code-as-snippet.copy", async () => {
  // Copy the active selection or document.
});

context.subscriptions.push(disposable);

테스트는 가짜 ExtensionContext를 매번 새로 만들었습니다. teardown()에서는 그 context의 subscriptions를 순회하며 dispose했습니다.

겉으로 보면 대칭입니다.

당시 구현을 구조만 남겨 축약하면 다음과 같습니다. 실제 파일은 helper를 호출하지 않고 setup() 안에 긴 ExtensionContext object literal을 직접 할당했습니다.

setup(() => {
  context = {
    subscriptions: [],
    // workspaceState, globalState, storage URIs, secrets, ...
  } as vscode.ExtensionContext;
  activate(context);
});

teardown(() => {
  context.subscriptions.forEach((subscription) => subscription.dispose());
});

등록하고, 정리하고, 다시 등록합니다. 무엇이 문제일까요?

가짜 context는 테스트마다 새것이지만 Extension Host는 같은 process 안에서 공유됩니다. 테스트가 관리한다고 생각한 객체의 수명과, VS Code가 command registration을 보유하는 수명이 정확히 겹친다는 보장이 없었습니다.

그 틈에서 다음 activation이 같은 command ID를 다시 등록하려 했고, 이미 존재한다는 오류가 간헐적으로 나타났습니다.

테스트별 격리를 만들려 했는데, 실제로는 host 전체 상태를 테스트마다 다시 초기화하는 척하고 있었던 셈입니다.

재실행은 진단 도구이지 통과 버튼이 아닙니다

간헐적 실패를 만나면 가장 먼저 다시 돌려보게 됩니다. 그 선택 자체는 맞습니다.

하지만 두 번째 실행이 통과했다고 첫 번째 실패를 지워버리면 안 됩니다.

재실행으로 확인할 수 있는 것은 “이 실패가 결정적인가?”입니다. 확인할 수 없는 것은 “그러므로 제품 코드에는 문제가 없다”입니다.

이 사건에서는 같은 test tree가 command 등록 오류를 냈다가 통과했습니다. 그 결과는 dependency 회귀의 증거도 아니고, 무시해도 되는 잡음의 증거도 아니었습니다.

공유 상태가 실행 순서나 이전 실행에 오염될 가능성을 조사하라는 신호였습니다.

제가 확인할 순서는 다음과 같습니다.

  1. 실패 메시지에서 process-wide resource를 찾습니다. command, port, singleton, cache, global listener 같은 것들입니다.
  2. setup()이 그 resource를 새로 만들거나 다시 등록하는지 봅니다.
  3. teardown()이 내가 만든 mock만 정리하는지, 실제 runtime의 소유권까지 정리하는지 구분합니다.
  4. 전체 suite, 단일 test, 연속 반복 실행의 결과를 비교합니다.
  5. 원인을 고친 뒤 반복 실행을 회귀 검증으로 남깁니다.

한 번의 green rerun은 4번의 관찰 하나일 뿐입니다. 결론은 아닙니다.

실제 extension은 suite당 한 번만 활성화했습니다

고친 방향은 단순했습니다.

테스트가 activate()를 직접 호출하며 VS Code의 lifecycle을 흉내 내지 않게 했습니다. 대신 Extension Host에 설치된 실제 development extension을 찾아 suite 시작 시 한 번 활성화했습니다.

suiteSetup(async () => {
  const installedExtension = vscode.extensions.getExtension("dongminyu.copy-code-as-snippet");

  assert.ok(installedExtension, "Development extension should be installed");
  extension = installedExtension;
  await extension.activate();
});

이 변경으로 command registration의 소유권은 실제 extension lifecycle로 돌아갔습니다.

반면 테스트마다 달라져야 하는 것들은 여전히 setup()teardown()에 남겼습니다.

  • configuration stub
  • workspace-folder stub
  • clipboard 대체 객체
  • information-message spy
  • sinon.restore()

이 구분이 핵심입니다.

extension activation은 suite state이고, 입력과 spy는 test state입니다.

모든 상태를 suite로 올리면 테스트끼리 값이 섞입니다. 모든 상태를 test로 내리면 process-wide registration을 반복해서 건드립니다.

격리는 무조건 더 많이 초기화한다고 생기지 않습니다. 각 상태의 실제 소유자와 수명을 맞춰야 생깁니다.

긴 가짜 context보다 실제 lifecycle이 나았습니다

이 수정은 테스트를 더 복잡하게 만들지 않았습니다.

기존 suite는 ExtensionContext의 필드를 맞추기 위해 workspaceState, globalState, storage URI, secrets, environment-variable collection까지 긴 mock을 만들고 있었습니다. 정작 실패를 만든 것은 그 mock이 실제 Extension Host의 command registry를 소유한다고 가정한 부분이었습니다.

수정 commit 7564c3b는 그 mock context와 직접 activation을 제거하고, 실제 extension lookup과 suite-level activation으로 바꿨습니다. 같은 commit은 이전에 flaky하다는 이유로 빠져 있던 Extension Host test를 Linux CI에 다시 넣었습니다.

- run: xvfb-run -a yarn -s vscode-test

현재 CI는 이 integration test를 선언된 최소 VS Code 1.106.1stable 두 환경에서 실행합니다.

matrix:
  vscode-version: [1.106.1, stable]

후속 commit 38177e7의 검증 기록에는 두 버전에서 각각 28개 integration test 통과, 그리고 최소 버전에서 세 번의 연속 실행마다 28개 통과가 남아 있습니다.

중요한 것은 숫자가 예쁘다는 사실만이 아닙니다. 한 번 통과한 suite와 반복해도 같은 결과를 내는 suite는 제공하는 증거가 다릅니다.

stale build 가설도 source에서 분리해야 합니다

Extension test가 이상하면 오래된 out/을 먼저 의심하기 쉽습니다.

이 저장소의 yarn test lifecycle은 pretest에서 test code, extension bundle, lint를 다시 실행한 뒤 unit test와 integration test를 순서대로 돌립니다.

{
  "pretest": "yarn run compile-tests && yarn run compile && yarn run lint",
  "test": "yarn test:unit && yarn test:integration"
}

CI에서 test:integration을 직접 호출할 때도 그 앞 단계에 yarn compile-testsyarn compile이 명시돼 있습니다.

따라서 같은 구성에서 결과가 달라진 사건을 설명할 때 “아마 stale build였을 것”이라고 먼저 결론 내릴 근거는 약했습니다.

이처럼 flake를 진단할 때는 가설마다 그 가설을 읽는 evidence가 필요합니다.

  • stale build 가설은 실제 compile 단계가 매번 실행됐는지 읽어야 합니다.
  • dependency 가설은 같은 lifecycle에서 버전만 바꾼 A/B 결과를 읽어야 합니다.
  • test-order 가설은 단일 test와 전체 suite의 차이를 읽어야 합니다.
  • shared-state 가설은 setup과 teardown 밖에서 살아남는 runtime object를 읽어야 합니다.

“다시 돌리니 됐음”은 어느 가설도 제대로 읽지 않습니다.

테스트 격리는 파일이 아니라 소유권 문제입니다

unit test에서는 새 객체를 만들어 주입하면 격리가 끝나는 경우가 많습니다. Extension Host, browser, database, simulator처럼 runtime을 공유하는 integration test는 다릅니다.

mock을 새로 만들었다고 runtime이 새로 생긴 것은 아닙니다. subscription을 dispose했다고 host의 모든 등록 상태가 내가 기대한 순간에 사라졌다는 뜻도 아닙니다.

그래서 integration test의 setup을 볼 때 저는 이제 세 가지를 따로 묻습니다.

  1. 이 resource의 실제 소유자는 누구인가?
  2. 이 resource는 test, suite, process 중 어디까지 살아야 하는가?
  3. cleanup이 mock의 상태를 지우는가, runtime의 상태를 지우는가?

이 세 질문에 답하지 않은 채 beforeEach를 늘리는 것은 격리가 아니라 반복입니다.

이번 수정에서 가장 효과가 컸던 코드는 새로운 abstraction이 아니었습니다. 테스트가 runtime lifecycle을 재연하지 않게 하고, 실제 runtime에게 한 번만 맡긴 suiteSetup()이었습니다.

간헐적 테스트를 만나면 재실행하세요. 다만 green이 나오면 멈추지 말고, 첫 번째 red가 어떤 수명주기를 읽고 있었는지 확인하세요.

테스트는 가끔 거짓말을 합니다. 대부분은 테스트가 나빠서가 아니라, 우리가 누구의 상태를 누가 소유하는지 잘못 가르쳐줬기 때문입니다.