古生代紀のブログ

主にアプリ開発/その他ナレッジ

Nextjs テスト実装方法 基本編勉強

Reactの勉強にいつもお世話になっているこの方の講義手順のメモです(個人メモです)
https://github.com/GomaGoma676/nextjs-testing

Tips!! スタイルについて

スタイルについてののドキュメント
Tailwind CSS - Rapidly build modern websites without ever leaving your HTML.

チートシート
Tailwind CSS Cheat Sheet

ホバーしたときに背景が当たっていることを示せる
hover:bg-gray-700

テスト準備

以下手順でエラーが出る場合はこれが原因の可能性がある

/**
* @jest-environment jsdom
*/

1. プロジェクト作成手順
https://github.com/GomaGoma676/nextjs-testing/blob/main/README.md
この通りにやる

なお自分のプロジェクト作成場所はユーザーフォルダ直下にreact-testフォルダを作ってその下にfrontendフォルダを作成しそこを起点とした

1-1. create-next-app

npx create-next-app . --use-npm

成功するとこんな感じの文字が出る

Success! Created frontend at C:\Users\XXXXXXX\projects\udemy-react-test\frontend
Inside that directory, you can run several commands:

npm run dev
Starts the development server.

npm run build
Builds the app for production.

npm start
Runs the built app in production mode.

We suggest that you begin by typing:

cd C:\Users\XXXXXXX\projects\udemy-react-test\frontend
npm run dev


1-2. 必要 module のインストール

npm install axios msw swr

成功するとこのような感じになる
個人的にエラーゼロでうれしい(create-react-appで作成したプロジェクトはなぜかWARNINGだらけでvulnerabilitiesが大量に発生する)

  1. axios@0.24.0
  2. swr@1.0.1
  3. msw@0.35.0

added 92 packages from 86 contributors and audited 534 packages in 28.619s

96 packages are looking for funding
run `npm fund` for details

found 0 vulnerabilities

1-2-ex. VSCode拡張機能のインストール
ES7とJESTをインストール(Prettierも)
f:id:shinseidaiki:20211108000313p:plain
f:id:shinseidaiki:20211108000255p:plain
f:id:shinseidaiki:20211108000334p:plain

prettierの設定は設定>save>Editor:Format On Saveにチェックを入れる
f:id:shinseidaiki:20211108000413p:plain
f:id:shinseidaiki:20211108000542p:plain

1-3. prettierの設定 : package.json
不要なセミコロンを消してくれる設定

    "prettier": {
        "singleQuote": true,
        "semi": false
    }


2. React-testing-library の導入

2-1. 必要 module のインストール

Testのためのモジュール群をインストール
jest-css-modulesはテスト時に不具合を出す可能性のあるCSSのモック化のためのライブラリ

npm install -D jest @testing-library/react @types/jest @testing-library/jest-dom @testing-library/dom babel-jest @testing-library/user-event jest-css-modules

2-2. Project folder 直下に".babelrc"ファイルを作成して下記設定を追加

コマンドじゃなくて普通に作成してもよい(windowsユーザーはtouch使えないので右クリックで普通に作る)

touch .babelrc

作ったファイルに以下記載(next.jsのプロジェクトに対してテスト実行するということを伝えるという記述)

    {
        "presets": ["next/babel"]
    }

2-3. package.json に jest の設定を追記

    "jest": {
        "testPathIgnorePatterns": [
            "<rootDir>/.next/",
            "<rootDir>/node_modules/"
        ],
        "moduleNameMapper": {
            "\\.(css)$": "<rootDir>/node_modules/jest-css-modules"
        }
    }

testPathIgnorePatternsはテストとは関係ないフォルダを無視する記述
"moduleNameMapper"はCSSファイルがプロジェクトに存在した場合はmock化する記述


2-4. package.jsonに test scriptを追記
test行だけを追記

    "scripts": {
        ...
        "test": "jest --env=jsdom --verbose"
    },

この記述があればターミナルからnpm testを使ってテストを実行することができるようになる

    • env=jsdom --verboseのオプションはデフォルトでは全テストケースの成功有無しか出力されないところを、各テストケースの成功有無を出力させてもらえるようになる記述

3. TypeScript の導入

この手順と同じ
https://nextjs.org/learn/excel/typescript/create-tsconfig

3-1. 空のtsconfig.json作成
touch tsconfig.jsonとか書いてるけど右クリックで作成でよい

3-2. 必要moduleのインストール

TypeScriptのインストール

npm install -D typescript @types/react @types/node

3-3. 開発server起動

npm run dev

これを実行するとtsconfig.jsonの内容が自動的に生成される

正常に実行されたのち、Ctrl + Cでプロセスを切断


3-4. _app.js, index.js -> tsx へ拡張子変更
f:id:shinseidaiki:20211108003113p:plain

apiフォルダは使用しないため削除する


3-5. AppProps型追記

_app.tsx内に記述を追加
import { AppProps } from 'next/app'と: AppPropsが追記する要素

    import { AppProps } from 'next/app'

    function MyApp({ Component, pageProps }: AppProps) {
        return <Component {...pageProps} />
    }

    export default MyApp

4. Tailwind CSS の導入
https://tailwindcss.com/docs/guides/nextjs

4-1. 必要moduleのインストール

テイルウィンドウのインストール

npm install tailwindcss@latest postcss@latest autoprefixer@latest


4-2. tailwind.config.js, postcss.config.jsの生成

コマンド実行で上記二つのファイルを自動生成

npx tailwindcss init -p


4-3. tailwind.config.jsのpurge設定追加

パージ内に'./pages/**/*.tsx', './components/**/*.tsx'を追記
これはpagesやcomponentsフォルダ下の実際に使用されているtsxファイルのユーティリティファイルに対してデプロイするときに自動生成されるCSSファイルについて、記載したフォルダのものだけをCSSに自動生成する記述
これを設定しておかないと存在するtailwindのクラスユーティリティすべてがCSSに出力されてファイルサイズが膨大になる

module.exports = {
    purge: ['./pages/**/*.tsx', './components/**/*.tsx'],
    darkMode: false,
    theme: {
        extend: {},
    },
    variants: {
        extend: {},
    },
    plugins: [],
}

4-3-x. componentsフォルダをプロジェクト直下に作成する
普通に作成する


4-4. globals.cssの編集

stylesフォルダにあるblobals.cssのすでに記載されている内容を以下の三行で置き換える

@tailwind base;
@tailwind components;
@tailwind utilities;

5. 動作確認

5-1. index.tsxの編集

既に存在するコードを以下の内容に置き換える

const Home: React.FC = () => {
  return (
    <div className="flex justify-center items-center flex-col min-h-screen font-mono">
      Hello Nextjs
    </div>
  )
}
export default Home

また既存のデフォルトコードで使用されていたHome.module.cssは使用しないため、削除する

その後開発サーバーを実行してTailwind CSSが効いているかブラウザで確認する

npm run dev 

Hello Nextjsの文字列が中央寄せになっていることが確認できる
f:id:shinseidaiki:20211108005102p:plain


