Open15

Next.js(v12)のアプリにページテストを書いていくログ

たつきちたつきち

Next.js(v12)で ちょっとしたSNS を作ったけど、フロントエンド初心者すぎて単体テストしか書いたことないマンだったのでスモールスタートを言い訳にコンポーネントやページのテストを全然書かないまま突き進んでしまった。ページテストを書いていくにあたり、何をどうすればいいかすらさっぱり分からず調べながらになるので、作業ログをスクラップに残しておく。

たつきちたつきち

幸いなことに過去のプロジェクトで業務委託の方が書いてくれたテストコードが参考資料として手元にあるので、それを真似つつ、そのまま流用できない部分は調べながら試行錯誤していく。多分。

たつきちたつきち

とりあえず入れてる依存は

  "devDependencies": {
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^14.0.0",
    "@testing-library/user-event": "^14.4.3",
    "jest": "^29.5.0",
    "jest-environment-jsdom": "^29.5.0",
    "whatwg-fetch": "^3.6.2"
  },

この辺。

ちなみにバックエンドにはSupabaseを使ってる。

たつきちたつきち

jestの設定はこんな感じ

jest.config.ts
// @see https://nextjs.org/docs/testing#setting-up-jest-with-the-rust-compiler
// @see https://github.com/vercel/next.js/blob/canary/examples/with-jest/jest.config.js
import nextJest from 'next/jest'

const createJestConfig = nextJest({
  dir: './',
})

const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
  testEnvironment: 'jest-environment-jsdom',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/$1',
    // @see https://github.com/vercel/next.js/discussions/43591#discussioncomment-4298506
    '^jose$': require.resolve('jose'),
  },
  // @see https://github.com/react-hook-form/resolvers/issues/396#issuecomment-1114248072
  resolver: '<rootDir>/jest.resolver.js',
  collectCoverageFrom: [
    'components/**/*.{ts,tsx}',
    'lib/**/*.{ts,tsx}',
    'pages/**/*.{ts,tsx}',
    'supabase/functions/**/*.{ts,tsx}',
  ],
  coveragePathIgnorePatterns: ['/lib/supabase/types.ts', '/lib/$path.ts'],
}

module.exports = createJestConfig(customJestConfig)
jest.setup.ts
import '@testing-library/jest-dom'
import 'whatwg-fetch'

// @see https://github.com/jsdom/whatwg-url/issues/209#issuecomment-1015559283
// @see https://github.com/jsdom/jsdom/issues/2524
import {TextEncoder, TextDecoder} from 'util'
global.TextEncoder = TextEncoder
;(global as any).TextDecoder = TextDecoder

気まぐれで1つだけ書いたコンポーネントテストを動かすために結構色々調べてなんやかんや対処してある状態。(詳細は @see コメントのリンク先参照)

たつきちたつきち

で、ページテスト。

とりあえず _app.tsx の中身が

return (
  <SessionContextProvider
    supabaseClient={supabaseClient}
    initialSession={pageProps.initialSession}
  >
    <ChakraProvider theme={theme}>
      <NextNprogress
        color="#f96c6a"
        startPosition={0.3}
        stopDelayMs={200}
        height={3}
        showOnShallow={true}
        options={{showSpinner: false}}
        nonce="xxx"
      />
      <QueryClientProvider client={queryClient}>
        <Hydrate state={pageProps.dehydratedState}>
          <RecoilRoot
            initializeState={({set}) => {
              const initialMe = pageProps.me
              initialMe && set(meAtom, initialMe)
            }}
          >
            <Component {...pageProps} />
          </RecoilRoot>
        </Hydrate>
        {process.env.NODE_ENV === 'development' && (
          <ReactQueryDevtools initialIsOpen={false} />
        )}
      </QueryClientProvider>
    </ChakraProvider>
  </SessionContextProvider>
)

こんな感じなので、既存プロジェクトをパクって以下のような感じのプロバイダを作ってみる。

import {ChakraProvider} from '@chakra-ui/react'
import {DehydratedState, QueryClient} from '@tanstack/query-core'
import {Hydrate, QueryClientProvider} from '@tanstack/react-query'
import {RecoilRoot} from 'recoil'
import {meAtom} from '@/lib/recoil/me'
import {User} from '@/lib/types/models'

