콘텐츠로 건너뛰기
Codex

GUI 앱에서 테스트할 수 있는 부분만 떼어내기

TECH NOTE · AI COLLAB

창이 하는 일 대부분은 사실 판정입니다. DOM과 창 API를 안 쓰는 순수 함수로 빼면 창을 띄우지 않고도 테스트가 붙습니다.

창을 띄우는 코드는 테스트가 안 붙는다

데스크톱 앱을 만들면 테스트를 어디까지 붙일지 애매해진다. 창이 뜨고 마우스가 움직이고 그림이 그려지는 걸 어떻게 자동으로 확인하나.

보통 두 갈래로 간다. E2E 도구를 붙이거나, 그냥 눈으로 보고 넘어가거나. 앞은 무겁고 뒤는 회귀를 못 잡는다.

투명 창 예제를 만들면서 세 번째 길을 썼다. 창이 하는 일 대부분은 사실 판정이라는 것을 이용한다.

창 코드가 실제로 하는 일

화면에 떠 있으면서 빈 곳의 클릭은 뒤로 통과시키는 창을 만들었다. 요구사항을 늘어놓으면 이렇다.

  • 커서 아래에 UI가 있으면 UI가 클릭을 받는다
  • 캐릭터가 있으면 캐릭터가 받는다
  • 아무것도 없으면 뒤에 있는 창으로 통과시킨다

여기서 창 API를 쓰는 부분은 마지막 한 줄이다. setIgnoreCursorEvents(true/false)를 부르는 것.

나머지는 전부 “지금 어느 경우인가”를 정하는 계산이다. DOM에서 값을 읽어 오긴 하지만, 그 값만 주어지면 판정 자체는 순수 함수다.

값을 읽는 곳과 판정하는 곳을 나눈다

그래서 판정을 별도 파일로 뺐다. DOM도 창 API도 참조하지 않는다.

export function resolveOwner(elementStyles, alpha) {
  if (elementStyles.some(acceptsMouse)) {
    return Owner.UI;
  }
  if (alpha === null) {
    return Owner.CHARACTER;
  }
  return alpha > 0 ? Owner.CHARACTER : Owner.PASS_THROUGH;
}

인자가 elementStylesalpha다. 어디서 왔는지 모른다. 브라우저에서 왔든 테스트에서 만들어 넣었든 상관없다.

값을 읽는 쪽은 앱에 남는다.

const styles = document.elementsFromPoint(x, y).map((el) => getComputedStyle(el));
const alpha = alphaAt(x, y);
const owner = resolveOwner(styles, alpha);
await appWindow.setIgnoreCursorEvents(shouldIgnoreCursor(owner));

이 네 줄만 창에 묶여 있고, 그 안의 판정은 밖에서 검증된다.

그러면 이런 테스트가 붙는다

브라우저 없이 돌아간다.

test("장식 레이어가 화면을 덮어도 창 전체가 클릭을 먹지 않는다", () => {
  const decoration = { display: "block", visibility: "visible", pointerEvents: "none" };
  assert.equal(resolveOwner([decoration], 0), Owner.PASS_THROUGH);
});

test("알파를 못 읽으면 통과시키지 않는다", () => {
  assert.equal(resolveOwner([], null), Owner.CHARACTER);
});

두 번째가 특히 값어치가 있다. getImageData는 캔버스 상태에 따라 실패하는데, 그 실패를 “통과”로 처리하면 캐릭터를 눌렀는데 뒤에 있는 창이 반응한다. 손으로 재현하기 어려운 상황이고, 테스트로는 인자에 null을 넣으면 끝난다.

이런 식으로 아홉 개가 붙었다. 창 코드는 열 줄이 안 되는데 판정에는 테스트가 아홉 개다.

무엇을 뺄 수 있는지 가르는 기준

전부 뺄 수 있는 건 아니다. 실제로 나눠 보니 기준이 이렇게 정리됐다.

남는 것 나가는 것
DOM 조회, 창 API 호출 그 값으로 하는 판단
타이머, 프레임 루프 시간이 얼마 지났을 때 무엇을 할지
난수 난수를 받아서 고르는 규칙
파일·네트워크 입출력 읽어 온 내용의 해석

한 줄로 줄이면 “바깥 세계를 만지는 것”과 “그걸로 정하는 것”을 가르는 일이다.

같은 프로젝트의 미니게임 코드에도 이 원칙이 적혀 있다. 연출 모듈 첫머리에 이런 주석이 있다.

여기는 의도적으로 불순물 격리소다

DOM·타이머·프레임 루프·실제 난수가 그 파일에만 있고, 규칙과 대사 선택은 순수 함수로 밖에 있다. 그래서 게임 로직에 테스트가 붙는다.

나누면 딸려 오는 것

테스트 말고도 얻는 게 있다.

증상을 보고 원인을 좁히기 쉬워진다. 클릭이 안 먹으면 판정이 틀렸는지 창 설정이 안 걸렸는지 둘 중 하나인데, 판정은 테스트가 지키고 있으니 창 쪽부터 보면 된다.

설명하기 쉬워진다. 이 글에 코드를 실을 수 있는 것도 판정이 따로 있어서다. 창 코드와 뒤엉켜 있었으면 앞뒤를 다 설명해야 했다.

다른 데 쓸 수 있다. 판정 로직은 Tauri에 묶여 있지 않다. Electron이든 브라우저 확장이든 값만 넣어 주면 그대로 돈다.

어디까지 나눌 것인가

과하게 나누면 코드를 따라가기 어려워진다. 함수 세 개를 오가야 한 줄이 이해되는 상태는 안 낫다.

기준을 하나 뒀다. 테스트를 쓰고 싶은 만큼만 나눈다.

판정이 if 하나면 굳이 뺄 필요가 없다. 조건이 셋이고 실패했을 때 무엇을 돌려줄지 정해야 하고 경계값이 있으면, 그때는 뺀다.

투명 창의 경우 판정 결과가 세 갈래이고 실패 처리가 갈림길이라 뺄 값이 있었다.

정리

  • 창이 하는 일 대부분은 판정이다. 창 API를 쓰는 건 마지막 한 줄인 경우가 많다
  • 값을 읽는 곳과 그 값으로 정하는 곳을 나눈다
  • 나눈 쪽은 DOM 없이 테스트가 붙는다. 재현하기 어려운 실패 경로도 인자로 만들 수 있다
  • 시간·난수·입출력도 같은 기준으로 가른다
  • 테스트를 쓰고 싶은 만큼만 나눈다. 그 이상은 읽기만 어려워진다

E2E 도구를 붙이기 전에 이걸 먼저 해 보는 게 낫다. 판정을 빼고 나면 정작 눈으로 봐야 할 것이 얼마 안 남는다.

이 블로그 더 보기

이 블로그는 실제 프로젝트를 AI와 함께 굴리면서 남긴 기록입니다.

  • 새 글은 RSS로 받아볼 수 있습니다.
  • 시리즈 전체는 여기에 정리해 두었습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다