5-2. __tests__フォルダとHome.test.tsxファイルの作成

まずはテストファイルを格納するための__tests__フォルダをプロジェクト直下に作成して、その後、直下にHome.test.tsxを作成する
f:id:shinseidaiki:20211108005427p:plain

Home.test.tsxに以下の内容を記述
itの中身がテストケース
コンポーネントの画面が正しく'Hello Nextjs'という内容をレンダリングしているかというのを確認するテスト
render()でHTMLの構造を取得でき、その中からお目当ての要素の確認をすることができる

import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import Home from '../pages/index'

it('Should render hello text', () => {
  render(<Home />)
  expect(screen.getByText('Hello Nextjs')).toBeInTheDocument()
})


npm test -> テストがPASSするか確認

テストの実行は以下のコマンド

npm test


成功した場合はこのような文字が出力される

PASS __tests__/Home.test.tsx
√ Should render hello text (36 ms)

Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 3.429 s
Ran all test suites.

失敗の場合は原因などの情報を含めて出力される

FAIL __tests__/Home.test.tsx
× Should render hello text (40 ms)

● Should render hello text

TestingLibraryElementError: Unable to find an element with the text: Hello Nextjs2. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

......

Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 total
Snapshots: 0 total
Time: 3.296 s
Ran all test suites.
npm ERR! Test failed. See above for more details.


テストのデバッグをしたい場合はscreen.debug()をテストコードの中に記載して確認することもできる

it('Should render hello text', () => {
  render(<Home />)
  screen.debug()
  expect(screen.getByText('Hello Nextjs2')).toBeInTheDocument()
})

デバックを入れているときのテストが成功する場合のコンソールへの出力例
(テスト失敗の時はデフォルトでデバッグ出力されている模様)
レンダリングされているHTMLの要素を出力させている

console.log



Hello Nextjs

※Nextjs ver12.0 + Next-page-tester互換性

現状(2021/11/3)、next-page-testerがNextjs ver12に対応していない為、Nextのversionを11系に変更する必要がある模様
npm install

yarn add next@11.1.2


※ReferenceError: setImmediate is not defined 対処法

Navigationのテスト実行時、"setImmediate is not defined"というエラーが発生した場合

1. setimmdiate パッケージのnpmインストール

npm i setimmediate

2 . 各テストファイルのimport部に下記importを追加(※以降すべてのテストファイルに追記)

import "setimmediate"


※Nextjs ver11.0対応

Nextjs ver11.0にした場合はLayout componentの修正

1. Layout componentに Imageのimport文を追加

import Image from 'next/image';

2. をNextの表記に変更

{/*  <img ............................. />  */}
<Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />

Static Site Generation 実装パターンのテスト作成

静的なコンテンツをテストするパターン

今回は5つの画面を遷移するテストを実装する

components/Layout.tsxを作成
Layoutはすべての画面で共通して使われるベースの画面
基本形

interface TITLE {
    title: string
}

const Layout: React.FC<TITLE> = ({ children, title = 'Next.js' }) => {
    return (
        <div>
            
        </div>
    )
}

export default Layout


returnの中身
タグがルーティング

import Head from 'next/head';
import Link from 'next/link';


return (
        <div className="flex justify-center intems-center flex-col min-h-screen font-mono">
            <Head>
                <title>{title}</title>
            </Head>
            <header>
                <nav className="bg-gray-800 w-screen">
                    <div className="flex items-center pl-8 h-14">
                        <div className="flex space-x-4">
                            <Link href="/">
                                <a data-testid="home-nav" 
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >Home</a>
                            </Link>
                            <Link href="/blog-page">
                                <a data-testid="blog-nav" 
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >Blog</a>
                            </Link>
                            <Link href="/comment-page">
                                <a data-testid="comment-nav" 
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >Comment</a>
                            </Link>
                            <Link href="/context-page">
                                <a data-testid="context-nav" 
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >Context</a>
                            </Link>
                            <Link href="/task-page">
                                <a data-testid="task-nav" 
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >Todos</a>
                            </Link>
                        </div>
                    </div>
                </nav>
            </header>
            <main className="flex flex-1 justify-center item-center flex-col w-screen">
                {children}
            </main>
            <footer className="w-full h-12 flex justify-center items-center border-t">
                <a className="flex items-center"
                    href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
                    target="_blank"
                    rel="noopener noreferrer"
                >Powered by <img src="/vercel.svg" alt="Vercel Logo" width={72} height={16} /></a>
            </footer>
        </div>
    )

次にindex.tsxでホーム画面を作成する
Layout.tsxをベースに作成するので、return 直下をLayoutで囲う

import Layout from "../components/Layout"

return (
    <Layout title="Home">
      <div>...</div>
    </Layout>
  )


次にblog-page,tsxを作成する
画面となるので、pagesフォルダ下に作成する
index.tsxのコードを流用
ファンクションコンポーネント名とエクスポート名をBlogPageに置き換え、他も必要な個所を修正する

他のcomment-page.tsxなども同様に作成していく



すべて作成終わったのちに、テストケースの修正をする
index.tsxの内容が修正されているのでHome.test.tsxのテストする内容を修正する


次にテスターがナブバーをクリックしてページ遷移するかどうかを確認するためのテストを行う
NavBar.test.tsxを__tests__直下に作成

Linkコンポーネントのテストをする場合は、以下のパッケージをインストールする必要がある

npm install next-page-tester

※これでエラーが出る場合は以下を実行してから再度上記を実行

npm install next@11.1.2
[※yarnの場合]  yarn add next@11.1.2 

nextをバージョン11に戻した場合
Layout.tsxでimgで記載している箇所をImageに修正

import Image from 'next/image';


  Powered by{' '} 
  <Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />


基本的なテストモジュールのインポート

import {render, screen} from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';

NavBarテストのために必要なモジュールのインストールとおまじない

import useEvent from '@testing-library/user-event'; // NavBarをクリックさせるために必要
import { getPage } from 'next-page-tester';
import { initTestHelpers } from 'next-page-tester'; // 初期化を行う

initTestHelpers()


テストケースを書く
テストの基本形はこちら

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


取得したいページを読み込み、テストできる形にレンダーできる処理

const { page } = await getPage({
    route: '/index',
})
render(page)

NavBarのブログをクリックするためにはLayout.tsxのdata-testidを手掛かりにする

Blogをクリックする

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

意図した挙動となっているか確認する

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


テストケースの全体像基本形

it('Should route to selected page in navbar', async () => {
        const { page } = await getPage({
            route: '/index',
        })
        render(page)

        useEvent.click(screen.getByTestId('blog-nav'))
        expect(await screen.findByText('blog page')).toBeInTheDocument()
    })

Static Site Generation + PreFecth 実装パータンのテスト作成

Nextjsのスタティックサイトジェネレータを使用する実装
Blogを実装してテストする

JsonPlaceHolderの/postsエンドポイントを使用する
JSONPlaceholder - Free Fake REST API

postsのデータ型を保持するためのデータ型を定義するファイルTypes.tsをプロジェクト直下に作ったtypesに格納する

