Open8

フロントエンドテストのまとめ

nabetsunabetsu

セットアップ

2020年師走における Next.js をベースとしたフロントエンドの環境構築の記事を元にセットアップを行う。

稼働確認(詰まったところ)

以下のコードを実行したところerror TS2686: 'React' refers to a UMD global, but the current file is a module. Consider adding an import instead.というエラーが出た。

src/__tests__/Home.test.tsx
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')).toBeInTheDocument()
})

テスト対象のComponentは以下

src/pages/index.js
const Home: React.FC = () => {
  return (
    <div>
      Hello
    </div>
  )
}
export default Home

原因

Reactのimport(import React from 'react')が行われていないため。
Reactのimportをコードに追加したら解決した。

ただし、yarn devでNext.jsを起動した時には正常に表示ができているのでこの機会に根本原因を調べてみた

Reactの仕組み

React公式ブログのIntroducing the New JSX Transformに記載してある通り、React17から

  • ブラウザはJSXを理解できないのでBabelやTypeScriptといったコンパイラーによってJSXをレギュラーJavaScriptに変換する必要がある
  • Create React AppやNext.jsといったツールではJSXを変換する仕組みがあらかじめ設定されている
  • React17ではJSXの変換に以下の
    • ReactをimportすることなくJSXの変換が可能に
    • 設定によるがコンパイル後のファイルサイズが改善される
    • JSXの書き方を変更する必要は一切ない

TypeScriptのエラー

メッセージに出ているようにエラーを出しているのはTypeScriptのコンパイル。

TypeScript4.1から上記のReactの機能に対応している。
Compoilerオプションとして以下のどちらかを設定すればOK

  • react-jsx
  • react-jsxdev

React 17 JSX Factories

テストファイルの修正

jestの設定を以下の通りreactからreact-jsxに変更すればReactをimportしなくてもエラーが発生しなくなった。

// エラーが出てた時
module.exports = {
  ...
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
  globals: {
    'ts-jest': {
      tsconfig: {
        jsx: 'react',
      },
    },
  },
}

// 修正後
module.exports = {
  ...
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
  globals: {
    'ts-jest': {
      tsconfig: {
        jsx: 'react-jsx',
      },
    },
  },
}
nabetsunabetsu

Reactでのテストの基本

  • screen.debug()
    • HTMLを出力する
