・Next.js: 13.5.2
・Vitest: 0.34.4
・react-testing-library: 14.0.0
・happy-dom: 12.1.5
・coverage-v8: 0.34.4
1. 以下のコマンドを入力し、Vitestの実行に必要なライブラリをインストールします。
$ npm install --save-dev vitest @testing-library/react happy-dom @vitejs/plugin-react
2. プロジェクトのルート配下に「vitest.config.ts」を新規作成し、以下の内容で保存します。
// vitest.config.ts
import react from '@vitejs/plugin-react'
import path from 'path'
import { defineConfig } from 'vitest/config'
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'happy-dom',
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
      '~': path.resolve(__dirname, './src'),
    },
  },
})
3. 「package.json」のscriptsセクションに以下を追加します。
{
  "scripts": {
    "test": "vitest",
  }
}
4. 以下のコマンドを入力することで、Vitestを実行して単体テストを実施することができます。
$ npm run test
> vitest
 ✓ src/unit-test/test.spec.tsx (1)
   ✓ some test...
 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  18:36:43
   Duration  154ms※現時点ではカバレッジが表示されないため、次項ではVitest実行時のテスト結果にカバレッジを表示する手順を記載しています。
1. 以下のコマンドを入力し、カバレッジの取得に必要なライブラリをインストールします。
$ npm install --save-dev @vitest/coverage-v8
2. 「vitest.config.ts」のtestセクションに以下を追加します。
// vitest.config.ts
import react from '@vitejs/plugin-react'
import path from 'path'
import { defineConfig } from 'vitest/config'
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'happy-dom',
    // ここから追加
    coverage: {
      provider: 'v8',
      include: ['src/**/*.{tsx,js,ts}'],
      all: true,
      reporter: ['html', 'clover', 'text']
    },
    root: '.',
    reporters: ['verbose'],
    // ここまでを追加
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
      '~': path.resolve(__dirname, './src'),
    },
  },
})
3. 「package.json」のscriptsセクションに追加したvitestコマンドに「--coverage」を追加します。
{
  "scripts": {
    "test": "vitest --coverage",
  }
}
4. 再度Vitestを実行して単体テストを実施し、カバレッジが表示されていることを確認します。
$ npm run test
> vitest
 ✓ src/unit-test/test.spec.tsx (1)
   ✓ some test...
 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  18:36:43
   Duration  154ms
 % Coverage report from v8
-------------|---------|----------|---------|---------|-------------------
File         | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
-------------|---------|----------|---------|---------|-------------------
All files    |    9.39 |       50 |      50 |    9.39 |                   
 app         |       0 |        0 |       0 |       0 |                   
  layout.tsx |       0 |        0 |       0 |       0 | 1-22              
  page.tsx   |       0 |        0 |       0 |       0 | 1-113             
 app/test    |     100 |      100 |     100 |     100 |                   
  page.tsx   |     100 |      100 |     100 |     100 |                   
-------------|---------|----------|---------|---------|-------------------'use client'
import { useState } from 'react'
export default function Test() {
  const [count, setCount] = useState(0)
  const handleClick = () => {
    setCount(count + 1)
  }
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={handleClick}>Click</button>
    </div>
  )
}import { render, fireEvent } from '@testing-library/react'
import { expect, test, describe } from 'vitest'
import Test from './page'
describe('test/page.tsxのテスト', () => {
  describe('画面の初期表示確認', () => {
    test('クリック数が初期表示として「0」が表示されていること', () => {
      // Arrange
      const { getByText } = render(<Test />)
      // Assert
      expect(getByText('You clicked 0 times')).toBeDefined()
    })
  })
  test('ボタンをクリックした場合、クリック数が画面に表示されていること', () => {
    // Arrange
    const { getByText } = render(<Test />)
    // Act
    fireEvent.click(getByText('Click'))
    // Assert
    expect(getByText('You clicked 1 times')).toBeDefined()
  })
})$ npm run test
✓ src/app/test/page.test.tsx (2)
   ✓ test/page.tsxのテスト (2)
     ✓ 画面の初期表示確認 (1)
       ✓ クリック数が初期表示として「0」が表示されていること
     ✓ ボタンをクリックした場合、クリック数が画面に表示されていること
 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  18:59:31
   Duration  161ms
 % Coverage report from v8