JsonPlaceHolderでは以下のようなデータ型になっているので、

{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
},

このようなデータ型をTypes.tsに定義する

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


/commentsエンドポイントのデータ型も使うので、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 {
    taskId: number
    id: number
    title: string
    completed: boolean
}

続いて、libフォルダをプロジェクト直下に作成して、fetch.tsのファイルを作成する

今回はStatic Site Generation + Pre-fetchのデータを取得するパターンの模様
ビルド時に外部のjsonフォルダにアクセスしてデータを取得(Pre-fetch)して、そのデータを使って事前に静的なHTMLを作っておくアクセス方式

サーバーサイドで実行されるPre-fetchの処理を記述していく

不要だが、サーバーサイドでfcthをしていることを明示するために記述しているインポート { fetch } としないように注意

import fetch from "node-fetch";

axiosと同じように実装する
もともとは100件だが ?_limit=10 をつけると10件分取得になる

export const getAllPostData = async () => {
    const res = await fetch(
        new URL('https://jsonplaceholder.typicode.com/posts/?_limit=10')
    )
    const posts = await res.json()
    return posts
}

Postの詳細データ取得

export const getPostData = async (id: string) => {
    const res = await fetch(
        new URL(`https://jsonplaceholder.typicode.com/posts/${id}`)
    )
    const post = await res.json()
    return post
}


コンポーネントにPost.tsx作成して詳細ページ作成
Post.tsx 作る

import Link from 'next/link';
import { POST } from '../types/Types'

const Post: React.FC<POST> = ({ id, title }) => {
    return (
        <div>
            <span>{id}</span>
            {' : '}
            <Link href={`/posts/${id}`}>
                <a className="curxor-pointer border-b border-gray-500 hover:bg-gray-300">
                    {title}
                </a>
            </Link>
        </div>
    )
}

export default Post


[id].tsx 作る

import Link from 'next/link';
import Layout from '../../components/Layout'
import { getAllPostIds, getPostData } from '../../lib/fetch';
import { POST } from '../../types/Types'
import { GetStaticProps, GetStaticPaths } from 'next';

const PostDetail: React.FC<POST> = ({ id, title, body }) => {
    return (
        <Layout title="">
            <p className="m-4">
                {'ID : '}
            </p>
            <p className="mb-4 text-xl font-bold">{title}</p>
            <p className="mx-10 mb-12">{body}</p>
            <Link href="/blog-page">
                <div className="flex cursor-pointer mt-12">
                    <svg 
                        xmlns="http://www.w3.org/2000/svg" 
                        className="h-6 w-6 mr-3" 
                        fill="none" 
                        viewBox="0 0 24 24" 
                        stroke="currentColor"
                    >
                        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
                    </svg>
                    <a data-testid="back-blog">Back to blog-page</a>
                </div>
            </Link>
        </Layout>
    )
}

export default PostDetail

export const getStaticPaths: GetStaticPaths = async () => {
    const paths = await getAllPostIds()
    return {
        paths,
        fallback: false,
    }
}

export const getStaticProps: GetStaticProps = async (ctx) => {
    const { post: post } = await getPostData(ctx.params.id as string)
    return {
        props: {
            ...post, // id, title, body に分割してファンクショナルコンポーネントのpropsに渡す
        },
    }
}


post-page.tsx 書き換える

import Layout from "../components/Layout"
import { getAllPostData } from "../lib/fetch"
import Post from "../components/Post"
import { GetStaticProps } from "next"
import { POST } from "../types/Types"

interface STATICPROPS {
  posts: POST[]
}

const BlogPage: React.FC<STATICPROPS> = ({ posts }) => {
  return (
    <Layout title="Blog">
      <p className="text-4xl">blog page</p>
      <ul>{posts && posts.map((post) => <Post key={post.id} {...post} />)}</ul>
    </Layout>
  )
}
export default BlogPage

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

Blog-page.tsxに対するテスト実装

以下のコマンドを叩いて、下準備が完了

npm build


BlogPage.test.tsxを作成

テストの基本形 REST APIを使用するケース
サーバーサイドをMock化している

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 handlers = [
    rest.get(`https://jsonplaceholder.typicode.com/posts/?_limit=10`, (req, res, ctx) => {
            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(() => {
    server.listen()
})
afterEach(() => {
    server.resetHandlers()
    cleanup()
})
afterAll(() => {
    server.close()
})

describe('Blog page', () => {
    it('Should render the list of blogs pre-fetched by getStaticProps', async () => {

    })
})

afterEachの中身の処理は各テストケースの処理が終了するごとにハンドラーの中身をお掃除している
afterAllの中身の処理はすべてのテストケースが終了した場合にサーバーを閉じている

テストケースの中身を書く

it('テストの中身', () => {})

const { page } = await getPage({
   route: '/blog-page',
})
render(page)
expect(await screen.findByText('blog page')).toBeInTheDocument()
expect(screen.getByText('dummy title 1')).toBeInTheDocument()
expect(screen.getByText('dummy title 2')).toBeInTheDocument()
BlogDetailのテストも行う

こちらのテストではユーザーイベントを再現するためiuserEventモジュールのインポートを行っている
こちらはpost詳細データ取得の部分もモック化する以外は一覧画面と同じ前処理
BlogDetail.test.tsx のテストの中身

import userEvent from '@testing-library/user-event'

// ..............

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()
        // screen.debug()
    })
    it('Should route back to blog-page from detailed page', async () => {
        const { page } = await getPage({
            route: '/posts/2',
        })
        render(page)
        expect(await screen.findByText('dummy title 2')).toBeInTheDocument()
        userEvent.click(screen.getByTestId('back-blog'))
        expect(await screen.findByText('blog page')).toBeInTheDocument()
    })
})

userEvent.click(screen.getByTestId('back-blog'))がユーザーイベントを行っている
ユーザーイベントを行うときはtestidを設定しておく必要がある


Propsのテスト

コンポーネントが与えられたPropsに対して正しくレンダリングできているかどうか確かめるテストを作成する
複雑なコンポーネントになればなるほどテストの重要度が増す

Postコンポーネントのpropsに対するテストProps.test.tsxを作成
Propsをテストするテストの基本形はこちらになる

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

describe('Post component with given props', () => {
    let dummyProps: POST
    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} />)
    })
})


Propsのテストケースの実装

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

スクリーンデバッグを入れて確認するとダミーで渡したPropsの値が表示されている

console.log    
    <body>       
      <div>      
        <div>    
          <span> 
            1    
          </span>
           :
          <a
            class="curxor-pointer border-b border-gray-500 hover:bg-gray-300"
            href="/posts/1"
          >
            dummy title 1
          </a>
        </div>
      </div>
    </body>

Static Side Generation + Client Side Fetchingの実装ケースにおけるテストの作成

build時にDBの中身はPre-Fetchをしないパターンの実装(静的な内容に関してはプリレンダリングされる)
SEO対策が必要のないコンテンツに有効
サイトの例としてはTodoリストやDashboardなど

