wheatandcatの開発ブログ

React Nativeで開発しているペペロミア & memoirの技術系記事を投稿してます

Enzyme→testing-library/react-nativeに置き換え

Expo SDK 48にアップデートをしたらReactのバージョンが最新になり、Enzymeがサポートされなくなったので、すべてのテストケースをtesting-library/react-nativeに置き換えた

PR

実装

今までEnzymeのshallowを使用して、テスト対象となるcomponentのスナップショットのみを行っていた。

■ Enzymeでのテストコード src/components/pages/Home/tests/index.test.tsx

import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { Home, Props } from '../';

const propsHomeData = (): Props =>
  ({
    navigation: {
      setParams: jest.fn(),
      navigate: jest.fn(),
    },
    route: {
      params: {},
    },
  } as any);

describe('components/pages/Home/index.tsx', () => {
  let wrapper: ShallowWrapper;

  describe('Home', () => {
    beforeEach(() => {
      wrapper = shallow(<Home {...propsHomeData()} />);
    });

    it('正常にrenderすること', () => {
      expect(wrapper).toMatchSnapshot();
    });
  });
});

shallowを使用すると直下のテストで参照する直下のコンポーネント以外は自動でMockされるので深く考えずにコンポーネントだけimportしたテストコードを書けばOKだった。 この辺はEnzymeの楽なところではあったが、実際の運用的にはテストというよりは修正による影響検知に近い使い方をしていた。

ただ、上記にも記載した通り、Reactの最新バージョンをEnzymeがサポートしなくなったので、testing-library/react-nativeに置き換えた。

testing-library.com

書き換えた結果は以下の通り。

■ react-native-testing-libraryでのテストコード src/components/pages/Home/tests/index.test.tsx

import React from 'react';
import * as Recoil from 'recoil';
import { items } from '__mockData__/item';
import * as useHomeItems from 'hooks/useHomeItems';
import * as client from '@apollo/client';
import { testRenderer } from 'lib/testUtil';
import { screen } from '@testing-library/react-native';
import { Home, Props } from '../';

const propsHomeData = (): Props =>
  ({
    navigation: {
      setParams: jest.fn(),
      navigate: jest.fn(),
      setOptions: jest.fn(),
      addListener: jest.fn(),
    },
    route: {
      params: {},
    },
  } as any);

describe('components/pages/Home/index.tsx', () => {
  beforeEach(() => {
    jest.spyOn(Recoil, 'useRecoilValue').mockImplementation((): any => ({
      items: items(),
    }));
    jest.spyOn(Recoil, 'useRecoilState').mockImplementation((): any => [
      {
        date: '2020-01-01',
      },
      jest.fn(),
    ]);
    jest.spyOn(useHomeItems, 'default').mockImplementation((): any => ({
      loading: false,
      error: null,
      refetch: jest.fn(),
    }));
    jest
      .spyOn(client, 'useQuery')
      .mockImplementation((): any => [jest.fn(), { loading: false }]);
    jest.spyOn(client, 'useMutation').mockImplementation((): any => [
      jest.fn(),
      {
        loading: false,
      },
    ]);
  });

  describe('Home', () => {
    it('正常にrenderすること', () => {
      testRenderer(<Home {...propsHomeData()} />)();
      expect(screen.findAllByText('今週のmemoirを確認する')).toBeTruthy();
    });
  });
});

react-native-testing-libraryを使用すると基本mockしないので実際に動かす時と同様の状態でテストさせる感じになるのでテストコードは長めになる。
API通信の部分は前の記事でも紹介したmswgraphql-codegen-typescript-mock-dataを使用して実装。

www.wheatandcat.me

APIのモック部分は以下のようなコードで実装。

src/mocks/handler.ts

import { graphql } from 'msw';
import {
  ItemDocument,
  UpdateItemDocument,
  DeleteItemDocument,
  ItemsByDateDocument,
  ItemsInPeriodDocument,
  RelationshipsDocument,
  InviteDocument,
  InviteByCodeDocument,
  UpdateInviteDocument,
  CreateInviteDocument,
  CreateRelationshipRequestDocument,
  DeleteUserDocument,
  RelationshipRequestsDocument,
  AcceptRelationshipRequestDocument,
  NgRelationshipRequestDocument,
  UpdateUserDocument,
} from 'queries/api/index';
import {
  aPageInfo,
  anItem,
  anItemsInPeriodEdge,
  aRelationshipEdge,
  anInvite,
  aUser,
  aRelationshipRequest,
  aRelationshipRequestEdge,
} from 'queries/api/mocks';

