next.jsのテストをやってみたメモ(reactでも使えるかと思います)

17 min read

これは個人的なメモです(参考にならないと思いますのでもし良いものがあったら選んで持って帰ってください)

設定

テスト内容は確認しておきたいからその設定したいな。

"test": "jest --env=jsdom --verbose"

ページ遷移のテスト

next-page-testerをインストールします。

npm i next-page-tester

next-page-testerを使う場合はテストの関数をasyncにしておく必要がある

describe('Navigation by Link', () => {
  it('Should route to selected page in navbar'), async () => {
    
  }
})

取得したいページの取得方法

async () => {
      const { page } = await getPage({
        route: '/index',
      }) // この書き方で取得したいページが格納できる
      render(page)
      // 取得したページをレンダー
      // testidを頼りにクリックをする
    }

ページ遷移の場合はdata-testidを頼りにクリックイベントを実施

userEvent.click(screen.getByTestId('blog-nav'))

これでページが遷移できているか確認して遷移先にテキストがあるかを評価することでページ遷移のテストが可能になる。

expect(await screen.findByText('blog page')).toBeInTheDocument()

外部APIなどを取得する場合はinterfaceで型を定義しておくとよい
またその型定義はtypesディレクトリの中のtypes.tsファイルの中に保存しておくと管理が楽。

export interface POST {
  userId: number
  id: number
  title: string
  body: string
}

export interface COMMENT {
  postId: number
  id: number
  name: string
  email: string
  body: string
}

export interface TASK {
  userId: number
  id: number
  title: string
  completed: boolean
}

これは必要なメモなんかどうか分からないけどサーバーサイドで実行される内容はlibフォルダに格納するみたい

getStaticPropsやgetStaticPathsのテスト方法

依存関係をインポートする

msw(mock service worker)からrestとsetupServerをimportする

// 依存関係のimport
import '@testing-library/jest-dom/extend-expect'
import { render, screen, cleanup } from '@testing-library/react'
import { getPage } from 'next-page-tester'
import { initTestHelpers } from 'next-page-tester'
import { rest } from 'msw'
import { setupServer } from 'msw/node'


// next-page-testerのinitTestHelpersを実行します。
initTestHelpers()

blog-page.tsxでは

export const getStaticProps: GetStaticProps = async () => {
  const posts = await getAllPostsData()
  return {
    props: { posts },
  }
}

が実行されるのでgetAllPostsData()が実行される

なのでapiのレスポンスをモックする

handlersの中にAPIのレスポンスを定義する

const handlers = [
  rest.get('https://jsonplaceholder.typicode.com/posts/', (req, res, ctx) => {
    const query = req.url.searchParams
    const _limit = query.get('_limit')
    if (_limit === '10') {
      return res(
      // 以下はダミーデータです
        ctx.status(200),
        ctx.json([
          {
            userId: 1,
            id: 1,
            title: 'dummy title 1',
            body: 'dummy body 1',
          },
          {
            userId: 2,
            id: 2,
            title: 'dummy title 2',
            body: 'dummy body 2',
          },
        ])
      )
    }
  }),
]

セットアップサーバーを立てます。

const server = setupServer(...handlers)

beforeAllでモックサーバーを起動します

beforeAll(() => {
  server.listen()
})

afterEachでサーバーのリセットとクリーンアップ

afterEach(() => {
  server.resetHandlers()
  cleanup()
})

afterAllでサーバーを閉じます

afterAll(() => {
  server.close()
})

テストケース

describe(`Blog page`, () => {
  it('Should render the list of blogs pre-fetched by getStaticProps', async () => {
  // blog-pageを取得する
    const { page } = await getPage({
      route: '/blog-page',
    })
    // renderでpageの内容を取得する
    render(page)
    // ブログページのテキストが取得できるまで待機する
    expect(await screen.findByText('blog page')).toBeInTheDocument()
    // ダミーデータのレスポンスがDOMに表示されているかを確認する
    expect(screen.getByText('dummy title 1')).toBeInTheDocument()
    expect(screen.getByText('dummy title 2')).toBeInTheDocument()
  })
})

個別のページのレンダリングが行われているかの確認

必要なモジュールのインポート