今回はコメント機能を作成してテスト実装する

components/Comment.tsx

import { COMMENT } from '../types/Types'

const Comment: React.FC<COMMENT> = ({ id, name, body }) => {
    return (
        <li className="mx-10">
            <p>
                {id}
                {' : '}
                {body}
            </p>
            <p className="text-center mt-3 mb-10">
                {'by '}
                {name}
            </p>
        </li>
    )
}

export default Comment

pages/comment-page.tsx

import Layout from '../components/Layout'
import useSWR from 'swr'
import axios from 'axios'
import Comment from '../components/Comment'
import { COMMENT } from '../types/Types'

const axiosFetcher = async () => {
    const result = await axios.get<COMMENT[]>(
        `https://jsonplaceholder.typicode.com/comments/?_limit=10`
    )
    return result.data
}

const CommentPage: React.FC = () => {
  const { data: comments, error } = useSWR('commentsFetch', axiosFetcher)
    
  if (error) return <span>Error!</span>

  return (
    <Layout title="Comment">
      <p className="text-4xl m-10">comment page</p>
      <ul>{comments && comments.map((comment) => <Comment key={comment.id} {...comment} />)}</ul>
    </Layout>
  )
}

export default CommentPage
テスト実装の前に

useSWRの仕様を確認しておく
https://swr.vercel.app/ja, https://swr.vercel.app/ja/docs/options
initialDataは初期値であり、feedbackに名前が変更になっている
revalidateOnMount: trueにしているとリアクトコンポーネントやページがマウントされたときにサーバーから最新のデータを取得しに行く(デフォルトでtrueだが、initialDataを設定している場合に明示的にtrueにしなければならない制約がある)
refreshInterval: defaultでは0秒だが、1000msに設定すると1sごとに定期的にデータを更新することができる
リアルタイムダッシュボードなどに利用できる
ただしサーバーへの負荷はかなり高くなる
dedupingInterval: デフォルトでは2秒間に複数のリクエストがあった場合は最初の一回だけを実行してサーバーへの負荷を低減させる
キャッシュを活用したい場合はこの時間を長く設定するとよいとされる
また、テストで使用する場合はdedupingInterval は 0 とすることが推奨されている

CommentPage.test.tsxテストの実装

SWRのテストの場合は他のテストの必要なコンポーネントに加えて以下のコンポーネントを追加する

import { SWRConfig, Cache } from 'swr'

ダミーとなるサーバーを作成する(サーバーのモック化)

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

ダミーサーバーの前処理・後処理の実装

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

テストケースの作成
正常系

describe('Comment page with useSWR / Success+Error', () => {
    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()
    })
})

異常系
server.use( rest.get( のステータスを400にすることでErrorを意図的に起こすことができる

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

screen.debug()でエラーが出ていることを確認できる

console.log   
    <body>      
      <div>     
        <span>  
          Error!
        </span> 
      </div>    
    </body>

複数コンテキストをグローバルステートで管理しているコンポーネントに対するテストの実装

複数のコンポーネントにまたがってステートを管理している状態管理のテスト
状態管理をするcontextフォルダとStateProvider.tsxを作成、プロバイダーを利用するコンポーネントを2つつかったページを作成してテストを行う

状態管理基本形 StateProvider.tsx

import { useContext, useState, createContext } from "react";

const StateContext = createContext(管理するステートのデータ型)

export const StateProvider: React.FC = ({ children }) => {
    const [管理したいステート, セットステート] = useState(初期値)

    return (
        <StateContext.Provider value={{ 状態管理するステート,  セットステート }}>
            {children}
        </StateContext.Provider>
    )
}

export const useStateContext = () => useContext(StateContext)

export const useStateContext の処理に関して、本来はProviderを使用したい場合はコンポーネント側でuseContextをインポートして引数からStateContextを渡す必要があるところを、呼び出し側でStateContextを毎回呼び出さないで済むように実装している


状態管理するステートは toggle: boolean型とする
CreateContext()を使ってコンテキストを作成する
TypeScriptは管理するステートの型を定義する必要があり、toggleだけでなくsetToggleも型を明示する必要がある
setToggleはカーソルをホバーすると表示される
表示される型の属性にReact.を付与することで定義できる
f:id:shinseidaiki:20211122014029p:plain

StateProvider.tsx 実装完成形

import { useContext, useState, createContext } from "react";

const StateContext = createContext(
    {} as {
        toggle: boolean
        setToggle: React.Dispatch<React.SetStateAction<boolean>>
    }
)

export const StateProvider: React.FC = ({ children }) => {
    const [toggle, setToggle] = useState(false)

    return (
        <StateContext.Provider value={{ toggle, setToggle }}>
            {children}
        </StateContext.Provider>
    )
}

export const useStateContext = () => useContext(StateContext)


StateProvider.tsxを利用するコンポーネントのContextAとContextBの2種類のコンポーネントを作成する

状態管理を利用するコンポーネントの基本形

import { useStateContext } from "../context/StateProvider"

const ContextA: React.FC = () => {
    const { 使用するステート, 対になるセットステート} = useStateContext()

    return (
        <>
        </>
    )
} 

export default ContextA

セットステートは参照するだけなら不要
使用するステートが複数ある場合は列挙して書く


ContextA.tsx return中身

<button
    className="bg-gray-500 hover:bg-gray-400 px-3 py-2 mb-5 text-white rounded focus:outline-none"
    onClick={() => setToggle(!toggle)}
>
    change
</button>
<p>Context A</p>
<p className="mb-5 text-indigo-600" data-testid="toggle-a">
    {toggle ? 'true' : 'false'}
</p>

ContextB.tsx toggleコンテキストを参照するだけ

import { useStateContext } from "../context/StateProvider"

const ContextB: React.FC = () => {
    const { toggle } = useStateContext()
    return (
        <>
            <p>Context A</p>
            <p className="text-indigo-600" data-testid="toggle-b">
                {toggle ? 'true' : 'false'}
            </p>
        </>
    )
} 

export default ContextB

context-pageにコンテキストA, Bを表示させる
Providerを使用する際は使用したい場所の直前で囲むことでProviderを利用することができる
またアプリ全体でProviderを利用する場合はindexや_appからプロバイダーを囲うことで毎回プロバイダーを呼ぶ手間がかからなくすることもできる

<StateProvider>
  <ContextA />
  <ContextB />
</StateProvider>
状態管理のテストの実装

Context.test.tsx作成

状態管理テストの基本形

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

Context.test.tsx テストケース作成

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

testidで指定したデータの内容を読む場合はテキストの場合、.textContentをつける
ボタンを押すときはgetByRole('button')を使用する

テストが成功ればよい
状態管理されているテストは特段変わったことをする必要はない

Static Site Generation + Pre-Fetch + Client side fetching が実装されたアプリのテスト実装方法

動的データがビルド時に事前にプリレンダリングされていてかつ、最新データもリアルタイムで反映されてかつ、SEO対策も施されているパターンの実装におけるテストを実装していく

トラブルシュート

useSWRのinitialDataが廃止 → fallbackDataに変更

task-page.tsxで Static Site Generation + Pre-Fetch + Client side fetching の実装を行っていく

task-page.tsx

import Layout from "../components/Layout"
import { GetStaticProps } from "next"
import { getAllTaskData } from "../lib/fetch"
import useSWR from "swr"
import axios from "axios"
import { TASK } from "../types/Types"

interface STATICPROPS {
  staticTasks: TASK[]
}

const axiosFetcher = async () => {
  const result = await axios.get<TASK[]>(
    `https://jsonplaceholder.typicode.com/todos/?_limit=10`
  )
  return result.data
}

const TaskPage: React.FC<STATICPROPS> = ({staticTasks}) => {
  const { data: tasks, error } = useSWR('todosFetch', axiosFetcher, {
    fallbackData: staticTasks,
    revalidateOnMount: true,
  })

  if (error) return <span>Error!</span>

  return (
    <Layout title="Todos">
      <p className="text-4xl">todos page</p>
      <ul>
        {tasks && tasks.map((task) => (
          <li key={task.id}>
            {task.id}
            {': '}
            <span>{task.title}</span>
          </li>
        ))}
      </ul>
    </Layout>
  )
}
export default TaskPage

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

最初の挙動としてはビルド時にサーバーサイドで getStaticProps が実行されて、ファンクショナルコンポーネントに渡ったstaticTasks がuseSWRの初期値 fallback に設定されて、静的コンテンツが生成される
その後、運用時にユーザーがアクセスするなどしてランタイムでTaskPageコンポーネントがマウントされた場合はクライアントサイドでuseSWRが実行され、最新のデータがサーバーから取得されて、取得したデータでreturn以下の内容を上書きレンダリングして最新情報を反映するという挙動が行われる

Static Site Generation + Pre-Fetch + Client side fetching のテストの実装

このタイプは2段階に分けたテストを実装していく
1. getStaticPropsと 2. useSWR のテストに分ける

1. getStaticProps関係のテストを行う

TaskPageStatic.test.tsx

/**
 * @jest-environment jsdom
 */
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 'setimmediate'

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()
    })
})