type TestProviderProps = {
  initialMe?: User
  dehydratedState?: DehydratedState
  children: React.ReactNode
}

export const TestProvider: React.FC<TestProviderProps> = ({
  initialMe,
  dehydratedState,
  children,
}) => {
  const queryClient = new QueryClient()

  return (
    <ChakraProvider>
      <QueryClientProvider client={queryClient}>
        <Hydrate state={dehydratedState}>
          <RecoilRoot
            initializeState={({set}) => {
              initialMe && set(meAtom, initialMe)
            }}
          >
            {children}
          </RecoilRoot>
        </Hydrate>
      </QueryClientProvider>
    </ChakraProvider>
  )
}
たつきちたつきち

SSRしてるページのテストを書くにあたり、既存プロジェクトをパクって以下のようなユーティリティを作る。

もとの参考情報は下記記事🙏

https://zenn.dev/takepepe/articles/testing-gssp-and-api-routes#gsspctx-関数内訳

import {ParsedUrlQuery} from 'querystring'
import {GetServerSidePropsResult} from 'next'
import {GetServerSidePropsContext} from 'next'
import {createRequest, createResponse} from 'node-mocks-http'

type CreateMockSsrContextOption = Partial<{
  params: ParsedUrlQuery
  query: ParsedUrlQuery
}>

// @see https://zenn.dev/takepepe/articles/testing-gssp-and-api-routes#gsspctx-%E9%96%A2%E6%95%B0%E5%86%85%E8%A8%B3
// @see https://github.com/howardabrams/node-mocks-http
export const createMockSsrContext = ({
  params = {},
  query = {},
}: CreateMockSsrContextOption): GetServerSidePropsContext<ParsedUrlQuery> => ({
  req: createRequest(),
  res: createResponse(),
  params,
  query,
  resolvedUrl: '',
})

export const assertHasProps = <T>(ssrResponse: GetServerSidePropsResult<T>) => {
  expect('props' in ssrResponse).toBe(true)
}

export const assertRedirectsTo = <T>(
  ssrResponse: GetServerSidePropsResult<T>,
  to: string,
) => {
  if ('redirect' in ssrResponse) {
    expect(ssrResponse.redirect.destination).toBe(to)
  } else {
    throw 'ssrResponse.redirect is not defined.'
  }
}
たつきちたつきち

手始めに pages/login/index.tsx のテストを書き始めてみる。

tests/pages/login/index.test.tsx
import {render} from '@testing-library/react'
import {pagesPath} from '@/lib/$path'
import {TestProvider} from '@/lib/tests/TestProvider'
import Index from '@/pages/login'

const links = [
  {
    label: '新規登録はこちら',
    to: pagesPath.signup.$url().pathname,
  },
  {
    label: 'パスワードを忘れてしまった方',
    to: pagesPath.reset_password.$url().pathname,
  },
  {
    label: 'ご利用規約',
    to: pagesPath.tos.$url().pathname,
  },
  {
    label: 'プライバシーポリシー',
    to: pagesPath.privacy_policy.$url().pathname,
  },
]

describe('pages/login/index', () => {
  links.forEach((link) => {
    test(`"${link.label}" を押すと ${link.to} へ遷移できる`, async () => {
      const {findByRole} = render(
        <TestProvider>
          <Index />
        </TestProvider>,
      )
      const linkElement = await findByRole('link', {name: link.label})
      expect(linkElement).toHaveAttribute('href', link.to)
    })
  })
})
$ yarn test tests/pages/login/index.test.tsx
yarn run v1.22.19
$ jest tests/pages/login/index.test.tsx
info  - Loaded env from /Users/ttskch/ghq/github.com/ttskch/pocitta/.env
warn  - You have enabled experimental feature (scrollRestoration) in next.config.js.
warn  - Experimental features are not covered by semver, and may cause unexpected or broken application behavior. Use at your own risk.

 PASS  tests/pages/login/index.test.tsx
  pages/login/index
    ✓ "新規登録はこちら" を押すと /signup へ遷移できる (136 ms)"パスワードを忘れてしまった方" を押すと /reset-password へ遷移できる (80 ms)"ご利用規約" を押すと /tos へ遷移できる (51 ms)"プライバシーポリシー" を押すと /privacy-policy へ遷移できる (47 ms)

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        0.942 s, estimated 1 s
Ran all test suites matching /tests\/pages\/login\/index.test.tsx/i.
✨  Done in 2.19s.

