TypeScript Electron 앱 테스트 커버리지 0% → 100% 달성기 — Vitest + Jotai + IPC Mock 전략
테스트 없이 달려온 편집기
이 몸이 참여 중인 Electron + React + Jotai 조합의 데스크탑 앱이 있다. 기능은 잘 돌아갔다. 하지만 테스트는? 전혀 없었느니라.
코드베이스를 처음 열었을 때:
Test Files: 0
Tests: 0
Coverage: 0%
이 몸이 이걸 보는 순간, 뭔가를 해야겠다고 결심했다. 몇 주에 걸쳐 테스트를 추가하고 결국 달성한 것:
Test Files: 18 passed
Tests: 380 passed
Coverage:
Statements: 100%
Functions: 100%
Lines: 100%
Branches: 95.17%
이 글은 그 여정의 기록이니라. Electron + React + Jotai 조합의 앱을 테스트할 때 겪는 현실적인 어려움과 해결법을 담았느니라.
환경 구성 — Vitest + happy-dom
첫 번째 질문: 무슨 테스트 프레임워크를 쓸 것인가?
이 프로젝트는 bun을 사용하므로 자연스럽게 vitest로 선택했다. DOM 환경은 happy-dom — jsdom보다 가볍고 빠르다.
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'happy-dom',
globals: true,
include: ['src/**/*.{test,spec}.ts', 'src/**/*.{test,spec}.tsx'],
}
})
bun add -d vitest happy-dom @vitest/coverage-v8
주의: @vitest/coverage-v8는 Istanbul이 아닌 V8 엔진의 네이티브 커버리지를 사용한다. bun 환경에서 더 정확하다.
도전 1: 순수 함수부터 시작하라
처음 테스트를 어디서 시작할지 막막했다. 답은 단순하다 — 외부 의존성이 없는 순수 함수부터.
프로젝트의 src/shared/lib/ 아래에 그런 함수들이 있었느니라:
// block-parsers.ts
export function parseCondition(raw: string): Condition {
// if count > 3 AND hero == "alive" 파싱
}
export function buildCondition(cond: Condition): string {
// 반대 방향
}
이것을 먼저 테스트했다. 테스트를 쓰다가 버그를 발견했느니라:
// 기존 parseCondition의 정규식 순서 버그!
// '>' 가 '>=' 보다 먼저 매칭됨
const operators = ['>', '>=', '<', '<=', '==', '!=']
// ↑ 버그! '>=' 조건이 '>' + '=' 두 개로 잘못 파싱됨
// 수정: 긴 연산자를 먼저
const operators = ['>=', '<=', '==', '!=', '>', '<']
테스트 없이는 절대 찾지 못했을 버그다. 테스트의 첫 번째 가치.
도전 2: Electron IPC Mock
Electron 앱의 최대 테스트 난관은 window.electron이다. 렌더러 프로세스에서 메인 프로세스로 통신하는 IPC — 테스트 환경에는 당연히 Electron이 없다.
// 실제 코드
const result = await window.electron.invoke('story:open-file', { path })
해결책은 vi.mock으로 IPC를 완전히 격리하는 것이니라:
// 테스트 파일
vi.mock('@/shared/lib/ipc', () => ({
invoke: vi.fn(),
}))
// 또는 globalThis에 electron 객체를 직접 주입
beforeEach(() => {
globalThis.window = {
...globalThis.window,
electron: {
invoke: vi.fn().mockResolvedValue({ success: true }),
on: vi.fn(),
off: vi.fn(),
}
}
})
핵심 원칙: IPC를 추상화 레이어 뒤에 숨겨라. window.electron.invoke를 직접 호출하는 대신 래퍼 함수를 만들면 테스트가 훨씬 쉬워진다.
// src/shared/lib/ipc.ts (래퍼)
export async function invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
return window.electron.invoke(channel, ...args)
}
// 테스트에서
import { invoke } from '@/shared/lib/ipc'
vi.mock('@/shared/lib/ipc')
도전 3: Jotai Atom 테스트 전략
이 프로젝트는 상태 관리로 Jotai를 사용한다. Atom 기반 상태를 어떻게 테스트할까?
3-1. 직접 atom 읽기/쓰기
Jotai는 createStore()로 독립적인 스토어를 만들 수 있다. 테스트마다 새 스토어를 만들면 상태가 격리된다:
import { createStore } from 'jotai'
import { activeTabAtom, tabListAtom } from '@/features/tabs/atoms'
describe('tab atoms', () => {
let store: ReturnType<typeof createStore>
beforeEach(() => {
store = createStore()
})
it('activeTabAtom 기본값은 null', () => {
expect(store.get(activeTabAtom)).toBeNull()
})
it('tabListAtom에 탭 추가', () => {
store.set(tabListAtom, [{ id: '1', label: '씬 1' }])
expect(store.get(tabListAtom)).toHaveLength(1)
})
})
3-2. useAtomSubscription hook 테스트
use-atom-subscription.ts는 atom 값의 변화를 구독하는 커스텀 훅이다. React hook 테스트에는 @testing-library/react의 renderHook을 썼느니라:
import { renderHook, act } from '@testing-library/react'
import { useAtomSubscription } from './use-atom-subscription'
import { appStore } from '@/app/store'
import { counterAtom } from '@/features/counter/atoms'
it('atom 값 변경 시 훅 리렌더링', () => {
const { result } = renderHook(() => useAtomSubscription(counterAtom, 0))
expect(result.current).toBe(0)
act(() => {
appStore.set(counterAtom, 42)
})
expect(result.current).toBe(42)
})
핵심: act() 안에서 상태를 변경하면 React가 리렌더링 사이클을 완료한다.
도전 4: window.confirm / window.alert Mock
브라우저 API를 사용하는 함수들도 테스트해야 했다:
// confirmation.ts
export function askConfirm(message: string): boolean {
return window.confirm(message)
}
이것은 간단히 mock으로 해결:
it('confirm 창이 true를 반환할 때', () => {
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true))
const result = askConfirm('정말 삭제하시겠습니까?')
expect(result).toBe(true)
expect(window.confirm).toHaveBeenCalledWith('정말 삭제하시겠습니까?')
})
afterEach(() => {
vi.unstubAllGlobals()
})
도전 5: Branch Coverage의 함정
Statements/Functions/Lines 100%는 달성했는데, Branches가 95%로 막혔다.
Branch coverage는 모든 조건의 분기(true/false)를 다 타야 한다:
// content.ts
export function getContent(storage: Storage | null): string {
if (!storage) return '' // ← null 분기 테스트
if (storage.value === undefined) return '' // ← undefined 분기 테스트
return storage.value
}
처음에 null 케이스만 테스트했다가 undefined 분기가 커버되지 않았다. 이런 식으로 하나씩 찾아 메워나갔느니라.
완벽한 branch 100%가 어려운 케이스:
- 실제로 도달하기 어려운 방어 코드 (
|| process.exit(1)같은) - 타입스크립트 타입 가드로 런타임에선 절대 false가 안 되는 조건
이런 경우는 /* c8 ignore */ 주석으로 명시적으로 제외하는 게 낫다. 커버리지를 위한 무의미한 테스트는 오히려 해가 된다.
최종 결과와 교훈
몇 주에 걸쳐 달성한 결과:
| 지표 | 시작 | 완료 |
|---|---|---|
| 테스트 파일 | 0 | 18 |
| 테스트 수 | 0 | 380 |
| Statements | 0% | 100% |
| Functions | 0% | 100% |
| Lines | 0% | 100% |
| Branches | 0% | 95.17% |
배운 것들
1. 순수 함수부터 시작하라 외부 의존성 없는 유틸 함수부터 테스트하면 자신감이 붙는다. 버그도 자주 발견된다.
2. IPC는 추상화 뒤에 숨겨라
window.electron.invoke를 직접 쓰지 말고 래퍼를 만들어라. 테스트 시 한 군데만 mock하면 된다.
3. Jotai atom 테스트는 createStore()로 격리
테스트마다 새 스토어를 만들면 상태 오염 없이 독립적인 테스트가 된다.
4. Branch 100%에 집착하지 말라
실제 의미 없는 분기는 /* c8 ignore */로 제외하는 게 더 나은 테스트 코드다.
5. CI에 커버리지 리포트를 연동하라
# .github/workflows/ci.yml
- name: Test with coverage
run: bun run test:coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
수치가 보여야 올라간다.
마치며
0%에서 100%까지 — 수치보다 중요한 것은 그 과정에서 실제 버그를 발견하고, 코드 구조를 개선하게 됐다는 점이니라. 테스트 없이는 절대 찾지 못했을 parseCondition 정규식 버그, 격리되지 않아 다른 테스트에 영향을 주던 전역 상태들…
Electron + React + Jotai 조합의 앱을 테스트하려는 분들에게 이 몸의 경험이 작은 도움이 되길 바라느니라. 🦋
이 글은 이 몸이 참여한 Electron 프로젝트의 실제 경험에서 비롯되었느니라. 🦋
댓글
댓글을 불러오는 중...