import '@testing-library/jest-dom/extend-expect'
import { render, screen, cleanup } from '@testing-library/react'
import { getPage } from 'next-page-tester'
import { initTestHelpers } from 'next-page-tester'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import userEvent from '@testing-library/user-event'

ページヘルパーの初期化

initTestHelpers()

モック化するためのhandlersを作成
モック化するのは一覧のレスポンスのモックと
id1と2の個別化したモックのレスポンスをモック化する

const handlers = [
  rest.get('https://jsonplaceholder.typicode.com/posts/', (req, res, ctx) => {
    const query = req.url.searchParams
    const _limit = query.get('_limit')
    if (_limit === '10') {
      return res(
        ctx.status(200),
        ctx.json([
          {
            userId: 1,
            id: 1,
            title: 'dummy title 1',
            body: 'dummy body 1',
          },
          {
            userId: 2,
            id: 2,
            title: 'dummy title 2',
            body: 'dummy body 2',
          },
        ])
      )
    }
  }),
  rest.get('https://jsonplaceholder.typicode.com/posts/1', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        userId: 1,
        id: 1,
        title: 'dummy title 1',
        body: 'dummy body 1',
      })
    )
  }),
  rest.get('https://jsonplaceholder.typicode.com/posts/2', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        userId: 2,
        id: 2,
        title: 'dummy title 2',
        body: 'dummy body 2',
      })
    )
  }),
]

サーバー起動

const server = setupServer(...handlers)
beforeAll(() => {
  server.listen()
})

afterEachでサーバーのリセットとクリーンアップ

afterEach(() => {
  server.resetHandlers()
  cleanup()
})

afterAllでサーバーを閉じます

afterAll(() => {
  server.close()
})

テストケース
posts/1にアクセスした時
posts/2にアクセスした時
posts/2からuserEventで戻る時

describe(`Blog detail page`, () => {
  it('Should render detailed content of ID 1', async () => {
    const { page } = await getPage({
      route: '/posts/1',
    })
    render(page)
    expect(await screen.findByText('dummy title 1')).toBeInTheDocument()
    expect(screen.getByText('dummy body 1')).toBeInTheDocument()
    //screen.debug()
  })
  it('Should render detailed content of ID 2', async () => {
    const { page } = await getPage({
      route: '/posts/2',
    })
    render(page)
    expect(await screen.findByText('dummy title 2')).toBeInTheDocument()
    expect(screen.getByText('dummy body 2')).toBeInTheDocument()
  })
  it('Should route back to blog-page from detail page', async () => {
    const { page } = await getPage({
      route: '/posts/2',
    })
    render(page)
    await screen.findByText('dummy title 2')
    userEvent.click(screen.getByTestId('back-blog'))
    expect(await screen.findByText('blog page')).toBeInTheDocument()
  })
})

Componentsにテスト用のpropsを渡してテストする

必要な依存関係をimportする

import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import Post from '../components/Post'
import { POST } from '../types/Types'

dummyPropsを定義する

let dummyProps: POST

beforeEachでテストデータを指定する

  beforeEach(() => {
    dummyProps = {
      userId: 1,
      id: 1,
      title: 'dummy title 1',
      body: 'dummy body 1',
    }
  })

テストケースを記載する

  it('Should render correctly with given props value', () => {
    render(<Post {...dummyProps} />)
    expect(screen.getByText(dummyProps.id)).toBeInTheDocument()
    expect(screen.getByText(dummyProps.title)).toBeInTheDocument()
    //screen.debug()
  })

SSG + Client side fetching

用はSEO対策は必要ないのでWebページに訪れた時にAPIで取得できればいいということ
TODOやDashboardなど特段必要ないものはビルド時に生成しない。
Client side fetching -> ユーザーアクセス時に最新データ取得可

Client side fetchingと静的サイトジェネレーターを比較するには
npm run dev(開発モード)
ではなく

npm run build
npm run start
を実行することでわかりやすくなります。

inspecterでDisable JavaScriptを実行するとわかる
Pre-fetchを実施するとJavaScriptを実行する前に

Client side fetchingを使った手法ではSEO対策のないページなどが活用されます

テスト