モックサーバーを立ててgetStaticPropsのメソッドが正しく動作するかを確認する
task-page.tsxにおける useSWR のtasksにはこのテストで定義した初期値が入る挙動となることを確認できればよい

トラブルシュート

task-pageでtasksがレンダリングされない場合はuseSWRの中の fallbackData が fallback とかになっていないか確認

トラブルシュート

ReferenceError: setImmediate is not defined 対処法
"setImmediate is not defined"エラー発生時

1. setimmdiate パッケージのnpmインストール

npm i setimmediate

2. 各テストファイルのimport部に下記importを追加

import "setimmediate"
2. useSWR関係のテストを行う

こちらのテストを行う際は、TaskPageのgetStaticPropsは使用せずに、ファンクショナルコンポーネントのprops(staticTasks)にダミーデータを渡して行う
useSWRのテストなので、CommentPage.test.tsxで作成したときのようなダミーサーバーの用意とテストしたいページをSWRConfigでラップしてレンダーする実装を行い、かつダミーPropsデータをテスト内で作成してテストを行う

<TaskPage staticTasks={staticProps} />

で渡すダミーデータのstaticPropsを用意する

let staticProps: TASK[]
    staticProps = [
        {
            userId: 3,
            id: 3,
            title: 'Static task C',
            completed: true,
        },
        {
            userId: 4,
            id: 4,
            title: 'Static task D',
            completed: true,
        },
    ]

これがTaskPageのprops ( staticTasks ) に疑似的に渡される
こちらのpropsに直接渡すデータ (id: 3, 4) が最初に表示され、その後、最新情報として設定したconst server = setupServer で定義しているデータ (id: 1, 2) がuseSWRによって次に取得されているかを確認する

TaskPageSWR.test.tsx

/**
 * @jest-environment jsdom
 */
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'
import 'setimmediate'

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: 'Static task A',
                    completed: false,
                },
                {
                    userId: 2,
                    id: 2,
                    title: 'Static 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: true,
        },
    ]
    
    it('Should render CSF data after pre-rendered data', async () => {
        render(
            <SWRConfig value={{ dedupingInterval: 0 }}>
                <TaskPage staticTasks={staticProps} />
            </SWRConfig>
        )

        // props (staticTasks) に疑似的に渡すデータを初めに確認
        expect(await screen.findByText('Static task C')).toBeInTheDocument()
        expect(screen.getByText('Static task D')).toBeInTheDocument()
        screen.debug()
        // server から疑似的な最新データを取得できているかをその後確認
        expect(await screen.findByText('Static task A')).toBeInTheDocument()
        expect(screen.getByText('Static task B')).toBeInTheDocument()
        screen.debug()
    })
})

テストを行うと

npm 

screen.debug() propsデータ取得初期時 画面表示


  • 3
    :

    Static task C


  • 4
    :

    Static task D


screen.debug() 最新情報取得時 画面表示


  • 1
    :

    Static task A


  • 2
    :

    Static task B

想定した挙動で表示されていることがわかる


続いて異常系テストケースを実装する

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()
    })

デプロイ時に自動テスト実行設定をしてVercelにデプロイ

GitHubにプッシュ
Vercelはデフォルトのままデプロイする
deploy時にgetStaticPropsやgetStaticPathsが実行されて静的コンテンツが作成されている

デプロイ時自動テスト設定

VercelのSetting>General>BUILD COMMAND
OverrideをONにして、以下を追記してSave

npm test && npm run build

この設定をするとテストが通らないとデプロイされない

動作確認

テストを失敗するようにコードを書き換えてGitHubに再プッシュすると自動でbuildが開始する
VerceダッシュボードのDeploymentを確認すると

STATUS Error
Build Failed

表示されていることがわかる

コンソールにエラーの理由が記されている

✕ Should render Error text when fetch failed (1028 ms)
Error: Command "npm test && npm run build" exited with 1

ビルドもデプロイもされていないことがわかる

正しく戻して再度デプロイして完了
なお、異常系テストはVercel上でエラーとしてコンソールに表示(400ステータスなど)されるが、テスト自体が通っていれば大丈夫と判定される

END

テスト基礎2 Next js テスト実践編 Django連携編

この章からはこちらを参照
GitHub - GomaGoma676/api-blog-prj: [テスト編] Nextjs + React-testing-libraryでモダンReactソフトウェアテスト / Section 3 : REST API 🚀

トラブルシュート

[MSW] Found a redundant usage of query parameters in the request handler
npm test 実行時に発生する下記のwarning対応策
クエリパラメータ ?_limit=10 をeq経由で取得する形に変える

PyJWT versionでエラーが出るときは以下のバージョンにすること

pip install PyJWT==2.0.0

Django バックエンド作成

Blog機能などを使う
プロジェクト作成方法は前の記事参照

Tips!

seializers.pyにおいて、BlogSerializerで取得するtagsはデフォルトではidとなるが、以下のように上書きをすることでnamフィールドも取得できるようになる
TagsはManyToManyFieldで作っているのでmanyをTrueにする