おk

たつきちたつきち

ログインページには、SSRで getMe() という関数でログインセッションを調べて User | undefined を取得し、ログイン中ならトップ画面へリダイレクトさせる、という処理がある。

ので、次はこういうテストを書きたい。

test('ログイン済ならトップ画面へリダイレクトする', async () => {
  // ここでgetMe()をモックしたい
  const ssrResponse = await getServerSideProps(createMockSsrContext({}))
  assertRedirectsTo(ssrResponse, pagesPath.$url().pathname)
})

test('未ログインならページを表示する', async () => {
  // ここでgetMe()をモックしたい
  const ssrResponse = await getServerSideProps(createMockSsrContext({}))
  assertHasProps(ssrResponse)
})
たつきちたつきち

この getMe() をモックする処理を再利用できるようにする方法が全然分からなくて、

https://twitter.com/ttskch/status/1676949173915238400

数時間あれこれ試した結果一旦こんな形でよいのではという結論になった。

追記: その後 https://zenn.dev/ttskch/scraps/58cec8ccf0118e#comment-1150dc83a667dc このような形にリファクタした。

lib/tests/mocks.ts
import {jest} from '@jest/globals'
import {getMe} from '@/lib/ssr'

export const mockGetMe = (
  jest
    .mock('@/lib/ssr', () => ({
      ...(jest.requireActual('@/lib/ssr') as object),
      getMe: jest.fn(),
    }))
    .requireMock('@/lib/ssr') as {getMe: jest.Mock<typeof getMe>}
).getMe

export const mockUser = {
  id: 1,
  uuid: 'uuid',
  username: 'test',
  // ...
}
import {mockGetMe, mockUser} from '@/lib/tests/mocks'

describe('pages/login/index', () => {
  // ...

  test('ログイン済ならトップ画面へリダイレクトする', async () => {
    mockGetMe.mockResolvedValue(mockUser)
    const ssrResponse = await getServerSideProps(createMockSsrContext({}))
    assertRedirectsTo(ssrResponse, pagesPath.$url().pathname)
  })

  test('未ログインならページを表示する', async () => {
    mockGetMe.mockResolvedValue(undefined)
    const ssrResponse = await getServerSideProps(createMockSsrContext({}))
    assertHasProps(ssrResponse)
  })
})

結果

$ yarn test tests/pages/login/index.test.tsx
yarn run v1.22.19
$ jest tests/pages/login/index.test.tsx
info  - Loaded env from /Users/ttskch/ghq/github.com/ttskch/pocitta/.env
warn  - You have enabled experimental feature (scrollRestoration) in next.config.js.
warn  - Experimental features are not covered by semver, and may cause unexpected or broken application behavior. Use at your own risk.

 PASS  tests/pages/login/index.test.tsx
  pages/login/index
    ✓ "新規登録はこちら" を押すと /signup へ遷移できる (141 ms)"パスワードを忘れてしまった方" を押すと /reset-password へ遷移できる (82 ms)"ご利用規約" を押すと /tos へ遷移できる (54 ms)"プライバシーポリシー" を押すと /privacy-policy へ遷移できる (56 ms)
    ✓ ログイン済ならトップ画面へリダイレクトする (1 ms)
    ✓ 未ログインならページを表示する

Test Suites: 1 passed, 1 total
Tests:       6 passed, 6 total
Snapshots:   0 total
Time:        1.138 s
Ran all test suites matching /tests\/pages\/login\/index.test.tsx/i.
✨  Done in 2.82s.

見たもの

https://gist.github.com/amerryma/1399a8728f4dfe590e0dd81519c88d50
https://zenn.dev/aidemy/articles/62720a7cab9115
https://jestjs.io/ja/docs/api
https://jestjs.io/ja/docs/jest-object#jestdomockmodulename-factory-options
https://github.com/jestjs/jest/issues/2582#issuecomment-378677440

たつきちたつきち

続いてログインフォームのバリデーションのテスト。

追記: その後 https://zenn.dev/ttskch/scraps/58cec8ccf0118e#comment-1150dc83a667dc このような形にリファクタした。

