🧪

TypeScript移行 - Pinia storeのメモリリーク対策とVitestセットアップ

に公開

はじめに

Vue 3プロジェクトをJavaScriptからTypeScriptに移行する際、Pinia storeのメモリリーク問題に遭遇しました。本記事では、この問題を解決し、さらにVitestでテスト環境を構築した過程を共有します。

背景

Toast通知を管理するPinia storeを実装していたのですが、元のJavaScriptコードには潜在的なメモリリークの問題がありました。

toast.js(問題あり)
export const useToastStore = defineStore('toast', () => {
  const toasts = ref([])
  let nextId = 0

  function showSuccess(message, duration = 3000) {
    const id = nextId++
    toasts.value.push({ id, message, type: 'success', duration })

    // 自動削除タイマーを設定
    setTimeout(() => {
      dismissToast(id)
    }, duration)
  }

  function dismissToast(id) {
    toasts.value = toasts.value.filter(t => t.id !== id)
  }

  return { toasts, showSuccess, dismissToast }
})

問題点:

  1. setTimeoutのタイマーIDを保存していない
  2. 手動でdismissToastを呼んでも、タイマーがキャンセルされない
  3. ページ遷移時にタイマーがクリーンアップされない

解決策1: タイマーIDの管理

まず、タイマーIDをMapで管理するようにしました。

toast.ts
const timeoutMap = new Map<number, ReturnType<typeof setTimeout>>()

function createToast(message: string, type: ToastType, duration: number): void {
  const id = nextId++
  toasts.value.push({ id, message, type, duration })

  // タイマーIDを保存
  const timeoutId = setTimeout(() => {
    dismissToast(id)
  }, duration)
  timeoutMap.set(id, timeoutId)
}

function dismissToast(id: number): void {
  // 保存したタイマーをキャンセル
  const timeoutId = timeoutMap.get(id)
  if (timeoutId !== undefined) {
    clearTimeout(timeoutId)
    timeoutMap.delete(id)
  }

  toasts.value = toasts.value.filter(t => t.id !== id)
}

ポイント: ReturnType<typeof setTimeout>

setTimeoutの戻り値の型は環境によって異なります:

  • ブラウザ: number
  • Node.js: NodeJS.Timeout

ReturnType<typeof setTimeout>を使うことで、環境に応じて自動的に正しい型が適用されます。

解決策2: onScopeDisposeでクリーンアップ

ページ遷移時にタイマーをクリーンアップするため、onScopeDisposeを追加しました。

import { onScopeDispose } from 'vue'

export const useToastStore = defineStore('toast', (): ToastStore => {
  // ... 前述のコード

  // ストア破棄時のクリーンアップ
  onScopeDispose(() => {
    // 全タイマーをキャンセル
    timeoutMap.forEach(timeoutId => {
      clearTimeout(timeoutId)
    })
    timeoutMap.clear()
    toasts.value = []
  })

  return { toasts, showSuccess, showError, dismissToast }
})

これにより、ページ遷移やアプリ終了時に残っているタイマーが自動的にクリーンアップされます。

Vitestのセットアップ

次に、TypeScript化したコードのテスト環境を構築しました。

インストール

npm install -D vitest @vue/test-utils @pinia/testing happy-dom

vite.config.js設定

vite.config.js
export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,
    environment: 'happy-dom',
  },
})

package.json

package.json
{
  "scripts": {
    "test": "vitest"
  }
}

テストコードの作成

Toast Storeのテスト

toast.test.ts
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useToastStore } from './toast'

describe('useToastStore', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
    vi.useFakeTimers()  // タイマーをモック
  })

  afterEach(() => {
    vi.restoreAllMocks()
  })

  it('should add a success toast', () => {
    const store = useToastStore()
    store.showSuccess('Success message')

    expect(store.toasts).toHaveLength(1)
    expect(store.toasts[0]).toMatchObject({
      message: 'Success message',
      type: 'success',
      duration: 3000
    })
  })

  it('should auto-dismiss after duration', () => {
    const store = useToastStore()
    store.showSuccess('Auto dismiss')

    expect(store.toasts).toHaveLength(1)

    // 3秒後にタイマーを進める
    vi.advanceTimersByTime(3000)

    expect(store.toasts).toHaveLength(0)
  })

  it('should cancel auto-dismiss when manually dismissed', () => {
    const store = useToastStore()
    store.showSuccess('Manual dismiss')

    const toastId = store.toasts[0].id
    store.dismissToast(toastId)

    expect(store.toasts).toHaveLength(0)

    // タイマーを進めてもエラーにならない
    vi.advanceTimersByTime(3000)
    expect(store.toasts).toHaveLength(0)
  })
})

テストファイルの配置

テストファイルはソースファイルと同じディレクトリに配置しました:

src/
├── stores/
│   ├── toast.ts
│   └── toast.test.ts  ← 同じディレクトリ
├── api/
│   ├── client.ts
│   └── client.test.ts
└── utils/
    ├── formatters.ts
    └── formatters.test.ts

この配置には以下のメリットがあります:

  • テストとソースが近い
  • インポートパスが短い
  • リファクタリング時に一緒に移動しやすい
  • Vue/Viteの推奨パターン

テスト結果

npm test

 ✓ src/utils/formatters.test.ts (16 tests)
 ✓ src/utils/signalr.test.ts (4 tests)
 ✓ src/stores/toast.test.ts (11 tests)
 ✓ src/api/client.test.ts (5 tests)
 ✓ src/api/devices.test.ts (4 tests)
 ✓ src/api/users.test.ts (13 tests)

Test Files  6 passed (6)
     Tests  53 passed (53)

学んだこと

1. 型の理解が重要

TypeScriptの基本概念(Ref<T>, ジェネリクス、ユーティリティ型など)を理解することで、より安全なコードが書けます。

const toasts = ref<Toast[]>([])
//             ^^^  ^^^^^^  ^^
//             関数  型     初期値

refは関数、Refは型、Toast[]は配列型という3つの要素を理解することが大切です。

2. 環境依存の型を吸収する

ReturnType<typeof setTimeout>のようなユーティリティ型を使うことで、環境(ブラウザ/Node.js)による型の違いを吸収できます。

3. テストでメモリリークを検証

Vitestのvi.useFakeTimers()を使うことで、タイマーが適切にクリーンアップされているかをテストで検証できます。

it('should cancel auto-dismiss when manually dismissed', () => {
  store.dismissToast(toastId)

  // タイマーを進めてもエラーにならない = クリーンアップ成功
  vi.advanceTimersByTime(3000)
  expect(store.toasts).toHaveLength(0)
})

まとめ

  • メモリリーク対策: MapでタイマーIDを管理し、onScopeDisposeでクリーンアップ
  • 型安全性: ReturnType<typeof setTimeout>で環境依存を吸収
  • テスト環境: Vitestでモダンなテスト環境を構築
  • テスト駆動: タイマーのクリーンアップをテストで検証

TypeScript移行は単なる型付けではなく、コードの品質向上の機会です。メモリリーク対策とテストを組み合わせることで、安心して運用できるコードになりました。

参考リンク

Discussion