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の設定はこんな感じ
// @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)
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してるページのテストを書くにあたり、既存プロジェクトをパクって以下のようなユーティリティを作る。
もとの参考情報は下記記事🙏
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
のテストを書き始めてみる。
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://zenn.dev/ttskch/scraps/58cec8ccf0118e#comment-1150dc83a667dc このような形にリファクタした。
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://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.
見たもの
メモ
メールアドレス入力欄を <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())
})
})
mockLogin
を mocks.ts
に移動させて、ログイン処理自体が呼ばれないパターンとログイン処理が成功したかのようにモックするパターンの両方を書けるようにした。
jest.mock()
でモジュールごとモックする方法だと、テストケースごとにモックしたりしなかったりということができない
ので、jest.spyOn()
を使う書き方に修正したい。
が、ESMは書き換えができないので、jest.mock()
でモジュールごとモックする方法との合わせ技にする必要がある。
というわけで、jest.spyOn()
する可能性のあるモジュールについては 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()
できる。
結局、ここまでのテストコードは以下のようにリファクタすることができた。
export const mockUser = {
id: 1,
uuid: 'uuid',
username: 'test',
// ...
}
+ 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経由でフロントエンドに渡す
ようになっているページです。
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),
},
}
}
とりあえずこのページに対して、画面内に必要なリンクが正しく出力されていることをテストしてみます。
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 が原因だった)
対処方法としては、ts-jest
のESMサポートを利用する方法がよく挙げられている。
が、今回自分のプロジェクトはNext.js v12組み込みののRustコンパイラを使っていて、ts-jest
は使っていなかった。
Babelと ts-jest
を入れて自分でJestを設定する方法に変えれば、上記のよく挙げられている対処方法が使えるのかもしれないが、未確認。
今回は
これを見つけて、あーモックしちゃえばいいか、となったので、モックする方法を選択した。(react-medium-image-zoom
の動作そのものを機能テストするつもりは今のところないので)
方法は上記リンク先で紹介されている方法ではなく、jest.spyOn()
をESMに対して実行できるようにした のと同じ方法で、以下のようにした。
// ...
;['react-medium-image-zoom'].forEach((module) => {
jest.mock(module, () => ({
__esModule: true,
}))
})
その他に見たもの
ちょっと毛色違う情報もあったけど詳細未確認
実は上記のテストでもう一つエラーが発生していた。
TypeError: win.matchMedia is not a function
これはググるとすぐに答えが見つかって、これだった。
ので、言われたとおり、以下のようなファイルを作って、
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'
を追記すれば解決した。