const validationErrorTestCases = [
  {
    title: 'メールアドレスの必須入力バリデーション',
    input: {
      email: '',
      password: '123123',
    },
    errorMessage: 'この項目は必須です。',
  },
  {
    title: 'メールアドレスの形式バリデーション',
    input: {
      email: 'test',
      password: '123123',
    },
    errorMessage: 'メールアドレスの形式が正しくありません。',
  },
  {
    title: 'パスワードの必須入力バリデーション',
    input: {
      email: 'test@example.com',
      password: '',
    },
    errorMessage: 'この項目は必須です。',
  },
]

const mockLogin = jest.fn()
const mockLoginWith = jest.fn()
jest.mock('@/lib/auth/login', () => ({
  useLogin: () => ({
    login: mockLogin,
    loginWith: mockLoginWith,
  }),
}))

describe('pages/login/index', () => {
  // ...

  validationErrorTestCases.forEach(({title, input, errorMessage}) =>
    test(title, async () => {
      const {getByPlaceholderText, getByRole, findByText} = render(
        <TestProvider>
          <Index />
        </TestProvider>,
      )
      input.email &&
        (await userEvent.type(
          getByPlaceholderText('メールアドレス'),
          input.email,
        ))
      input.password &&
        (await userEvent.type(
          getByPlaceholderText('パスワード'),
          input.password,
        ))
      await userEvent.click(getByRole('button', {name: 'ログイン'}))
      const errorMessageElement = await findByText(errorMessage)

      expect(errorMessageElement).toBeInTheDocument()
      expect(mockLogin).not.toHaveBeenCalled()
    }),
  )
})

結果

$ yarn test tests/pages/login/index.test.tsx
yarn run v1.22.19
$ jest tests/pages/login/index.test.tsx
info  - Loaded env from /Users/ttskch/ghq/github.com/ttskch/pocitta/.env
warn  - You have enabled experimental feature (scrollRestoration) in next.config.js.
warn  - Experimental features are not covered by semver, and may cause unexpected or broken application behavior. Use at your own risk.

 PASS  tests/pages/login/index.test.tsx
  pages/login/index
    ✓ "新規登録はこちら" を押すと /signup へ遷移できる (155 ms)"パスワードを忘れてしまった方" を押すと /reset-password へ遷移できる (60 ms)"ご利用規約" を押すと /tos へ遷移できる (49 ms)"プライバシーポリシー" を押すと /privacy-policy へ遷移できる (47 ms)
    ✓ ログイン済ならトップ画面へリダイレクトする (1 ms)
    ✓ 未ログインならページを表示する (1 ms)
    ✓ メールアドレスの必須入力バリデーション (158 ms)
    ✓ メールアドレスの形式バリデーション (185 ms)
    ✓ パスワードの必須入力バリデーション (151 ms)

Test Suites: 1 passed, 1 total
Tests:       9 passed, 9 total
Snapshots:   0 total
Time:        1.64 s, estimated 2 s
Ran all test suites matching /tests\/pages\/login\/index.test.tsx/i.
✨  Done in 3.11s.

見たもの

https://testing-library.com/docs/user-event/intro/
https://zenn.dev/panyoriokome/scraps/78aecf55ba5a38#comment-cbe20867ad2e24
https://qiita.com/kobanyan/items/126512ec3e8d76c538b3#不必要に-act-でラップしている
https://zenn.dev/tnyo43/scraps/6d15ee29867b7e

メモ

メールアドレス入力欄を <input type="email"> にしてたので test とかを入力してログインを試行してもアプリのバリデーションエラーが表示されずにブラウザ標準のエラーツールチップが表示される挙動になってて、メールアドレスの形式バリデーション だけ最初テストが落ちた。

挙動に統一感があるほうがいいので type="email" を使うのをやめた。

たつきちたつきち

続いてログイン完了後の画面遷移のテスト。

追記: その後 https://zenn.dev/ttskch/scraps/58cec8ccf0118e#comment-1150dc83a667dc このような形にリファクタした。

import {jest} from '@jest/globals'
import {useLogin} from '@/lib/auth/login'

// ...

export const mockUseLogin = (
  jest
    .mock('@/lib/auth/login', () => ({
      useLogin: jest.fn(() => ({
        login: jest.fn(),
        loginWith: jest.fn(),
        confirmPassword: jest.fn(),
      })),
    }))
    .requireMock('@/lib/auth/login') as {useLogin: jest.Mocked<typeof useLogin>}
).useLogin
import {mockGetMe, mockUseLogin, mockUser} from '@/lib/tests/mocks'