useSWR(Stale while revalidation)
initialData(クライアントサイドとしてfetchを実施するため非同期の処理として実施するためにタイムラグが発生します。そのために、最新のデータが取得できるまでに渡しておくデータとしてinitialData(初期値として実施ができる)
よくする方法としてgetStaticPropsでビルド時に取得したデータをinitialDataに格納することがある。

revalidateOnMount(Reactのコンポーネントやページなどがマウントされた時にリロードされた時にサーバーサイドからデータを取ってくるか?)
initialDataがないときは自動的に発動される
initialDataがある時はパラメータをtrueにすることで発動が可能となる

refreshInterval(ミリセカンド単位でサーバーサイドにアクセスすることができる)金融データなどの最新の情報が必要な場合などに使用

dedupingInterval = 2000(2秒間に複数回のリクエストがあった場合は、初回の1回だけ実行される
テストを実行する場合は0にすることが推奨される

useSWRは常にデータをキャッシュに保存してくれる

APIをモックする場合は
msw(mock service worker)を使用する

import { rest } from 'msw'
import { setupServer } from 'msw/node'

サーバーを作っていく(getレスポンスの場合)

const server = setupServer(
  rest.get(
    'https://jsonplaceholder.typicode.com/comments/',
    (req, res, ctx) => {
      const query = req.url.searchParams
      const _limit = query.get('_limit')
      if (_limit === '10') {
        return res(
          ctx.status(200),
          ctx.json([
            {
              postId: 1,
              id: 1,
              name: 'A',
              email: 'dummya@gmail.com',
              body: 'test body a',
            },
            {
              postId: 2,
              id: 2,
              name: 'B',
              email: 'dummyb@gmail.com',
              body: 'test body b',
            },
          ])
        )
      }
    }
  )
)

各テストごとにモックサーバーを起動させたり停止したり、毎回のテストでモックサーバーの内容をクリーンにしたりします。

beforeAll(() => server.listen())
afterEach(() => {
  server.resetHandlers()
  cleanup()
})
afterAll(() => server.close())

200番(成功のとき)と400番(失敗のとき)のテストをする

it('Should render the value fetched by useSWR ', async () => {
    render(
      <SWRConfig value={{ dedupingInterval: 0 }}>
        <CommentPage />
      </SWRConfig>
    )
    expect(await screen.findByText('1:test body a')).toBeInTheDocument()
    expect(screen.getByText('2:test body b')).toBeInTheDocument()
  })

200番の場合はこんな感じです。
useSWRの機能をテストする場合はSWRConfigで括ってdedupingIntervalを0にセットします。

it('Should render Error text when fetch failed', async () => {
    server.use(
      rest.get(
        'https://jsonplaceholder.typicode.com/comments/',
        (req, res, ctx) => {
          const query = req.url.searchParams
          const _limit = query.get('_limit')
          if (_limit === '10') {
            return res(ctx.status(400))
          }
        }
      )
    )
    render(
      <SWRConfig value={{ dedupingInterval: 0 }}>
        <CommentPage />
      </SWRConfig>
    )
    expect(await screen.findByText('Error!')).toBeInTheDocument()

400番の時はモックを変更する
その結果としてErrorが表示されるかを確認します。

useContextなどの状態管理

ボタンをクリックしたので今回はuser-eventを活用しました。

import '@testing-library/jest-dom/extend-expect'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { StateProvider } from '../context/StateProvider'
import ContextA from '../components/ContextA'
import ContextB from '../components/ContextB'

describe('Global state management (useContext)', () => {
  it('Should change the toggle state globally', () => {
    render(
      <StateProvider>
        <ContextA />
        <ContextB />
      </StateProvider>
    )
    expect(screen.getByTestId('toggle-a').textContent).toBe('false')
    expect(screen.getByTestId('toggle-b').textContent).toBe('false')
    userEvent.click(screen.getByRole('button'))
    expect(screen.getByTestId('toggle-a').textContent).toBe('true')
    expect(screen.getByTestId('toggle-b').textContent).toBe('true')
  })
})

getStaticPropsとuseSWRを使用したテスト

2段階に分けてテストを実施する
getStaticPropsのテスト
useSWRのテスト
staticPropsのところにダミーデータを渡してuseSWRのテストを実施する

staticPropsのテスト
サーバーを作ってawaitで取得できるのを待ってからテストします。

import '@testing-library/jest-dom/extend-expect'
import { render, screen, cleanup } from '@testing-library/react'
import { getPage } from 'next-page-tester'
import { initTestHelpers } from 'next-page-tester'
import { rest } from 'msw'
import { setupServer } from 'msw/node'

initTestHelpers()
const server = setupServer(
  rest.get('https://jsonplaceholder.typicode.com/todos/', (req, res, ctx) => {
    const query = req.url.searchParams
    const _limit = query.get('_limit')
    if (_limit === '10') {
      return res(
        ctx.status(200),
        ctx.json([
          {
            userId: 3,
            id: 3,
            title: 'Static task C',
            completed: true,
          },
          {
            userId: 4,
            id: 4,
            title: 'Static task D',
            completed: false,
          },
        ])
      )
    }
  })
)
beforeAll(() => {
  server.listen()
})
afterEach(() => {
  server.resetHandlers()
  cleanup()
})
afterAll(() => {
  server.close()
})
describe('Todo page / getStaticProps', () => {
  it('Should render the list of tasks pre-fetched by getStaticProps', async () => {
    const { page } = await getPage({
      route: '/task-page',
    })
    render(page)
    expect(await screen.findByText('todos page')).toBeInTheDocument()
    expect(screen.getByText('Static task C')).toBeInTheDocument()
    expect(screen.getByText('Static task D')).toBeInTheDocument()
  })
})

基本的にはモックを使用してダミーデータでテストを確認する流れは変わりません。

import '@testing-library/jest-dom/extend-expect'
import { render, screen, cleanup } from '@testing-library/react'
import { SWRConfig } from 'swr'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import TaskPage from '../pages/task-page'
import { TASK } from '../types/Types'

const server = setupServer(
  rest.get('https://jsonplaceholder.typicode.com/todos/', (req, res, ctx) => {
    const query = req.url.searchParams
    const _limit = query.get('_limit')
    if (_limit === '10') {
      return res(
        ctx.status(200),
        ctx.json([
          {
            userId: 1,
            id: 1,
            title: 'Task A',
            completed: false,
          },
          {
            userId: 1,
            id: 2,
            title: 'Task B',
            completed: true,
          },
        ])
      )
    }
  })
)
beforeAll(() => {
  server.listen()
})
afterEach(() => {
  server.resetHandlers()
  cleanup()
})
afterAll(() => {
  server.close()
})
describe('Todos page / useSWR', () => {
  let staticProps: TASK[]
  staticProps = [
    {
      userId: 3,
      id: 3,
      title: 'Static task C',
      completed: true,
    },
    {
      userId: 4,
      id: 4,
      title: 'Static task D',
      completed: false,
    },
  ]
  it('Should render CSF data after pre-rendered data', async () => {
    render(
      <SWRConfig value={{ dedupingInterval: 0 }}>
        <TaskPage staticTasks={staticProps} />
      </SWRConfig>
    )
    expect(await screen.findByText('Static task C')).toBeInTheDocument()
    expect(screen.getByText('Static task D')).toBeInTheDocument()
    expect(await screen.findByText('Task A')).toBeInTheDocument()
    expect(screen.getByText('Task B')).toBeInTheDocument()
  })
  it('Should render Error text when fetch failed', async () => {
    server.use(
      rest.get(
        'https://jsonplaceholder.typicode.com/todos/',
        (req, res, ctx) => {
          const query = req.url.searchParams
          const _limit = query.get('_limit')
          if (_limit === '10') {
            return res(ctx.status(400))
          }
        }
      )
    )
    render(
      <SWRConfig value={{ dedupingInterval: 0 }}>
        <TaskPage staticTasks={staticProps} />
      </SWRConfig>
    )
    expect(await screen.findByText('Error!')).toBeInTheDocument()
  })
})

なのでポイントが見えてきましたが、
イベントを実施する場合はusereventを実施します
ページの内容が表示されているかどうかはtoBeInTheDocumentを使用します
APIを使用する場合はmswを使用して開発を進めていきます。

gitがpushした時にテストを実施して成功するとビルドできるようにする

vercelでデプロイコマンドをnpm test && npm run buildとするとテストが失敗したときにはデプロイができないようになる。

エラーが発生する時

テストでrenderの箇所にdocument is not definedのエラーが発生した時
テストの最初に以下のコメントを追加することで解消できる

/**
 * @jest-environment jsdom
 */