스토어 테스트
스토어는 설계상 여러 곳에서 사용되며, 이로 인해 테스트가 생각보다 훨씬 더 어려워질 수 있습니다. 다행히도, 반드시 그럴 필요는 없습니다. 스토어를 테스트할 때는 세 가지를 신경 써야 합니다:
pinia
인스턴스: 스토어는 이것 없이는 동작하지 않습니다actions
: 대부분의 경우, 스토어에서 가장 복잡한 로직을 담고 있습니다. 기본적으로 mock 처리된다면 정말 좋지 않을까요?- 플러그인: 플러그인에 의존한다면, 테스트에서도 설치해야 합니다
무엇을, 어떻게 테스트하느냐에 따라 이 세 가지를 다루는 방법이 달라집니다.
스토어 단위 테스트
스토어를 단위 테스트하려면, 가장 중요한 부분은 pinia
인스턴스를 생성하는 것입니다:
// stores/counter.spec.ts import { setActivePinia, createPinia } from 'pinia' import { useCounterStore } from '../src/stores/counter' describe('Counter Store', () => { beforeEach(() => { // 새로운 pinia를 생성하고 활성화합니다 // 그래서 어떤 useStore() 호출에서도 자동으로 사용됩니다 // pinia를 직접 전달할 필요 없이: `useStore(pinia)` setActivePinia(createPinia()) }) it('increments', () => { const counter = useCounterStore() expect(counter.n).toBe(0) counter.increment() expect(counter.n).toBe(1) }) it('increments by amount', () => { const counter = useCounterStore() counter.increment(10) expect(counter.n).toBe(10) }) })
스토어 플러그인이 있다면, 한 가지 중요한 점이 있습니다: 플러그인은 pinia
가 App에 설치되기 전까지는 사용되지 않습니다. 이는 빈 App이나 가짜 App을 생성하여 해결할 수 있습니다:
import { setActivePinia, createPinia } from 'pinia' import { createApp } from 'vue' import { somePlugin } from '../src/stores/plugin' // 위와 동일한 코드... // 테스트마다 앱을 하나씩 만들 필요는 없습니다 const app = createApp({}) beforeEach(() => { const pinia = createPinia().use(somePlugin) app.use(pinia) setActivePinia(pinia) })
컴포넌트 단위 테스트
이것은 createTestingPinia()
로 달성할 수 있으며, 이는 컴포넌트 단위 테스트를 돕기 위해 설계된 pinia 인스턴스를 반환합니다.
먼저 @pinia/testing
을 설치하세요:
npm i -D @pinia/testing
그리고 컴포넌트를 마운트할 때 테스트용 pinia를 생성해야 합니다:
import { mount } from '@vue/test-utils' import { createTestingPinia } from '@pinia/testing' // 테스트에서 상호작용할 스토어를 import import { useSomeStore } from '@/stores/myStore' const wrapper = mount(Counter, { global: { plugins: [createTestingPinia()], }, }) const store = useSomeStore() // 테스트용 pinia를 사용합니다! // state는 직접 조작할 수 있습니다 store.name = 'my new name' // patch를 통해서도 가능합니다 store.$patch({ name: 'new name' }) expect(store.name).toBe('new name') // actions는 기본적으로 stub 처리되어, 기본적으로 코드를 실행하지 않습니다. // 아래에서 이 동작을 커스터마이즈하는 방법을 확인하세요. store.someAction() expect(store.someAction).toHaveBeenCalledTimes(1) expect(store.someAction).toHaveBeenLastCalledWith()
초기 상태 설정
테스트용 pinia를 생성할 때 initialState
객체를 전달하여 모든 스토어의 초기 상태를 설정할 수 있습니다. 이 객체는 스토어가 생성될 때 테스트용 pinia가 patch 하는 데 사용됩니다. 예를 들어, 이 스토어의 상태를 초기화하고 싶다고 가정해봅시다:
import { defineStore } from 'pinia' const useCounterStore = defineStore('counter', { state: () => ({ n: 0 }), // ... })
스토어 이름이 _"counter"_이므로, initialState
에 일치하는 객체를 추가해야 합니다:
// 테스트 내 어딘가에서 const wrapper = mount(Counter, { global: { plugins: [ createTestingPinia({ initialState: { counter: { n: 20 }, // 카운터를 0이 아닌 20에서 시작 }, }), ], }, }) const store = useSomeStore() // 테스트용 pinia를 사용합니다! store.n // 20
액션 동작 커스터마이즈
createTestingPinia
는 별도의 지시가 없는 한 모든 스토어 액션을 stub 처리합니다. 이를 통해 컴포넌트와 스토어를 별도로 테스트할 수 있습니다.
이 동작을 되돌리고, 테스트 중에 액션이 실제로 실행되게 하려면, createTestingPinia
호출 시 stubActions: false
를 지정하세요:
const wrapper = mount(Counter, { global: { plugins: [createTestingPinia({ stubActions: false })], }, }) const store = useSomeStore() // 이제 이 호출은 스토어에 정의된 구현을 실제로 실행합니다 store.someAction() // ...하지만 여전히 spy로 감싸져 있으므로 호출을 검사할 수 있습니다 expect(store.someAction).toHaveBeenCalledTimes(1)
액션의 반환값 mock 처리
액션은 자동으로 spy 처리되지만, 타입상으로는 여전히 일반 액션입니다. 올바른 타입을 얻으려면, 각 액션에 Mock
타입을 적용하는 커스텀 타입 래퍼를 구현해야 합니다. 이 타입은 사용하는 테스트 프레임워크에 따라 다릅니다. 아래는 Vitest 예시입니다:
import type { Mock } from 'vitest' import type { UnwrapRef } from 'vue' import type { Store, StoreDefinition } from 'pinia' function mockedStore<TStoreDef extends () => unknown>( useStore: TStoreDef ): TStoreDef extends StoreDefinition< infer Id, infer State, infer Getters, infer Actions > ? Store< Id, State, Record<string, never>, { [K in keyof Actions]: Actions[K] extends (...args: any[]) => any ? // 👇 사용하는 테스트 프레임워크에 따라 다름 Mock<Actions[K]> : Actions[K] } > & { [K in keyof Getters]: UnwrapRef<Getters[K]> } : ReturnType<TStoreDef> { return useStore() as any }
이것은 테스트에서 올바른 타입의 스토어를 얻는 데 사용할 수 있습니다:
import { mockedStore } from './mockedStore' import { useSomeStore } from '@/stores/myStore' const store = mockedStore(useSomeStore) // 타입이 지정됨! store.someAction.mockResolvedValue('some value')
이와 같은 더 많은 트릭을 배우고 싶다면, Mastering Pinia의 Testing 강의를 확인해보세요.
createSpy 함수 지정
Jest를 사용하거나, globals: true
가 설정된 vitest를 사용할 때, createTestingPinia
는 기존 테스트 프레임워크(jest.fn
또는 vitest.fn
)에 따라 자동으로 액션을 spy 함수로 stub 처리합니다. globals: true
를 사용하지 않거나 다른 프레임워크를 사용하는 경우, createSpy 옵션을 제공해야 합니다:
// NOTE: `globals: true`에서는 필요 없음 import { vi } from 'vitest' createTestingPinia({ createSpy: vi.fn, })
import sinon from 'sinon' createTestingPinia({ createSpy: sinon.spy, })
더 많은 예시는 testing 패키지의 테스트에서 확인할 수 있습니다.
getter mock 처리
기본적으로, 모든 getter는 일반 사용과 같이 계산되지만, 원하는 값으로 getter를 직접 설정하여 강제로 값을 지정할 수 있습니다:
import { defineStore } from 'pinia' import { createTestingPinia } from '@pinia/testing' const useCounterStore = defineStore('counter', { state: () => ({ n: 1 }), getters: { double: (state) => state.n * 2, }, }) const pinia = createTestingPinia() const counter = useCounterStore(pinia) counter.double = 3 // 🪄 getter는 테스트에서만 쓰기 가능합니다 // undefined로 설정하면 기본 동작으로 리셋됩니다 // @ts-expect-error: 일반적으로는 숫자입니다 counter.double = undefined counter.double // 2 (=1 x 2)
Pinia 플러그인
Pinia 플러그인이 있다면, createTestingPinia()
를 호출할 때 반드시 전달하여 올바르게 적용되도록 하세요. 일반 pinia처럼 testingPinia.use(MyPlugin)
으로 추가하지 마세요:
import { createTestingPinia } from '@pinia/testing' import { somePlugin } from '../src/stores/plugin' // 어떤 테스트 내에서 const wrapper = mount(Counter, { global: { plugins: [ createTestingPinia({ stubActions: false, plugins: [somePlugin], }), ], }, })
E2E 테스트
Pinia와 관련해서는, E2E 테스트를 위해 별도로 변경할 필요가 없습니다. 이것이 바로 이러한 테스트의 핵심입니다! HTTP 요청을 테스트할 수도 있겠지만, 그건 이 가이드의 범위를 훨씬 벗어납니다 😄.