// ...

const correctInput = {
  email: 'test@example.com',
  password: '123123',
}

const mockRouterPush = jest.fn()
jest.mock('next/router', () => ({
  useRouter: () => ({
    push: mockRouterPush,
  }),
}))

describe('pages/login/index', () => {
  // ...

  validationErrorTestCases.forEach(({title, input, errorMessage}) =>
    test(title, async () => {
      const mockLogin = jest.fn()
      mockUseLogin.mockReturnValue({
        login: mockLogin,
        loginWith: jest.fn(),
        confirmPassword: jest.fn(),
      })

      const {getByPlaceholderText, getByRole, findByText} = render(
        <TestProvider>
          <Index />
        </TestProvider>,
      )
      input.email &&
        (await userEvent.type(
          getByPlaceholderText('メールアドレス'),
          input.email,
        ))
      input.password &&
        (await userEvent.type(
          getByPlaceholderText('パスワード'),
          input.password,
        ))
      await userEvent.click(getByRole('button', {name: 'ログイン'}))
      const errorMessageElement = await findByText(errorMessage)

      expect(errorMessageElement).toBeInTheDocument()
      expect(mockLogin).not.toHaveBeenCalled()
    }),
  )

  test('ログインが完了したらトップ画面へ遷移する', async () => {
    mockUseLogin.mockReturnValue({
      login: async () => ({
        status: 'success',
        data: mockUser,
      }),
      loginWith: jest.fn(),
      confirmPassword: jest.fn(),
    })

    const {getByPlaceholderText, getByRole} = render(
      <TestProvider>
        <Index />
      </TestProvider>,
    )
    await userEvent.type(
      getByPlaceholderText('メールアドレス'),
      correctInput.email,
    )
    await userEvent.type(
      getByPlaceholderText('パスワード'),
      correctInput.password,
    )
    await userEvent.click(getByRole('button', {name: 'ログイン'}))
    expect(mockRouterPush).toHaveBeenCalledWith(pagesPath.login.complete.$url())
  })
})

mockLoginmocks.ts に移動させて、ログイン処理自体が呼ばれないパターンとログイン処理が成功したかのようにモックするパターンの両方を書けるようにした。

たつきちたつきち

jest.mock() でモジュールごとモックする方法だと、テストケースごとにモックしたりしなかったりということができない

https://stackoverflow.com/questions/56496998/how-to-restore-a-mock-created-with-jest-mock#answer-56512217

ので、jest.spyOn() を使う書き方に修正したい。

が、ESMは書き換えができないので、jest.mock() でモジュールごとモックする方法との合わせ技にする必要がある。

https://scrapbox.io/dojineko/ES_Modulesなモジュールをモックする

https://github.com/aelbore/esbuild-jest/issues/26#issuecomment-968853688

というわけで、jest.spyOn() する可能性のあるモジュールについては jest.setup.ts 内で事前にすべてモックしておくようにする。

jest.setup.ts
// ...

['@/path/to/lib/a', '@/path/to/lib/b', '@/path/to/lib/c'].forEach(
  (module) => {
    jest.mock(module, () => ({
      __esModule: true,
      ...jest.requireActual(module),
    }))
  },
)

こうしておけば、'@/path/to/lib/a' '@/path/to/lib/b' '@/path/to/lib/c' については各テストケースで自由に jest.spyOn() できる。

結局、ここまでのテストコードは以下のようにリファクタすることができた。

lib/tests/mocks.ts
export const mockUser = {
  id: 1,
  uuid: 'uuid',
  username: 'test',
  // ...
}
tests/pages/login/index.test.tsx
+ import * as login from '@/lib/auth/login'
+ import * as ssr from '@/lib/ssr'

  import {
-   mockGetMe,
-   mockUseLogin,
    mockUser,
  } from '@/lib/tests/mocks'

