N-LAB

Next.js 13 × Vitest × React Testing Libraryで単体テストの実行とカバレッジを取得する

目標


条件

・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インストール
  2. カバレッジの取得
  3. Client Componentsの単体テストの実装
  4. Server Componentsの単体テストの実装


Vitestインストール

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


Client Componentsの単体テストの実装

  1. 画面の初期表示確認。
  2. 画面に表示されているボタンをクリックした場合、クリックした回数が正しく表示されているか。


'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 | 
-------------|---------|----------|---------|---------|-------------------


Server Componentsの単体テストの実装

※JSONPlaceholder:RESTで実装されたAPIサーバー。ダミーデータを返す。
※notFound()関数:Next.jsの機能の1つ。notFound関数をコールすると、not-found.tsxを描画することができます。詳細はこちら

  1. 取得した投稿が画面に正しく表示されているか。
  2. 投稿が取得できなかった場合はnotFound関数がコールされているか。


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


以上で全ての手順は完了になります