Skip to content

스토어 테스트

스토어는 설계상 여러 곳에서 사용되며, 이로 인해 테스트가 생각보다 훨씬 더 어려워질 수 있습니다. 다행히도, 반드시 그럴 필요는 없습니다. 스토어를 테스트할 때는 세 가지를 신경 써야 합니다:

  • pinia 인스턴스: 스토어는 이것 없이는 동작하지 않습니다
  • actions: 대부분의 경우, 스토어에서 가장 복잡한 로직을 담고 있습니다. 기본적으로 mock 처리된다면 정말 좋지 않을까요?
  • 플러그인: 플러그인에 의존한다면, 테스트에서도 설치해야 합니다

무엇을, 어떻게 테스트하느냐에 따라 이 세 가지를 다루는 방법이 달라집니다.

스토어 단위 테스트

스토어를 단위 테스트하려면, 가장 중요한 부분은 pinia 인스턴스를 생성하는 것입니다:

js
// 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을 생성하여 해결할 수 있습니다:

js
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을 설치하세요:

shell
npm i -D @pinia/testing

그리고 컴포넌트를 마운트할 때 테스트용 pinia를 생성해야 합니다:

js
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 하는 데 사용됩니다. 예를 들어, 이 스토어의 상태를 초기화하고 싶다고 가정해봅시다:

ts
import { defineStore } from 'pinia'  const useCounterStore = defineStore('counter', {  state: () => ({ n: 0 }),  // ... })

스토어 이름이 _"counter"_이므로, initialState에 일치하는 객체를 추가해야 합니다:

ts
// 테스트 내 어딘가에서 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를 지정하세요:

js
const wrapper = mount(Counter, {  global: {  plugins: [createTestingPinia({ stubActions: false })],  }, })  const store = useSomeStore()  // 이제 이 호출은 스토어에 정의된 구현을 실제로 실행합니다 store.someAction()  // ...하지만 여전히 spy로 감싸져 있으므로 호출을 검사할 수 있습니다 expect(store.someAction).toHaveBeenCalledTimes(1)

액션의 반환값 mock 처리

액션은 자동으로 spy 처리되지만, 타입상으로는 여전히 일반 액션입니다. 올바른 타입을 얻으려면, 각 액션에 Mock 타입을 적용하는 커스텀 타입 래퍼를 구현해야 합니다. 이 타입은 사용하는 테스트 프레임워크에 따라 다릅니다. 아래는 Vitest 예시입니다:

ts
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 }

이것은 테스트에서 올바른 타입의 스토어를 얻는 데 사용할 수 있습니다:

ts
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 옵션을 제공해야 합니다:

ts
// NOTE: `globals: true`에서는 필요 없음 import { vi } from 'vitest'  createTestingPinia({  createSpy: vi.fn, })
ts
import sinon from 'sinon'  createTestingPinia({  createSpy: sinon.spy, })

더 많은 예시는 testing 패키지의 테스트에서 확인할 수 있습니다.

getter mock 처리

기본적으로, 모든 getter는 일반 사용과 같이 계산되지만, 원하는 값으로 getter를 직접 설정하여 강제로 값을 지정할 수 있습니다:

ts
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)으로 추가하지 마세요:

js
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 요청을 테스트할 수도 있겠지만, 그건 이 가이드의 범위를 훨씬 벗어납니다 😄.