-------------|---------|----------|---------|---------|-------------------
File         | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------|---------|----------|---------|---------|-------------------
All files    |    12.9 |    66.66 |   33.33 |    12.9 | 
 app         |       0 |        0 |       0 |       0 | 
  layout.tsx |       0 |        0 |       0 |       0 | 1-22
  page.tsx   |       0 |        0 |       0 |       0 | 1-113
 app/test    |     100 |      100 |     100 |     100 | 
  page.tsx   |     100 |      100 |     100 |     100 | 
-------------|---------|----------|---------|---------|-------------------※JSONPlaceholder:RESTで実装されたAPIサーバー。ダミーデータを返す。
※notFound()関数:Next.jsの機能の1つ。notFound関数をコールすると、not-found.tsxを描画することができます。詳細はこちら。
import { notFound } from 'next/navigation'
export default async function Test() {
  const posts = await fetch(`https://jsonplaceholder.typicode.com/posts`).then((res) => res.json())
  if (!posts || posts.length == 0) {
    notFound()
  }
  return (
    <div>
      <h2>Post List</h2>
      {posts.map((post) => (
        <div key={post.id}>
          <h3>{post.title}</h3>
          <p title={post.body}>{post.body}</p>
        </div>
      ))}
    </div>
  )
}import { render } from '@testing-library/react'
import { expect, test, describe, vi, afterEach } from 'vitest'
import Test from './page'
const notFoundMock = vi.hoisted(() => vi.fn())
const responseData = [
  {
    id: 1,
    title: 'test title 1',
    body: 'test body 1',
  },
  {
    id: 2,
    title: 'test title 2',
    body: 'test body 2',
  },
]
describe('test/page.tsxのテスト', () => {
  const response = {} as Response
  vi.mock('next/navigation', () => ({
    notFound: notFoundMock,
  }))
  afterEach(() => {
    vi.restoreAllMocks()
  })
  test('投稿の一覧が画面に表示されていること', async () => {
    // Arrange
    response.json = vi.fn().mockResolvedValue(responseData)
    vi.spyOn(global, 'fetch').mockResolvedValue(response)
    const { getByText } = render(await Test())
    // Assert
    expect(getByText('test title 1')).toBeDefined()
    expect(getByText('test body 1')).toBeDefined()
    expect(getByText('test title 2')).toBeDefined()
    expect(getByText('test body 2')).toBeDefined()
    expect(notFoundMock).not.toBeCalled()
  })
  test('投稿が取得できなかった場合、notFound関数がコールされること', async () => {
    // Arrange
    response.json = vi.fn().mockResolvedValue([])
    vi.spyOn(global, 'fetch').mockResolvedValue(response)
    render(await Test())
    // Assert
    expect(notFoundMock).toHaveBeenCalledOnce()
  })
})$ npm run test
 ✓ src/app/test/page.test.tsx (2)
   ✓ test/page.tsxのテスト (2)
     ✓ 投稿の一覧が画面に表示されていること
     ✓ 投稿が取得できなかった場合、notFound関数がコールされること
 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  19:14:17
   Duration  846ms (transform 34ms, setup 0ms, collect 146ms, tests 13ms, environment 127ms, prepare 77ms)
 % Coverage report from v8
-------------|---------|----------|---------|---------|-------------------
File         | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------|---------|----------|---------|---------|-------------------
All files    |    12.9 |    66.66 |   33.33 |    12.9 | 
 app         |       0 |        0 |       0 |       0 | 
  layout.tsx |       0 |        0 |       0 |       0 | 1-22
  page.tsx   |       0 |        0 |       0 |       0 | 1-113
 app/test    |     100 |      100 |     100 |     100 | 
  page.tsx   |     100 |      100 |     100 |     100 | 
-------------|---------|----------|---------|---------|-------------------
以上で全ての手順は完了になります