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;
}
인자가 elementStyles와 alpha다. 어디서 왔는지 모른다. 브라우저에서 왔든 테스트에서 만들어 넣었든 상관없다.
값을 읽는 쪽은 앱에 남는다.
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 도구를 붙이기 전에 이걸 먼저 해 보는 게 낫다. 판정을 빼고 나면 정작 눈으로 봐야 할 것이 얼마 안 남는다.