nabetsunabetsu
describe('Navigation by Link', () => {
  // next-page-testerを使うので関数をasyncにする
  it('should route to selected page in navbar', async () => {
    const { page } = await getPage({
      route: '/index',
    })
  console.warn
    warn  - Detected next.config.js, no exported configuration found. https://err.sh/vercel/next.js/empty-configuration
nabetsunabetsu

msw

MSWとはフロントエンドの開発時に使用できるモックサーバのライブラリで、指定したエンドポイントへのリクエストを送った時の結果をモックしてくれる。
実際のAPIサーバとつなぐ前の段階でのテストや、実際のAPIではテストが難しいエラーレスポンスの場合のテストを実行するときに活用できる。

以下は公式ドキュメントに記載されている例だが、setupServerでエンドポイントと返されるステータスコードやレスポンスの値を指定することで、テスト対象のComponentで実施されるリクエスト結果がモックで指定したものになる。

// test/LoginForm.test.js
import '@testing-library/jest-dom'
import React from 'react'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Login from '../src/components/Login'

const server = setupServer(
  rest.post('/login', (req, res, ctx) => {
    // Respond with a mocked user token that gets persisted
    // in the `sessionStorage` by the `Login` component.
    return res(ctx.json({ token: 'mocked_user_token' }))
  }),
)

// Enable API mocking before tests.
beforeAll(() => server.listen())

// Reset any runtime request handlers we may add during the tests.
afterEach(() => server.resetHandlers())

// Disable API mocking after the tests are done.
afterAll(() => server.close())

test('allows the user to log in', async () => {
  render(<Login />)
  userEvent.type(
    screen.getByRole('textbox', { name: /username/i }),
    'john.maverick',
  )
  userEvent.type(
    screen.getByRole('textbox', { name: /password/i }),
    'super-secret',
  )
  userEvent.click(screen.getByText(/submit/i))
  const alert = await screen.findByRole('alert')

  // Assert successful login state
  expect(alert).toHaveTextContent(/welcome/i)
  expect(window.sessionStorage.getItem('token')).toEqual(fakeUserResponse.token)
})

test('handles login exception', () => {
  server.use(
    rest.post('/login', (req, res, ctx) => {
      // Respond with "500 Internal Server Error" status for this test.
      return res(
        ctx.status(500),
        ctx.json({ message: 'Internal Server Error' }),
      )
    }),
  )

  render(<Login />)
  userEvent.type(
    screen.getByRole('textbox', { name: /username/i }),
    'john.maverick',
  )
  userEvent.type(
    screen.getByRole('textbox', { name: /password/i }),
    'super-secret',
  )
  userEvent.click(screen.getByText(/submit/i))

  // Assert meaningful error message shown to the user
  expect(alert).toHaveTextContent(/sorry, something went wrong/i)
  expect(window.sessionStorage.getItem('token')).toBeNull()
})

参考資料

MSW で加速するフロントエンド開発

非同期リクエストを扱うコンポーネントのテスト:リクエストをインターセプト編

nabetsunabetsu

2. React Testing Library Tutorial

以下のページのチュートリアルを実施し、学んだ点をまとめる
https://www.robinwieruch.de/react-testing-library

2-1. Rendering a Component

  • RTLのrenderはJSXを受け取って表示してくれるので、これを使うことでComponentのテストが可能になる。

(App.js)

import React from 'react';

const title = 'Hello React';

function App() {
  return <div>{title}</div>;
}

export default App;

(App.test.js)

import React from 'react';
import { render } from '@testing-library/react';

import App from './App';

describe('App', () => {
  test('renders App component', () => {
    render(<App />);
  });
});
  • screen.debug()を使うことでrender functionによってrenderingされたDOMを表示することができる
  • これを使ってDOMを表示することでどうやってテストをするかを確認できる
import { render, screen } from '@testing-library/react';
...
render(<App />);
screen.debug();

以下のようにより複雑でReactの機能(state, props, controlled component)を使ったものであってもRTLの「人間が見るようにComponentを扱う」というコンセプト通り、実際に人間が見るレンダリングされるHTMLをテスト対象として扱うことができる。

import React from 'react';

function App() {
  const [search, setSearch] = React.useState('');

  function handleChange(event) {
    setSearch(event.target.value);
  }

  return (
    <div>
      <Search value={search} onChange={handleChange}>
        Search:
      </Search>

      <p>Searches for {search ? search : '...'}</p>
    </div>
  );
}

function Search({ value, onChange, children }) {
  return (
    <div>
      <label htmlFor="search">{children}</label>
      <input id="search" type="text" value={value} onChange={onChange} />
    </div>
  );
}

export default App;

2-2. Selecting Elements

  • getByTextで要素をセレクトし、toBeInTheDocumentでドキュメント内に存在するか確認できる
    • Selectした要素はassertionかuser interactionに使える
    • getByTextは対象の要素がないとエラーを投げるのでそれ自体をassertionに使うこともできる
    • getByTextは文字列か正規表現をインプットとして受け取る
    • 文字列の場合は完全一致、正規表現の場合は部分一致になる
describe('App', () => {
  test('renders App component', () => {
    render(<App />);
    screen.debug();
    expect(screen.getByText('Search:')).toBeInTheDocument();
  });
});

getByTextをassertionに使う例

// implicit assertion
    // because getByText would throw error
    // if element wouldn't be there
    screen.getByText('Search:');
 
    // explicit assertion
    // recommended
    expect(screen.getByText('Search:')).toBeInTheDocument();

文字列と正規表現の違い

// fails
expect(screen.getByText('Search')).toBeInTheDocument();
 
// succeeds
expect(screen.getByText('Search:')).toBeInTheDocument();
 
// succeeds
expect(screen.getByText(/Search/)).toBeInTheDocument();

2-3. Search Types

  • getByRole()
    • getByTextと並んでRTLで最もメジャーなSelect用のFunction
    • 基本はaria-labelで要素を取得するものだが、明示的に指定していないものでもHTMLには暗黙的なRoleが設定されており、それを使うこともできる。
    • 便利な機能として存在しないロールを指定した場合にはドキュメント内で取得可能なRoleの一覧を表示してくれるので、一々調べないでもこれで暗黙的なロールを知れる

以下がその他要素の取得に使えるFunction。

  • LabelText:
    • getByLabelText: <label for="search" />
  • PlaceholderText:
    • getByPlaceholderText: <input placeholder="Search" />
  • AltText:
    • getByAltText: <img alt="profile" />
  • DisplayValue:
    • getByDisplayValue: <input value="JavaScript" />
  • getByTestID:

取得不能なロールを指定した場合の例

render(<App />);
screen.getByRole('');

上記の出力結果でロールの指定方法としてtextboxが使えることがわかったのでそれを使ってassertionを行う

render(<App />);
expect(screen.getByRole('textbox')).toBeInTheDocument();

2-4. Search Variant

TextやRoleといった取得対象の要素によるタイプの違いだけでなく、検索用のFunctionには検索の仕方による違いも存在する。

  • getBy
  • queryBy
  • findBy

※検索の仕方が違うだけで検索対象の要素(Type)はどれでも同じ

  • queryByText
  • queryByRole
  • queryByLabelText
  • queryByPlaceholderText
  • queryByAltText
  • queryByDisplayValue

getByとqueryByの違い

getByは対象の要素がなければエラーを投げるので、例えば指定した条件の要素が存在しないことをassertしたい場合にはassertの前にgetByの時点でエラーが出てテストが失敗する。

こうしたケースではエラーを投げないqueryByを使う。

// fails
expect(screen.getByText(/Searches for JavaScript/)).toBeNull();

// success
expect(screen.queryByText(/Searches for JavaScript/)).toBeNull();

(getByの時のエラー)

findByの使いどころ

findByは非同期処理の確認で使う

テストのため、初回のレンダリング後にAPIをシミュレートした結果を受け取ってStateを更新し、再レンダリングを行う処理を構築する。

function getUser() {
  return Promise.resolve({ id: '1', name: 'Robin' });
}
 
function App() {
  const [search, setSearch] = React.useState('');
  const [user, setUser] = React.useState(null);
 
  React.useEffect(() => {
    const loadUser = async () => {
      const user = await getUser();
      setUser(user);
    };
 
    loadUser();
  }, []);
 
  function handleChange(event) {
    setSearch(event.target.value);
  }
 
  return (
    <div>
      {user ? <p>Signed in as {user.name}</p> : null}
    ...

こうした非同期処理をテストするにはasync testを書かないといけない

If we want to test the component over the stretch of its first render to its second render due to the resolved promise, we have to write an async test, because we have to wait for the promise to resolve asynchronously. In other words, we have to wait for the user to be rendered after the component updates for one time after fetching it:

テストコード自体は以下の通りになり、testのfunctionをasyncにした上で、非同期処理によって表示される要素の検索にはfindByを使う。

describe('App', () => {
  test('renders App component', async () => {
    render(<App />);
    // APIが返ってこない状態で何も表示されていないことを確認
    expect(screen.queryByText(/Signed in as/)).toBeNull();
    // APIが返ってきた時に表示されることの確認
    expect(await screen.findByText(/Signed in as/)).toBeInTheDocument();
  });
});

screen.debugでDOMを表示するとちゃんとDOMにも反映されていることが確認できる

// APIが返ってこない状態で何も表示されていないことを確認
    expect(screen.queryByText(/Signed in as/)).toBeNull();
    screen.debug();

    // APIが返ってきた時に表示されることの確認
    expect(await screen.findByText(/Signed in as/)).toBeInTheDocument();
    screen.debug();

(参考?この状態で以前のテストを実行すると以下のエラーが出る)

複数の項目のテスト

searchVariantに以下のものを使えば複数の項目を検索してくれる。
結果は配列で返ってくる

  • getAllBy
  • queryAllBy
  • findAllBy

アサートの方法

ここまでの例で出てきた以下の2つが最も良く使われるアサートの方法。

  • toBeNull
  • toBeInTheDocument

上記の2つも含めてアサートのfunctionはJestに起源があるが、RTLが独自で実装しているものもある。
以下がそのリストでjest-domをインストールすれば使えるようになる(create-react-appの場合は最初からインストールされている)

toBeDisabled
toBeEnabled
toBeEmpty
toBeEmptyDOMElement
toBeInTheDocument
toBeInvalid
toBeRequired
toBeValid
toBeVisible
toContainElement
toContainHTML
toHaveAttribute
toHaveClass
toHaveFocus
toHaveFormValues
toHaveStyle
toHaveTextContent
toHaveValue
toHaveDisplayValue
toBeChecked
toBePartiallyChecked
toHaveDescription

FireEvents

ユーザのアクションに応じた挙動をテストする。

ユーザがインプットに入力をした場合にはComponentが再レンダリングされて新しい値が表示されるはず。

こうしたユーザのアクションを再現するにはfireEvent functionを使う。

fireeventは以下の2つの引数を受け取る

  • an element
    • here the input field by textbox role
  • event
    • here an event which has the value "JavaScript"

(fireEventの例)

test('fire events', () => {
    render(<App />);

    screen.debug();

    fireEvent.change(screen.getByRole('textbox'), {
      target: { value: 'JavaScript' },
    });

    screen.debug();
  });

fireEventでフォームに入力した後には入力後の値がDOMに表示されていることが確認できる

非同期処理が含まれる場合

以下のようなWarningが表示される。

Warningに表示されているようにRTLのact functionで対応できるが、awaitでresolveを待つことでも解決できる。(毎回これをやるのは面倒な気がするが...)

test('fire events', async () => {
    render(<App />);

    // wait for the user to resolve
    // needs only be used in our special case
    await screen.findByText(/Signed in as/);

    screen.debug();

    fireEvent.change(screen.getByRole('textbox'), {
      target: { value: 'JavaScript' },
    });

    screen.debug();
  });

最終的にdebugの部分をassertionに書き換えればOK(resolveした後なのでgetByという理解でOK?findByにするとエラーになった)

2つ目のassertionはqueryByでも良いみたい

Sometimes you will see people use queryBy for the latter assertion too, because it can be used similar to getBy when it comes to elements which should be there.

test('fire events', async () => {
    render(<App />);

    // wait for the user to resolve
    // needs only be used in our special case
    await screen.findByText(/Signed in as/);

    expect(screen.queryByText(/Searches for JavaScript/)).toBeNull();

    fireEvent.change(screen.getByRole('textbox'), {
      target: { value: 'JavaScript' },
    });

    expect(screen.getByText(/Searches for JavaScript/)).toBeInTheDocument();
  });

UserEventの活用

RTLではfireEventの機能を拡張したuserEventというライブラリがある。

userEventはユーザがブラウザ上で行う挙動をfireEventより正確に再現することができる(値の変化だけでなくkeyDown, keyPress, keyUpなど)ので、特に理由がなければuserEventを使った方がいい。

※記事の執筆時点でfireEventが提供している全てのことがuserEventで実現できるわけではないので、その場合にはfireEventを使う

Callback Handlers

Unit TestとしてComponentを他のComponentとは隔離してテストしたい場合。

※RTLではComponentごとに隔離した状態でのテストをやりすぎることは推奨していない(integration testを推奨している)ので注意

Often these components will not have any side-effects or state, but only input (props) and output (JSX, callback handlers)

(Search.js)

import React from 'react';

function Search({ value, onChange, children }) {
  return (
    <div>
      <label htmlFor="search">{children}</label>
      <input id="search" type="text" value={value} onChange={onChange} />
    </div>
  );
}

export default Search;

Jestのutilityを使ってComponentに渡されるonChangeをmockし、mockしたfunctionが呼ばれることをテストする。

describe('Search', () => {
  test('calls the onChange callback handler', () => {
    const onChange = jest.fn();

    render(
      <Search value="" onChange={onChange}>
        Search:
      </Search>
    );

    fireEvent.change(screen.getByRole('textbox'), {
      target: { value: 'JavaScript' },
    });

    expect(onChange).toHaveBeenCalledTimes(1);
  });
});

先ほど記した通りuserEventを使うことでより精緻に機能の確認ができる。
実際にはonChangeはユーザがタイプするたびに呼ばれるので、今回のケースだと10回呼ばれるべきであり、userEventを使うことでそれがテストできる。

test('calls the onChange callback handler', async () => {
    ...
    // userEventを使って書き換え
    // fireEvent.change(screen.getByRole('textbox'), {
    //   target: { value: 'JavaScript' },
    // });
    await userEvent.type(screen.getByRole('textbox'), 'JavaScript');

    expect(onChange).toHaveBeenCalledTimes(10);

ASYNCHRONOUS / ASYNC

ボタンをクリックするとaxiosを使ってHacker NewsのAPIを呼ぶ処理のテストを書く

(AsyncSample.js)

import React from 'react';
import axios from 'axios';

const URL = 'http://hn.algolia.com/api/v1/search';

function AsyncSample() {
  const [stories, setStories] = React.useState([]);
  const [error, setError] = React.useState(null);

  async function handleFetch(event) {
    let result;

    try {
      result = await axios.get(`${URL}?query=React`);

      setStories(result.data.hits);
    } catch (error) {
      setError(error);
    }
  }

  return (
    <div>
      <button type="button" onClick={handleFetch}>
        Fetch Stories
      </button>

      {error && <span>Something went wrong ...</span>}

      <ul>
        {stories.map(story => (
          <li key={story.objectID}>
            <a href={story.url}>{story.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default AsyncSample;

テストコードは以下のようになる

  • 非同期処理なのでtest functionをasyncにする
  • jest.mockでaxiosをMockする
  • axios.get.mockImplementationOnceでMockから返される値を設定する
  • userEventで実際と同じようにボタンをクリックさせ、非同期処理なのでfindByで表示される要素を取得する
  • 取得した要素でassertionを行う。

注意点としてaxiosではなく別のライブラリやfetchを使っていたらそれをMockしないといけない

if you are using another library or the browser's native fetch API for data fetching, you would have to mock these.

import axios from 'axios';
...

jest.mock('axios');

describe('App', () => {
  test('fetches stories from an API and displays them', async () => {
    const stories = [
      { objectID: '1', title: 'Hello' },
      { objectID: '2', title: 'React' },
    ];

    axios.get.mockImplementationOnce(() => Promise.resolve({ data: { hits: stories } }));

    render(<App />);

    await userEvent.click(screen.getByRole('button'));

    const items = await screen.findAllByRole('listitem');

    expect(items).toHaveLength(2);
  });
});

次にAPIリクエストが失敗した場合のテストケースは以下のようになる。
全体の流れは同じで、違うのはmockから返すのをrejectでErrowオブジェクトにすること。

test('fetches stories from an API and fails', async () => {
    axios.get.mockImplementationOnce(() => Promise.reject(new Error()));

    render(<App />);

    await userEvent.click(screen.getByRole('button'));

    const message = await screen.findByText(/Something went wrong/);

    expect(message).toBeInTheDocument();
  });

上記のコードでは以下のWarningが出るが、userEvent(fireEvent)の箇所をact functionでラップしてあげれば出なくなる

act(() => {
  userEvent.click(screen.getByRole('button'));
});
nabetsunabetsu

Jest

カバレッジを出力

jestの実行オプションに--coverageをつければいい

jest --coverage

カバレッジは以下の形式で出力される。

-------------|---------|----------|---------|---------|-------------------
File         | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
-------------|---------|----------|---------|---------|-------------------
All files    |   95.65 |    93.75 |     100 |     100 |                   
 fizzbuzz.js |   95.65 |    93.75 |     100 |     100 | 13                
-------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.415 s, estimated 1 s
Ran all test suites.

Junitのレポートを出力

jest-junitが必要なのでインストールする

npm install --save-dev jest-junit

jestの実行時にオプションで設定を追加すればOK(詳細は公式ドキュメント参照)

jest --ci --reporters=default --reporters=jest-junit

Coverageの正規表現での取得

GitLab等のCIで正規表現でカバレッジ率を取得する場合、以下の正規表現で全体のカバレッジが取得できる。

coverage: /All\sfiles.*?\s+(\d+.\d+)/

参考資料

nabetsunabetsu

フロントエンドテスト戦略

フロントエンドの場合にはテストの実行環境がNode.js上とブラウザ上の2つに分かれ、それを考慮してテスト戦略を考える必要がある。

JestでのテストはNode.js(JSDOM)上での実施のため、高速ではあるが実際のブラウザの挙動を再現することが出来ない。そのため、ブラウザ上での挙動に関するテストは必ず必要であり、マニュアルテストを実施するか、CypressやSelenium等実際にブラウザ上で動作するツールを利用する必要がある。

使用するライブラリやツール

テストの観点ごとに利用が想定されるライブラリを以下のとおりまとめる。

テストの種類 テスト観点 想定されるライブラリ
Unit Componentを対象にしたテストを実施する。基本的にAPIや外部ファイルはモックを使用する React Testing Library, Jest
Integration Componentを対象にしたテストを実施する。基本的にAPIや外部ファイルはモックを使用する React Testing Library, Jest
UI UI(見栄え)について、想定外の変更がないかチェックを行う Storybook
E2E ステージング環境など実際に稼働するアプリケーションに対して、システムすべてを組み合わせた状態での機能を確認する Cypress

以下はテスト戦略についてまとめられた数少ないWeb上の参考資料。