반갑습니다! 프론트엔드 개발자로서 한 단계 더 도약하기 위해 Zustand 공식 문서를 통한 테스트 코드 작성까지 공부하고 계시다니 정말 멋진 자세입니다. 영어 문서라 조금 막막하셨을 텐데, 제가 꼼꼼하게 다 번역해 드리고 실무와 취업 준비에 도움이 될 만한 팁과 부연 설명도 팍팍 넣어 드릴게요!
프론트엔드 취업 시장에서 '테스트 코드를 작성할 줄 아는가'는 면접관의 눈길을 단번에 사로잡을 수 있는 강력한 무기가 됩니다. 포트폴리오 프로젝트에 이 내용을 잘 녹여내시길 바라며, 자, 그럼 시작해볼까요?
테스트 작성하기 (Writing Tests)
보통 테스트 러너는 JavaScript나 TypeScript 문법을 실행할 수 있도록 구성되어야 해요. 만약 UI 컴포넌트를 테스트할 예정이라면, 가짜 DOM 환경(mock DOM)을 제공하기 위해 테스트 러너가 JSDOM을 사용하도록 설정해야 할 가능성이 큽니다.
💡 강사의 팁: > 프론트엔드 테스트 환경에서 JSDOM은 정말 자주 등장하는 개념이에요. 테스트 코드는 실제 브라우저(크롬, 사파리 등)가 아니라 Node.js라는 터미널 환경에서 돌아가거든요. Node.js에는
window나document같은 브라우저 객체가 없기 때문에, JSDOM이라는 라이브러리가 브라우저인 척 가짜 DOM 환경을 만들어주는 거랍니다!
테스트 러너 환경 구성에 대한 지침은 다음 리소스들을 참고해 보세요:
Zustand와 연결된 React 컴포넌트를 테스트하려면 React Testing Library (RTL)을 사용하는 것을 강력히 권장합니다. RTL은 좋은 테스트 문화를 장려하는 간단하고 완벽한 React DOM 테스트 유틸리티예요. RTL은 ReactDOM의 render 함수와 react-dom/tests-utils의 act를 사용합니다. 게다가 React Native 컴포넌트를 테스트할 때는 RTL의 대안으로 Native Testing Library (RNTL)을 사용할 수 있습니다. Testing Library 도구 제품군에는 다른 인기 있는 프레임워크들을 위한 어댑터도 많이 포함되어 있어요.
우리는 또한 네트워크 요청을 모킹(mocking, 가짜로 만들기)하기 위해 Mock Service Worker (MSW)를 사용하는 것을 추천합니다. 이렇게 하면 테스트를 작성할 때 애플리케이션의 원래 로직을 변경하거나 모킹할 필요가 없기 때문이죠.
💡 강사의 팁: > RTL(React Testing Library)은 프론트엔드 개발자 면접에서도 단골로 나오는 주제입니다! RTL의 핵심 철학은 "구현 세부 사항이 아니라, 사용자가 앱을 사용하는 방식 그대로 테스트하라"는 거예요.
또한, MSW는 서버가 아직 완성되지 않았을 때나 테스트 환경에서 실제 API를 찌르지 않고 가짜 응답을 받을 때 쓰는 아주 강력한 도구입니다. 프론트엔드 개발자라면 꼭 알아두면 좋은 생태계 조합이 바로 Jest(또는 Vitest) + RTL + MSW 랍니다.
📝 참고: Jest는 CommonJS 모듈을 사용하고 Vitest는 ES 모듈을 사용하는 등 약간의 차이가 있기 때문에, Jest 대신 Vitest를 사용한다면 이 점을 염두에 두어야 합니다.
아래에 제공되는 모의(mock) 코드는 해당 테스트 러너가 각 테스트가 끝난 후 Zustand 스토어들을 초기화(reset)할 수 있도록 해줍니다.
💡 강사의 팁: > 이 부분이 가장 중요한 핵심입니다! Zustand 같은 전역 상태 관리 라이브러리는 말 그대로 '전역'에 상태를 들고 있어요. 그래서 첫 번째 테스트에서 상태를 변경하면, 두 번째 테스트가 시작될 때 초기 상태가 아니라 방금 변경된 상태를 물려받게 되어 테스트가 꼬이게 됩니다. (이걸 '테스트 간 격리가 안 된다'고 표현해요.) 그래서 테스트가 끝날 때마다 스토어를 깨끗하게 초기화해주는 세팅이 반드시 필요합니다!
이 공유 코드는 데모에서 코드 중복을 피하기 위해 추가되었어요. 데모에서는 Context API를 사용하는 구현(createStore)과 사용하지 않는 구현(create) 양쪽 모두에 동일한 카운터 스토어 생성기(counter store creator)를 사용하기 때문이죠.
// shared/counter-store-creator.ts
import { type StateCreator } from 'zustand'
export type CounterStore = {
count: number
inc: () => void
}
export const counterStoreCreator: StateCreator<CounterStore> = (set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
})
다음 단계에서는 Zustand를 모킹(mock)하기 위해 Jest 환경을 설정해 볼 거예요.
// __mocks__/zustand.ts
import { act } from '@testing-library/react'
import type * as ZustandExportedTypes from 'zustand'
export * from 'zustand'
const { create: actualCreate, createStore: actualCreateStore } =
jest.requireActual<typeof ZustandExportedTypes>('zustand')
// 앱에 선언된 모든 스토어의 초기화(reset) 함수들을 담아둘 변수
export const storeResetFns = new Set<() => void>()
const createUncurried = <T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
const store = actualCreate(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}
// 스토어를 생성할 때 초기 상태를 가져오고, 리셋 함수를 만들어 Set에 추가합니다.
export const create = (<T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
console.log('zustand create mock')
// curried 버전의 create를 지원하기 위함
return typeof stateCreator === 'function'
? createUncurried(stateCreator)
: createUncurried
}) as typeof ZustandExportedTypes.create
const createStoreUncurried = <T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
const store = actualCreateStore(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}
// 스토어를 생성할 때 초기 상태를 가져오고, 리셋 함수를 만들어 Set에 추가합니다.
export const createStore = (<T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
console.log('zustand createStore mock')
// curried 버전의 createStore를 지원하기 위함
return typeof stateCreator === 'function'
? createStoreUncurried(stateCreator)
: createStoreUncurried
}) as typeof ZustandExportedTypes.createStore
// 각 테스트 실행 후 모든 스토어 초기화
afterEach(() => {
act(() => {
storeResetFns.forEach((resetFn) => {
resetFn()
})
})
})
// setup-jest.ts
import '@testing-library/jest-dom'
// jest.config.ts
import type { JestConfigWithTsJest } from 'ts-jest'
const config: JestConfigWithTsJest = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['./setup-jest.ts'],
}
export default config
📝 참고: TypeScript를 사용하려면
ts-jest와ts-node라는 두 가지 패키지를 설치해야 합니다.
다음 단계에서는 Zustand를 모킹하기 위해 Vitest 환경을 설정해 볼 거예요.
⚠️ 경고: Vitest에서는 root (루트 경로)를 변경할 수 있어요. 그로 인해,
__mocks__디렉토리를 올바른 위치에 생성하고 있는지 반드시 확인해야 합니다. 만약 루트를./src로 변경했다고 가정해 볼까요? 그러면./src폴더 하위에__mocks__디렉토리를 만들어야 해요. 즉, 최종 결과물은./__mocks__가 아니라./src/__mocks__가 되어야 한다는 뜻이죠.__mocks__디렉토리를 잘못된 위치에 만들면 Vitest를 사용할 때 문제가 발생할 수 있습니다.
💡 강사의 팁: > 요즘 Next.js나 React 프로젝트를 새로 시작할 때는 속도가 훨씬 빠르고 Vite와 찰떡궁합인 Vitest를 도입하는 추세예요. 기존 Jest 지식과 문법이 거의 99% 똑같기 때문에, 최신 트렌드를 쫓고 싶으시다면 나만의 포트폴리오 프로젝트에 Vitest를 적용해 보는 것을 적극 추천합니다!
// __mocks__/zustand.ts
import { act } from '@testing-library/react'
import type * as ZustandExportedTypes from 'zustand'
export * from 'zustand'
const { create: actualCreate, createStore: actualCreateStore } =
await vi.importActual<typeof ZustandExportedTypes>('zustand')
// 앱에 선언된 모든 스토어의 초기화(reset) 함수들을 담아둘 변수
export const storeResetFns = new Set<() => void>()
const createUncurried = <T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
const store = actualCreate(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}
// 스토어를 생성할 때 초기 상태를 가져오고, 리셋 함수를 만들어 Set에 추가합니다.
export const create = (<T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
console.log('zustand create mock')
// curried 버전의 create를 지원하기 위함
return typeof stateCreator === 'function'
? createUncurried(stateCreator)
: createUncurried
}) as typeof ZustandExportedTypes.create
const createStoreUncurried = <T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
const store = actualCreateStore(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}
// 스토어를 생성할 때 초기 상태를 가져오고, 리셋 함수를 만들어 Set에 추가합니다.
export const createStore = (<T>(
stateCreator: ZustandExportedTypes.StateCreator<T>,
) => {
console.log('zustand createStore mock')
// curried 버전의 createStore를 지원하기 위함
return typeof stateCreator === 'function'
? createStoreUncurried(stateCreator)
: createStoreUncurried
}) as typeof ZustandExportedTypes.createStore
// 각 테스트 실행 후 모든 스토어 초기화
afterEach(() => {
act(() => {
storeResetFns.forEach((resetFn) => {
resetFn()
})
})
})
📝 참고: globals configuration (글로벌 설정)이 활성화되어 있지 않다면, 파일 맨 위에
import { afterEach, vi } from 'vitest'를 추가해야 합니다.
// global.d.ts
/// <reference types="vite/client" />
/// <reference types="vitest/globals" />
📝 참고: globals configuration이 활성화되어 있지 않다면,
/// <reference types="vitest/globals" />이 줄을 삭제해 주어야 합니다.
// setup-vitest.ts
import '@testing-library/jest-dom/vitest'
vi.mock('zustand') // Jest처럼 작동하게 만들기 위함 (자동 모킹)
📝 참고: globals configuration이 활성화되어 있지 않다면, 맨 위에
import { vi } from 'vitest'를 추가해야 합니다.
// vitest.config.ts
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'
export default defineConfig((configEnv) =>
mergeConfig(
viteConfig(configEnv),
defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./setup-vitest.ts'],
},
}),
),
)
다음 예제들에서는 useCounterStore를 사용해 볼 거예요.
📝 참고: 이 모든 예제들은 TypeScript를 사용하여 작성되었습니다.
💡 강사의 팁: > 아래 코드는 React 컴포넌트가 Zustand 스토어와 잘 연결되어 렌더링되고 작동하는지를 확인하는 테스트입니다. 화면에 우리가 원하는 UI 요소가 잘 그려지는지(
render,screen), 사용자가 버튼을 눌렀을 때(userEvent) 상태 변화가 화면에 잘 반영되는지 확인하는 아주 전형적이고 좋은 테스트 패턴이에요!
// shared/counter-store-creator.ts
import { type StateCreator } from 'zustand'
export type CounterStore = {
count: number
inc: () => void
}
export const counterStoreCreator: StateCreator<CounterStore> = (set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
})
// stores/use-counter-store.ts
import { create } from 'zustand'
import {
type CounterStore,
counterStoreCreator,
} from '../shared/counter-store-creator'
export const useCounterStore = create<CounterStore>()(counterStoreCreator)
// contexts/use-counter-store-context.tsx
import { type ReactNode, createContext, useContext, useState } from 'react'
import { createStore } from 'zustand'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { shallow } from 'zustand/shallow'
import {
type CounterStore,
counterStoreCreator,
} from '../shared/counter-store-creator'
export const createCounterStore = () => {
return createStore<CounterStore>(counterStoreCreator)
}
export type CounterStoreApi = ReturnType<typeof createCounterStore>
export const CounterStoreContext = createContext<CounterStoreApi | undefined>(
undefined,
)
export interface CounterStoreProviderProps {
children: ReactNode
}
export const CounterStoreProvider = ({
children,
}: CounterStoreProviderProps) => {
const [store] = useState(() => createCounterStore())
return (
<CounterStoreContext.Provider value={store}>
{children}
</CounterStoreContext.Provider>
)
}
export type UseCounterStoreContextSelector<T> = (store: CounterStore) => T
export const useCounterStoreContext = <T,>(
selector: UseCounterStoreContextSelector<T>,
): T => {
const counterStoreContext = useContext(CounterStoreContext)
if (counterStoreContext === undefined) {
throw new Error(
'useCounterStoreContext must be used within CounterStoreProvider',
)
}
return useStoreWithEqualityFn(counterStoreContext, selector, shallow)
}
// components/counter/counter.tsx
import { useCounterStore } from '../../stores/use-counter-store'
export function Counter() {
const { count, inc } = useCounterStore()
return (
<div>
<h2>Counter Store</h2>
<h4>{count}</h4>
<button onClick={inc}>One Up</button>
</div>
)
}
// components/counter/index.ts
export * from './counter'
// components/counter/counter.test.tsx
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './counter'
describe('Counter', () => {
test('should render with initial state of 1', async () => {
renderCounter()
expect(await screen.findByText(/^1$/)).toBeInTheDocument()
expect(
await screen.findByRole('button', { name: /one up/i }),
).toBeInTheDocument()
})
test('should increase count by clicking a button', async () => {
const user = userEvent.setup()
renderCounter()
expect(await screen.findByText(/^1$/)).toBeInTheDocument()
await user.click(await screen.findByRole('button', { name: /one up/i }))
expect(await screen.findByText(/^2$/)).toBeInTheDocument()
})
})
const renderCounter = () => {
return render(<Counter />)
}
// components/counter-with-context/counter-with-context.tsx
import {
CounterStoreProvider,
useCounterStoreContext,
} from '../../contexts/use-counter-store-context'
const Counter = () => {
const { count, inc } = useCounterStoreContext((state) => state)
return (
<div>
<h2>Counter Store Context</h2>
<h4>{count}</h4>
<button onClick={inc}>One Up</button>
</div>
)
}
export const CounterWithContext = () => {
return (
<CounterStoreProvider>
<Counter />
</CounterStoreProvider>
)
}
// components/counter-with-context/index.ts
export * from './counter-with-context'
// components/counter-with-context/counter-with-context.test.tsx
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CounterWithContext } from './counter-with-context'
describe('CounterWithContext', () => {
test('should render with initial state of 1', async () => {
renderCounterWithContext()
expect(await screen.findByText(/^1$/)).toBeInTheDocument()
expect(
await screen.findByRole('button', { name: /one up/i }),
).toBeInTheDocument()
})
test('should increase count by clicking a button', async () => {
const user = userEvent.setup()
renderCounterWithContext()
expect(await screen.findByText(/^1$/)).toBeInTheDocument()
await user.click(await screen.findByRole('button', { name: /one up/i }))
expect(await screen.findByText(/^2$/)).toBeInTheDocument()
})
})
const renderCounterWithContext = () => {
return render(<CounterWithContext />)
}
📝 참고: globals configuration이 활성화되어 있지 않다면, 각 테스트 파일의 맨 위에
import { describe, test, expect } from 'vitest'를 추가해야 합니다.
다음 예제들에서도 useCounterStore를 사용해 볼 거예요.
💡 강사의 팁: > 이번에는 React UI(컴포넌트)를 렌더링하지 않고 Zustand 스토어(비즈니스 로직) 그 자체만 테스트하는 방법입니다. 화면이 어떻게 그려지는지는 상관없이 "증가 버튼 함수를 호출하면 값이 1 증가하는가?" 처럼 데이터 중심의 테스트를 할 때 유용해요. 속도도 훨씬 빠르고요!
📝 참고: 이 예제들 역시 모두 TypeScript를 사용하여 작성되었습니다.
// shared/counter-store-creator.ts
import { type StateCreator } from 'zustand'
export type CounterStore = {
count: number
inc: () => void
}
export const counterStoreCreator: StateCreator<CounterStore> = (set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
})
// stores/use-counter-store.ts
import { create } from 'zustand'
import {
type CounterStore,
counterStoreCreator,
} from '../shared/counter-store-creator'
export const useCounterStore = create<CounterStore>()(counterStoreCreator)
// contexts/use-counter-store-context.tsx
import { type ReactNode, createContext, useContext, useState } from 'react'
import { createStore } from 'zustand'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { shallow } from 'zustand/shallow'
import {
type CounterStore,
counterStoreCreator,
} from '../shared/counter-store-creator'
export const createCounterStore = () => {
return createStore<CounterStore>(counterStoreCreator)
}
export type CounterStoreApi = ReturnType<typeof createCounterStore>
export const CounterStoreContext = createContext<CounterStoreApi | undefined>(
undefined,
)
export interface CounterStoreProviderProps {
children: ReactNode
}
export const CounterStoreProvider = ({
children,
}: CounterStoreProviderProps) => {
const [store] = useState(() => createCounterStore())
return (
<CounterStoreContext.Provider value={store}>
{children}
</CounterStoreContext.Provider>
)
}
export type UseCounterStoreContextSelector<T> = (store: CounterStore) => T
export const useCounterStoreContext = <T,>(
selector: UseCounterStoreContextSelector<T>,
): T => {
const counterStoreContext = useContext(CounterStoreContext)
if (counterStoreContext === undefined) {
throw new Error(
'useCounterStoreContext must be used within CounterStoreProvider',
)
}
return useStoreWithEqualityFn(counterStoreContext, selector, shallow)
}
// components/counter/counter.tsx
import { useCounterStore } from '../../stores/use-counter-store'
export function Counter() {
const { count, inc } = useCounterStore()
return (
<div>
<h2>Counter Store</h2>
<h4>{count}</h4>
<button onClick={inc}>One Up</button>
</div>
)
}
// components/counter/index.ts
export * from './counter'
// components/counter/counter.test.tsx
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter, useCounterStore } from '../../../stores/use-counter-store.ts'
describe('Counter', () => {
test('should render with initial state of 1', async () => {
renderCounter()
expect(useCounterStore.getState().count).toBe(1)
})
test('should increase count by clicking a button', async () => {
const user = userEvent.setup()
renderCounter()
expect(useCounterStore.getState().count).toBe(1)
await user.click(await screen.findByRole('button', { name: /one up/i }))
expect(useCounterStore.getState().count).toBe(2)
})
})
const renderCounter = () => {
return render(<Counter />)
}
// components/counter-with-context/counter-with-context.tsx
import {
CounterStoreProvider,
useCounterStoreContext,
} from '../../contexts/use-counter-store-context'
const Counter = () => {
const { count, inc } = useCounterStoreContext((state) => state)
return (
<div>
<h2>Counter Store Context</h2>
<h4>{count}</h4>
<button onClick={inc}>One Up</button>
</div>
)
}
export const CounterWithContext = () => {
return (
<CounterStoreProvider>
<Counter />
</CounterStoreProvider>
)
}
// components/counter-with-context/index.ts
export * from './counter-with-context'
// components/counter-with-context/counter-with-context.test.tsx
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CounterStoreContext } from '../../../contexts/use-counter-store-context'
import { counterStoreCreator } from '../../../shared/counter-store-creator'
describe('CounterWithContext', () => {
test('should render with initial state of 1', async () => {
const counterStore = counterStoreCreator()
renderCounterWithContext(counterStore)
expect(counterStore.getState().count).toBe(1)
expect(
await screen.findByRole('button', { name: /one up/i }),
).toBeInTheDocument()
})
test('should increase count by clicking a button', async () => {
const user = userEvent.setup()
const counterStore = counterStoreCreator()
renderCounterWithContext(counterStore)
expect(counterStore.getState().count).toBe(1)
await user.click(await screen.findByRole('button', { name: /one up/i }))
expect(counterStore.getState().count).toBe(2)
})
})
const renderCounterWithContext = (store) => {
return render(<CounterWithContext />, {
wrapper: ({ children }) => (
<CounterStoreContext.Provider value={store}>
{children}
</CounterStoreContext.Provider>
),
})
}
react-dom 및 react-dom/test-utils 위에 유틸리티 함수를 제공하죠. 이 라이브러리의 핵심 가이드 원칙은 바로 이것입니다: "여러분의 테스트가 소프트웨어가 실제 사용되는 방식과 유사할수록, 그 테스트는 여러분에게 더 많은 확신을 줄 수 있습니다."react-test-renderer를 기반으로 구축되어 있습니다.이전 페이지: Initialize state with props
다음 페이지: Flux inspired practice
수고하셨습니다! 공식 문서를 직접 번역하며 읽는 훈련은 주니어 개발자에서 미들급으로 성장하는 데 있어 아주 좋은 자양분이 됩니다. 읽으시다가 또 궁금한 점이 생기면 언제든 질문해 주세요!