tags = TagSerializer(many=True, read_only=True)

またリレーション先のusernameを取得したい場合はsource='user.username'で取得できる(Blogモデルでuserと定義しているため)

username = serializers.ReadOnlyField(source='user.username', read_only=True)

Nextjsフロントエンド作成

作成方法は基本的には前のブログにまとめてあるものを参照のこと
https://github.com/GomaGoma676/nextjs-testing-blog


package.json


今回はcookieを使用する

npm install universal-cookie

テスターを使用するのでテスターをインストールする

npm install next-page-tester


index.tsxのページ名はBlogPageにする
Cookiesを利用している部分だけの実装例を以下に記す

import Cookie from "universal-cookie"

const cookie = new Cookie()

const BlogPage: React.FC<STATICPROPS> = ({ posts }) => {
  const [hasToken, setHasToken] = useState(false) // cookieの保持状況はステートとして準備する
  const logout = () => {
    cookie.remove('access_token')
    setHasToken(false)
  }
  const deletePost = async (id: number) => {
    await fetch(`${process.env.NEXT_PUBLIC_RESTAPI_URL}/delete-blog/${id}`, {
      method: 'DELETE',
      headers: {
        Authorization: `JWT ${cookie.get('access_token')}`, // 認証ユーザーのみ削除できる
      },
    }).then((res) => {
      if (res.status === 401) {
        alert('JWT Token not valid')
      }
    })
  }
  useEffect(() => {
    if (cookie.get('access_token')) {
      setHasToken(true)
    }
  }, [])

  return (
    <Layout title="Blog">
      {hasToken && (
        // ログインユーザーのみに表示させたいもの
      )}
    </Layout>
    
  )
}
export default BlogPage
Tips!

CSS classNameのswith文の使用法例

{tags && tags.map((tag, i) => {
                    let bgcolor = 'bg-gray-400'
                    switch (i) {
                        case 0:
                            bgcolor = 'bg-blue-500'
                            break
                        case 1:
                            bgcolor = 'bg-gray-500'
                            break
                        case 2:
                            bgcolor = 'bg-green-500'
                            break
                        case 3:
                            bgcolor = 'bg-yellow-500'
                            break
                        case 4:
                            bgcolor = 'bg-indigo-500'
                            break
                        default:
                            bgcolor = 'bg-gray-400'
                    }
                    return <span className={`px-2 py-2 m-1 text-white rounded ${bgcolor}`}>{tag.name}</span>
                })}

なお、講義では以下のように記述されていた
コードの可読性の観点からすると三項演算子を複数回にわたって多用しない方がよいと考えられる

{tags &&
          tags.map((tag, i) => (
            <span
              className={`px-2 py-2 m-1 text-white rounded ${
                i === 0
                  ? 'bg-blue-500'
                  : i === 1
                  ? 'bg-gray-500'
                  : i === 2
                  ? 'bg-green-500'
                  : i === 3
                  ? 'bg-yellow-500'
                  : i === 4
                  ? 'bg-indigo-500'
                  : 'bg-gray-400'
              }`}
              key={tag.id}
            >
              {tag.name}
            </span>
          ))}
Tips! ページリンクの呼び出し方

リンクは通常以下のような形であるが、関数の中で呼び出したい場合はuseRouterを使用することができる

<Link href="/${呼び出し先}">
 <a>リンク名称</a>

useRouter()を使用する場合

import { useRouter } from "next/router"

// .........................

  const router = useRouter()

  const login = async () => {
        try {
            const res = await axios.post.............................................
            if (res.status === 200) {
                // ...........
                router.push('/')
            }
        }
    } 

Loginコンポーネントのテストを実装 !!

ログインコンポーネントテスト基本形
AuthPage.test.tsx

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

initTestHelpers()

const handlers = [
    rest.post(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/jwt/create/`, (req, res, ctx) => {
            return res(ctx.status(200), ctx.json({ access: '123xyz' }))
        }
    ),
    rest.post(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/register/`, (req, res, ctx) => {
            return res(ctx.status(201))
        }
    ),
    rest.get(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/get-blogs/`, (req, res, ctx) => {
            return res(
                ctx.status(200),
                ctx.json([
                    {
                        id: 1,
                        title: 'title1',
                        content: 'content1',
                        username: 'username1',
                        tags: [
                            { id: 1, name: 'tag1' },
                            { id: 2, name: 'tag2' },
                        ],
                        created_at: '2021-01-12 14:59:41',
                    },
                    {
                        id: 2,
                        title: 'title2',
                        content: 'content2',
                        username: 'username2',
                        tags: [
                            { id: 1, name: 'tag1' },
                            { id: 2, name: 'tag2' },
                        ],
                        created_at: '2021-01-13 14:59:41',
                    },
                ])
            )
        }
    ),
]
const server = setupServer(...handlers)
beforeAll(() => {
    server.listen()
})
afterEach(() => {
    server.resetHandlers()
    cleanup()
})
afterAll(() => {
    server.close()
})


describe('AdminPage Test Cases', () => {
    it('Should route to index-page when login succeeded', async () => {

    })
})

ハンドラーを作成してログインユーザーをモック化

トラブルシュート

環境変数読み込めない問題
以下の対応3で対応する
https://shinseidaiki.hatenablog.com/entry/2021/11/20/221502

ログインテストケース基本形

基本形は以下

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

initTestHelpers()
setupEnv()

const handlers = [
    rest.post(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/jwt/create/`, (req, res, ctx) => {
            return res(ctx.status(200), ctx.json({ access: '123xyz' }))
        }
    ),
    rest.post(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/register/`, (req, res, ctx) => {
            return res(ctx.status(201))
        }
    ),
    rest.get(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/get-blogs/`, (req, res, ctx) => {
            return res(
                ctx.status(200),
                ctx.json([
                    {
                        id: 1,
                        title: 'title1',
                        content: 'content1',
                        username: 'username1',
                        tags: [
                            { id: 1, name: 'tag1' },
                            { id: 2, name: 'tag2' },
                        ],
                        created_at: '2021-01-12 14:59:41',
                    },
                    {
                        id: 2,
                        title: 'title2',
                        content: 'content2',
                        username: 'username2',
                        tags: [
                            { id: 1, name: 'tag1' },
                            { id: 2, name: 'tag2' },
                        ],
                        created_at: '2021-01-13 14:59:41',
                    },
                ])
            )
        }
    ),
]
const server = setupServer(...handlers)
beforeAll(() => {
    server.listen()
})
afterEach(() => {
    server.resetHandlers()
    cleanup()
})
afterAll(() => {
    server.close()
})


describe('AdminPage Test Cases', () => {
    it('testcase1', async () => {
    })
    it('testcase2', async () => {
    })
})
Next.jsテストAssert基本形

Next.jsにおけるAssertはexpectとなる
基本形は以下

expect(await screen.findByText('Login')).toBeInTheDocument()
Next.jsテスターイベント実行基本形