// ...

    test('ログイン済ならトップ画面へリダイレクトする', async () => {
-     mockGetMe.mockResolvedValue(mockUser)
+     jest.spyOn(ssr, 'getMe').mockResolvedValue(mockUser)
      const ssrResponse = await getServerSideProps(createMockSsrContext())
      assertRedirectsTo(ssrResponse, pagesPath.$url().pathname)
    })

    test('未ログインならページを表示する', async () => {
-     mockGetMe.mockResolvedValue(undefined)
+     jest.spyOn(ssr, 'getMe').mockResolvedValue(undefined)
      const ssrResponse = await getServerSideProps(createMockSsrContext())
      assertHasProps(ssrResponse)
    })

    validationErrorTestCases.forEach(({title, input, errorMessage}) =>
      test(title, async () => {
        const mockLogin = jest.fn()
-       mockUseLogin.mockReturnValue({
+       jest.spyOn(login, 'useLogin').mockReturnValue({
          login: mockLogin,
          loginWith: jest.fn(),
          confirmPassword: jest.fn(),
        })
  
        // ...
      }),
    )

    test('ログインが成功したらトップ画面へ遷移する', async () => {
-     mockUseLogin.mockReturnValue({
+     jest.spyOn(login, 'useLogin').mockReturnValue({
        login: async () => ({
          status: 'success',
          data: mockUser,
        }),
        loginWith: jest.fn(),
        confirmPassword: jest.fn(),
      })
  
      // ...
    })

// ...
たつきちたつきち

続いて、React Queryを使ってSSRしているページ(ここでは例としてトップページ)のテスト。

以下のように、

  • URLクエリパラメータ tab'following' 'trend' undefined)で表示するタブを制御する
  • URLクエリパラメータ page limit でページングを、search で検索を制御する
  • URLクエリパラメータ tab page limit search の値に応じて必要なデータをSSRでバックエンドAPIから取得した、React Query経由でフロントエンドに渡す

ようになっているページです。

pages/index.test.tsx
export type OptionalQuery = {
  tab?: 'following' | 'trend'
  page?: number
  limit?: number
  search?: string
}

type Props = {
  me?: User
  query: OptionalQuery
  dehydratedState: DehydratedState
}

const Index: NextPage<Props> = ({me, query}) => {
  const {data: followingData} = useQuery(/* 略 */)
  const {data: trendData} = useQuery(/* 略 */)
  const {data: newData} = useQuery(/* 略 */)

  const router = useRouter()

  const {posts, total} =
    router.query.tab === 'following'
      ? followingData
      : router.query.tab === 'trend'
      ? trendData
      : newData

  const onSearch = (search?: string) => {
    router.push(pagesPath.$url({query: {...query, search, page: 1}}))
  }

  return (
    // ...
  )
}

export const getServerSideProps: GetServerSideProps<Props> = async (ctx) => {
  const query = ctx.query as OptionalQuery
  query.page = Number(query.page) || undefined
  query.limit = Number(query.limit) || undefined

  const me = await getMe(ctx)

  const queryClient = new QueryClient()

  const fetchData = () => /* fetchQuery() 内でバックエンドAPIから色々データを取ってくる */
  await Promise.all([fetchData()])

  return {
    props: {
      me,
      query,
      dehydratedState: dehydrate(queryClient),
    },
  }
}

とりあえずこのページに対して、画面内に必要なリンクが正しく出力されていることをテストしてみます。

tests/pages/index.test.tsx
import {render} from '@testing-library/react'
import {pagesPath} from '@/lib/$path'
import * as ssr from '@/lib/ssr'
import * as api from '@/lib/supabase/api/pages'
import {createMockSsrContext} from '@/lib/tests'
import {TestProvider} from '@/lib/tests/TestProvider'
import Index, {getServerSideProps} from '@/pages'

const links = [
  {
    label: 'ログイン',
    to: pagesPath.login.$url().pathname,
  },
  {
    label: '無料で登録',
    to: pagesPath.signup.$url().pathname,
  },
  {
    label: '特長を詳しく見る',
    to: pagesPath.about.$url().pathname,
  },
]

const mockRouterPush = jest.fn()
const mockQuery = {}
jest.mock('next/router', () => ({
  useRouter: () => ({
    push: mockRouterPush,
    query: mockQuery,
  }),
}))

describe('pages/index', () => {
  // 画面内のリンクのテスト
  links.forEach((link) => {
    test(`"${link.label}" を押すと ${link.to} へ遷移できる`, async () => {

      // 色々モックする
      jest.spyOn(ssr, 'getMe').mockResolvedValue(undefined)
      jest
        .spyOn(api, 'getFollowingData')
        .mockResolvedValue({posts: [], total: 0})
      jest.spyOn(api, 'getTrendData').mockResolvedValue({posts: [], total: 0})
      jest.spyOn(api, 'getNewData').mockResolvedValue({posts: [], total: 0})

      // SSRのレスポンスをモックする
      const ssrResponse = await getServerSideProps(createMockSsrContext())
      if (!('props' in ssrResponse && 'dehydratedState' in ssrResponse.props)) {
        fail(
          '"props" is not in ssrResponse or "dehydratedState" is not in props',
        )
        return
      }

      const {findByRole} = render(
        <TestProvider {...ssrResponse.props}>
          <Index {...ssrResponse.props} />
        </TestProvider>,
      )

      const linkElement = await findByRole('link', {name: link.label})
      expect(linkElement).toHaveAttribute('href', link.to)
    })
  })
})

こんな感じで書けました。

たつきちたつきち

実は上記のテストを実行した際、以下のエラーが発生しました。

Jest encountered an unexpected token

Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.

Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration.

By default "node_modules" folder is ignored by transformers.

Here's what you can do:
 • If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it.
 • If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript
 • To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
 • If you need a custom transformation specify a "transform" option in your config.
 • If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.

You'll find more details and examples of these config options in the docs:
https://jestjs.io/docs/configuration
For information about custom transformations, see:
https://jestjs.io/docs/code-transformation

Details:

/Users/ttskch/ghq/github.com/ttskch/pocitta/node_modules/react-medium-image-zoom/dist/index.js:1
({"Object.<anonymous>":function(module,exports,require,__dirname,__filename,jest){import React, { Component, createRef, useState } from 'react';
  ^^^^^^

SyntaxError: Cannot use import statement outside a module

ググったところ、依存ライブラリの中に、CommonJS形式のアーティファクトが提供されていない(ESM形式のアーティファクトだけしか提供されていない)ものがあると、Jestが(Node.jsが?)実行できずSyntax Errorになるというよく知られている問題らしい。(今回は react-medium-image-zoom が原因だった)

https://blog.n-t.jp/post/tech/jest-encountered-an-unexpected-token/

対処方法としては、ts-jest のESMサポートを利用する方法がよく挙げられている。

https://kulshekhar.github.io/ts-jest/docs/next/guides/esm-support

が、今回自分のプロジェクトはNext.js v12組み込みののRustコンパイラを使っていて、ts-jest は使っていなかった。

https://nextjs.org/docs/pages/building-your-application/optimizing/testing#setting-up-jest-with-the-rust-compiler

Babelと ts-jest を入れて自分でJestを設定する方法に変えれば、上記のよく挙げられている対処方法が使えるのかもしれないが、未確認。

https://nextjs.org/docs/pages/building-your-application/optimizing/testing#setting-up-jest-with-babel

今回は

https://zenn.dev/tokiya_horikawa/scraps/75f33067a28292#comment-55749eae765010

これを見つけて、あーモックしちゃえばいいか、となったので、モックする方法を選択した。(react-medium-image-zoom の動作そのものを機能テストするつもりは今のところないので)

方法は上記リンク先で紹介されている方法ではなく、jest.spyOn() をESMに対して実行できるようにした のと同じ方法で、以下のようにした。

jest.setup.ts
// ...

;['react-medium-image-zoom'].forEach((module) => {
  jest.mock(module, () => ({
    __esModule: true,
  }))
})

その他に見たもの

https://media-massage.net/blog/ts-jestが外部ライブラリをimportできない場合の対処方/

https://stackoverflow.com/questions/75261877/jest-encountered-an-unexpected-token-with-next-js-and-typescript-when-using-crea#answer-75290353

ちょっと毛色違う情報もあったけど詳細未確認

https://zenn.dev/tokiya_horikawa/scraps/c0e190f2c66776#comment-f3c8e415befc51

たつきちたつきち

実は上記のテストでもう一つエラーが発生していた。

TypeError: win.matchMedia is not a function

これはググるとすぐに答えが見つかって、これだった。

https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom

ので、言われたとおり、以下のようなファイルを作って、

lib/tests/matchMedia.mock.ts
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation((query) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(), // deprecated
    removeListener: jest.fn(), // deprecated
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
})

export {}

tests/pages/index.test.tsx

import '@/lib/tests/matchMedia.mock'

を追記すれば解決した。