export const handlers = [
  graphql.query(ItemDocument, (req, res, ctx) => {
    return res(
      ctx.data({
        item: {
          ...anItem(),
          id: req.variables.id,
        },
      })
    );
  }),
  graphql.mutation(UpdateItemDocument, (req, res, ctx) => {
    return res(
      ctx.data({
        updateItem: {
          id: req.variables.input.id,
          date: req.variables.input.date,
        },
      })
    );
  }),
  graphql.mutation(DeleteItemDocument, (req, res, ctx) => {
    return res(
      ctx.data({
        deleteItem: {
          id: req.variables.input.id,
        },
      })
    );
  }),
  graphql.query(ItemsByDateDocument, (_, res, ctx) => {
    return res(
      ctx.data({
        itemsByDate: [{ ...anItem(), categoryID: 1 }],
      })
    );
  }),
  graphql.query(ItemsInPeriodDocument, (_, res, ctx) => {
    return res(
      ctx.data({
        itemsInPeriod: {
          pageInfo: aPageInfo(),
          edges: [anItemsInPeriodEdge()],
        },
      })
    );
  }),
  graphql.query(RelationshipsDocument, (_, res, ctx) => {
    return res(
      ctx.data({
        relationships: {
          pageInfo: aPageInfo(),
          edges: [aRelationshipEdge()],
        },
      })
    );
  }),
  graphql.query(InviteDocument, (_, res, ctx) => {
    return res(
      ctx.data({
        invite: anInvite(),
      })
    );
  }),
  graphql.query(InviteByCodeDocument, (_, res, ctx) => {
    return res(
      ctx.data({
        inviteByCode: aUser(),
      })
    );
  }),
  graphql.mutation(UpdateInviteDocument, (_, res, ctx) => {
    return res(
      ctx.data({
        updateInvite: anInvite(),
      })
    );
  }),
  graphql.mutation(CreateInviteDocument, (_, res, ctx) => {
    return res(
      ctx.data({
        createInvite: anInvite(),
      })
    );
  }),
  graphql.mutation(CreateRelationshipRequestDocument, (_, res, ctx) => {
    return res(
      ctx.data({
        createRelationshipRequest: aRelationshipRequest(),
      })
    );
  }),
  graphql.mutation(DeleteUserDocument, (_, res, ctx) => {
    return res(
      ctx.data({
        deleteUser: aUser(),
      })
    );
  }),
  graphql.query(RelationshipRequestsDocument, (_, res, ctx) => {
    return res(
      ctx.data({
        relationshipRequests: {
          pageInfo: aPageInfo(),
          edges: [aRelationshipRequestEdge()],
        },
      })
    );
  }),
  graphql.mutation(AcceptRelationshipRequestDocument, (_, res, ctx) => {
    return res(
      ctx.data({
        acceptRelationshipRequest: aRelationshipRequest(),
      })
    );
  }),
  graphql.mutation(NgRelationshipRequestDocument, (_, res, ctx) => {
    return res(
      ctx.data({
        ngRelationshipRequest: aRelationshipRequest(),
      })
    );
  }),
  graphql.mutation(UpdateUserDocument, (_, res, ctx) => {
    return res(
      ctx.data({
        updateUser: aUser(),
      })
    );
  }),
];

上記の方法でほとんどのコンポーネントは置き換えが成功。

一部のコンポーネントで以下のようなエラーが発生した。

  console.error
    Warning: An update to Connected inside a test was not wrapped in act(...).

    When testing, code that causes React state updates should be wrapped into act(...):

    act(() => {
      /* fire events that update state */
    });
    /* assert on the output */

    This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act

こちらのエラーはテスト実行中にstate or propsが更新されて再レンダリングがかかっている場合に発生していた。 移行したコード内だと以下のようなコードがある場合に発生。

src/components/pages/Search/Plain.tsx

import React, { memo } from 'react';
import Loading from 'components/atoms/Loading';
import TemplateSearch from 'components/templates/Search/Page';
import { ConnectedType } from './Connected';

type Props = ConnectedType & {
  loading: boolean;
};

const Plain: React.FC<Props> = (props) => {
  if (props.loading) return <Loading />;

  return <TemplateSearch users={props.users} onSearch={props.onSearch} />;
};

export default memo(Plain);

props.loading=true(APIのレスポンス待ちの状態)の場合はローディング画面を表示させて、falseになると画面が表示させるようなコードがある場合は、実際にテストしたいのはローディング後のコンポーネントなので、props.loading=falseになるのを待ってからテストする必要がある。

なので、以下のようなテストコードにすればOK。

src/components/pages/Search/tests/index.test.tsx

import React from 'react';
import * as Recoil from 'recoil';
import { testRenderer } from 'lib/testUtil';
import {
  screen,
  waitFor,
  waitForElementToBeRemoved,
} from '@testing-library/react-native';
import { user } from '__mockData__/user';
import IndexPage, { Props } from '../';

const propsData = (): Props =>
  ({
    navigation: {
      setParams: jest.fn(),
      navigate: jest.fn(),
    },
    route: {
      params: {},
    },
  });

describe('components/pages/Search/index.tsx', () => {
  beforeEach(() => {
    jest.spyOn(Recoil, 'useRecoilValue').mockImplementation((): any => ({
      ...user(),
    }));
  });

  it('正常にrenderすること', async () => {
    testRenderer(<IndexPage {...propsData()} />)();

    await waitForElementToBeRemoved(() => screen.getByTestId('atoms_loading'));

    await waitFor(async () => {
      expect(screen.findByText('検索')).toBeTruthy();
    });
  });
});

以下のコードの部分でローディング画面の表示が消えるのを待って、テストを実行すれば、このエラーは回避できる。

    await waitForElementToBeRemoved(() => screen.getByTestId('atoms_loading'));

こんな感じで諸々テストコードを置き換えて、無事Expo Sdk 48のバージョンアップが行えた。