Next.jsにおけるテスターによるユーザーイベントの動作実行はuserEventを使用する
基本形は以下

userEvent.type(screen.getByPlaceholderText('Username'), 'user1')

typeを使用した場合は、第一引数に入力させたい要素を指定する
よく使うものはgetByTextやgetByTestId
第二引数に入力させたいデータを入れる
userEventにはclickも存在する

テストケース1 (正常系)ログイン成功時のテスト

AdminPage画面にて正しくログインできたのちBlog-pageに遷移する挙動を確認する
change-modeの挙動も確認する
AuthPage.test.tsx

it('Should route to index-page when login succeeded', async () => {
        const { page } = await getPage({
            route: '/admin-page',
        })
        render(page)
        expect(await screen.findByText('Login')).toBeInTheDocument()
        userEvent.type(screen.getByPlaceholderText('Username'), 'user1')
        userEvent.type(screen.getByPlaceholderText('Password'), 'dummypw')
        userEvent.click(screen.getByText('Login with JWT'))
        expect(await screen.findByText('blog page')).toBeInTheDocument()
    })
テストケース2 (異常系)ログイン失敗のテスト

ログイン失敗の際は画面遷移せずにエラーを表示させる
異常系のエラーをモック化する場合は、serverの処理をオーバーライドすることで実現可能
AuthPage.test.tsx

it('Should not route to index-page when login failed', async () => {
        server.use(
            rest.post(
                `${process.env.NEXT_PUBLIC_RESTAPI_URL}/jwt/create/`, (req, res, ctx) => {
                    return res(ctx.status(400))
                }    
            )
        )
        const { page } = await getPage({
            route: '/admin-page',
        })
        render(page)
        expect(await screen.findByText('Login')).toBeInTheDocument()
        userEvent.type(screen.getByPlaceholderText('Username'), 'user1')
        userEvent.type(screen.getByPlaceholderText('Password'), 'dummypw')
        userEvent.click(screen.getByText('Login with JWT'))
        expect(await screen.findByText('Login Error')).toBeInTheDocument()
        expect(screen.getByText('Login')).toBeInTheDocument()
        expect(screen.queryByText('blog page')).toBeNull()
    })


serverのオーバーライドは以下のように行う

server.use(
            rest.post(
                `${process.env.NEXT_PUBLIC_RESTAPI_URL}/jwt/create/`, (req, res, ctx) => {
                    return res(ctx.status(400))
                }    
            )
        )

存在しないものの判定は以下のように記述

expect(screen.queryByText('blog page')).toBeNull()
テストケース3 ユーザー登録画面を表示することができるかを確認

mode changeをクリックして表示が切り替わるかを確認
AuthPage.test.tsx

it('Should change to register mode', async () => {
        const { page } = await getPage({
            route: '/admin-page',
        })
        render(page)
        expect(await screen.findByText('Login')).toBeInTheDocument()
        expect(screen.getByText('Login with JWT')).toBeInTheDocument()
        userEvent.click(screen.getByTestId('mode-change'))
        expect(screen.getByText('Sign up')).toBeInTheDocument()
        expect(screen.getByText('Create new user')).toBeInTheDocument()
    })
テストケース4 (正常系)ユーザー登録が成功してその後ログインも成功して画面が遷移するかを確認

AuthPage.test.tsx

it('Should route to index-page when register+login succeeded', async () => {
        const { page } = await getPage({
            route: '/admin-page',
        })
        render(page)
        expect(await screen.findByText('Login')).toBeInTheDocument()
        userEvent.click(screen.getByTestId('mode-change'))
        userEvent.type(screen.getByPlaceholderText('Username'), 'user1')
        userEvent.type(screen.getByPlaceholderText('Password'), 'dummypw')
        userEvent.click(screen.getByText('Create new user'))
        expect(await screen.findByText('blog page')).toBeInTheDocument()
    })
テストケース5 (異常系)ユーザー登録が失敗して画面が遷移せずエラーが表示されるかを確認
it('Should not route to index-page when register+login failed', async () => {
        server.use(
            rest.post(
                `${process.env.NEXT_PUBLIC_RESTAPI_URL}/register/`, (req, res, ctx) => {
                    return res(ctx.status(400))
                }    
            )
        )
        const { page } = await getPage({
            route: '/admin-page',
        })
        render(page)
        expect(await screen.findByText('Login')).toBeInTheDocument()
        userEvent.click(screen.getByTestId('mode-change'))
        userEvent.type(screen.getByPlaceholderText('Username'), 'user1')
        userEvent.type(screen.getByPlaceholderText('Password'), 'dummypw')
        userEvent.click(screen.getByText('Create new user'))
        expect(await screen.findByText('Registration Error')).toBeInTheDocument()
        expect(screen.getByText('Sign up')).toBeInTheDocument()
        expect(screen.queryByText('blog page')).toBeNull()
    })

ブログページのテストを行う

クッキーが存在する場合のテストの基本形

各テストケースが終わるたびにクッキーを削除している

document.cookie = 'access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'

access_token=; アクセストークンを空にして
expires: 有効期限を過去に設定する


他は同様の実装
BlogPage.test.tsx

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

initTestHelpers()
setupEnv()

const handlers = [
    rest.get(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/get-blogs/`, (req, res, ctx) => {
            return res(
                ctx.status(200),
                ctx.json([
                    {
                        id: 1,
                        title: 'title1',
                        content: 'content1',
                        username: 'username1',
                        tags: [
                            { id: 1, name: 'tag1' },
                            { id: 2, name: 'tag2' },
                        ],
                        created_at: '2021-01-12 14:59:41',
                    },
                    {
                        id: 2,
                        title: 'title2',
                        content: 'content2',
                        username: 'username2',
                        tags: [
                            { id: 1, name: 'tag1' },
                            { id: 2, name: 'tag2' },
                        ],
                        created_at: '2021-01-13 14:59:41',
                    },
                ])
            )
        }
    ),
]
const server = setupServer(...handlers)
beforeAll(async () => {
    server.listen()
})
afterEach(() => {
    server.resetHandlers()
    cleanup()
    document.cookie = 'access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'
})
afterAll(() => {
    server.close()
})


describe('BlogPage Test Cases', () => {
    it('', async () => {
    })
})
テストケース1 画面表示&遷移確認
it('Should route to admin page and route back to blog page', async () => {
        const { page } = await getPage({
            route: '/',
        })
        render(page)
        userEvent.click(screen.getByTestId('admin-nav'))
        expect(await screen.findByText('Login')).toBeInTheDocument()
        userEvent.click(screen.getByTestId('blog-nav'))
        expect(await screen.findByText('blog page')).toBeInTheDocument()
    })
テストケース2 クッキーが存在する場合にアイコンが表示される

Cookieがない場合にはアイコンが表示されない
クッキーを存在するように設定するやり方は以下

document.cookie = 'access_token=123xyz;'

アイコンの表示確認はtestidを確認する

expect(screen.getByTestId('logout-icon')).toBeInTheDocument()


BlogPage.test.tsx

it('Should render delete btn + logout btn when JWT token cookie exist', async () => {
        document.cookie = 'access_token=123xyz;'

        const { page } = await getPage({
            route: '/',
        })
        render(page)
        expect(await screen.findByText('blog page')).toBeInTheDocument()
        expect(screen.getByTestId('logout-icon')).toBeInTheDocument()
        expect(screen.getByTestId('btn-1')).toBeInTheDocument()
        expect(screen.getByTestId('btn-2')).toBeInTheDocument()
    })
テストケース3 クッキーが存在しない場合にアイコンが表示されない

Cookieがない場合は特段変わった処理を行う必要はない
確認が.toBeNull()になるだけ

it('Should not render delete btn + logout btn when no cookie', async () => {
        const { page } = await getPage({
            route: '/',
        })
        render(page)
        expect(await screen.findByText('blog page')).toBeInTheDocument()
        expect(screen.queryByTestId('logout-icon')).toBeNull()
        expect(screen.queryByTestId('btn-1')).toBeNull()
        expect(screen.queryByTestId('btn-2')).toBeNull()
    })

ないもの判定はqueryByTestId()を使用することに注意


テストケース4 prefetchでレンダリングされているブログのタイトルが表示されているか確認する
it('Should render the list of blogs pre-fetched by getStaicProps', async () => {
        const { page } = await getPage({
            route: '/',
        })
        render(page)
        expect(await screen.findByText('blog page')).toBeInTheDocument()
        expect(screen.getByText('title1')).toBeInTheDocument()
        expect(screen.getByText('title2')).toBeInTheDocument()
    })

個別ページのテスト

個別ページの基本形

個別のレスポンスのモック化が増える

rest.get(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/get-blogs/1`, (req, res, ctx) => {
            return res(
                ctx.status(200),
                ctx.json({
                    id: 1,
                    title: 'title1',
                    content: 'content1',
                    username: 'username1',
                    tags: [
                        { id: 1, name: 'tag1' },
                        { id: 2, name: 'tag2' },
                    ],
                    created_at: '2021-01-12 14:59:41',
                })
            )
        }
    ),

それ以外は同じ BlogDetail.test.tsx

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

initTestHelpers()
setupEnv()

const handlers = [
    rest.get(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/get-blogs/`, (req, res, ctx) => {
            return res(
                ctx.status(200),
                ctx.json([
                    {
                        id: 1,
                        title: 'title1',
                        content: 'content1',
                        username: 'username1',
                        tags: [
                            { id: 1, name: 'tag1' },
                            { id: 2, name: 'tag2' },
                        ],
                        created_at: '2021-01-12 14:59:41',
                    },
                    {
                        id: 2,
                        title: 'title2',
                        content: 'content2',
                        username: 'username2',
                        tags: [
                            { id: 1, name: 'tag1' },
                            { id: 2, name: 'tag2' },
                        ],
                        created_at: '2021-01-13 14:59:41',
                    },
                ])
            )
        }
    ),
    rest.get(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/get-blogs/1`, (req, res, ctx) => {
            return res(
                ctx.status(200),
                ctx.json({
                    id: 1,
                    title: 'title1',
                    content: 'content1',
                    username: 'username1',
                    tags: [
                        { id: 1, name: 'tag1' },
                        { id: 2, name: 'tag2' },
                    ],
                    created_at: '2021-01-12 14:59:41',
                })
            )
        }
    ),
    rest.get(
        `${process.env.NEXT_PUBLIC_RESTAPI_URL}/get-blogs/2`, (req, res, ctx) => {
            return res(
                ctx.status(200),
                ctx.json({
                    id: 2,
                    title: 'title2',
                    content: 'content2',
                    username: 'username2',
                    tags: [
                        { id: 1, name: 'tag1' },
                        { id: 2, name: 'tag2' },
                    ],
                    created_at: '2021-01-13 14:59:41',
                })
            )
        }
    ),
]
const server = setupServer(...handlers)
beforeAll(async () => {
    server.listen()
})
afterEach(() => {
    server.resetHandlers()
    cleanup()
    document.cookie = 'access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'
})
afterAll(() => {
    server.close()
})

describe('BlogDetailPage Test Cases', () => {
    it('', async () => {
    })
})
テストケース1-2 個別ID1-2データが取得できているかを確認
it('Should render detailed content of ID 1', async () => {
        const { page } = await getPage({
            route: '/posts/1',
        })
        render(page)
        expect(await screen.findByText('title1')).toBeInTheDocument()
        expect(screen.getByText('content1')).toBeInTheDocument()
        expect(screen.getByText('by username1')).toBeInTheDocument()
        expect(screen.getByText('tag1')).toBeInTheDocument()
        expect(screen.getByText('tag2')).toBeInTheDocument()
    })
    it('Should render detailed content of ID 2', async () => {
        const { page } = await getPage({
            route: '/posts/2',
        })
        render(page)
        expect(await screen.findByText('title2')).toBeInTheDocument()
        expect(screen.getByText('content2')).toBeInTheDocument()
        expect(screen.getByText('by username2')).toBeInTheDocument()
        expect(screen.getByText('tag1')).toBeInTheDocument()
        expect(screen.getByText('tag2')).toBeInTheDocument()
    })
テストケース3 個別ページからブログ一覧に戻れるかを確認
it('Should route back to blog-page from detail page', async () => {
        const { page } = await getPage({
            route: '/posts/',
        })
        render(page)
        expect(await screen.findByText('title2')).toBeInTheDocument()
        userEvent.click(screen.getByTestId('back-blog'))
        expect(await screen.findByText('blog page')).toBeInTheDocument()
    })

詳細ページのPostDetail単体テスト

Props.test.tsxを作成
疑似的なPropsを詳細ページのPropsに渡して想定通りレンダリングされるかを確認する

ダミーProps動作確認テスト基本形
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import PostDetail from '../pages/posts/[id]'
import { POST } from '../types/types'


describe('PostDetailPage Test Cases', () => {
    const dummyProps: POST = {
        id: 1,
        title: 'title1',
        content: 'content1',
        username: 'username1',
        tags: [
            { id: 1, name: 'tag1' },
            { id: 2, name: 'tag2' },
        ],
        created_at: '2021-01-12 14:59:41',
    }
    it('Should render correctly with given props value', async () => {
         render(<PostDetail {...dummyProps} />)
    })
})
テストケース1 dummyPropsが画面上に表示できているかを確認
it('Should render correctly with given props value', async () => {
        render(<PostDetail {...dummyProps} />)
        expect(screen.getByText(dummyProps.title)).toBeInTheDocument()
        expect(screen.getByText(dummyProps.content)).toBeInTheDocument()
        expect(screen.getByText(`by ${dummyProps.username}`)).toBeInTheDocument()
        expect(screen.getByText(dummyProps.tags[0].name)).toBeInTheDocument()
        expect(screen.getByText(dummyProps.tags[1].name)).toBeInTheDocument()
        expect(screen.getByText(dummyProps.created_at)).toBeInTheDocument()
    })

END