📱

React Nativeのユニットテストを理解する

2024/12/18に公開

この記事はReact Native 全部俺 Advent Calendar 18目の記事です。

https://adventar.org/calendars/10741

このアドベントカレンダーについて

このアドベントカレンダーは @itome が全て書いています。

基本的にReact NativeおよびExpoの公式ドキュメントとソースコードを参照しながら書いていきます。誤植や編集依頼はXにお願いします。

React Nativeのユニットテストを理解する

React Nativeアプリケーションのテストは、一般的なReactアプリケーションと同様にJestを使って書くことができます。しかし、ネイティブの機能を使う部分やプラットフォーム固有の挙動については、いくつか追加の設定や考慮が必要になります。

テスト環境のセットアップ

React Nativeプロジェクトを作成すると、デフォルトでJestの設定が含まれています。この設定には react-native プリセットが含まれており、ネイティブモジュールのモックやトランスフォーマーの設定が含まれています。

package.json
{
  "scripts": {
    "test": "jest"
  },
  "jest": {
    "preset": "react-native"
  }
}

またreact-native-testing-libraryをインストールすることで、コンポーネントのレンダリングやユーザー操作のテストが書けるようになります。testing-libraryはReactコンポーネントのテストのデファクトスタンダードとなっており、実際のユーザー操作に近い形でテストを書くことができます。

testing-libraryをインストール
$ npm install --save-dev @testing-library/react-native

コンポーネントのテスト

まずはシンプルなコンポーネントのテストから見ていきましょう。React Nativeのコンポーネントの基本的なテストは、Webのテストと大きく変わりません。

Button.tsx
import { TouchableOpacity, Text } from 'react-native';

type Props = {
  onPress: () => void;
  label: string;
};

export const Button = ({ onPress, label }: Props) => (
  <TouchableOpacity onPress={onPress}>
    <Text>{label}</Text>
  </TouchableOpacity>
);
Button.test.tsx
import { render, fireEvent } from '@testing-library/react-native';
import { Button } from './Button';

describe('Button', () => {
  test('onPressが呼ばれる', () => {
    const onPress = jest.fn();
    const { getByText } = render(
      <Button onPress={onPress} label="ボタン" />
    );

    fireEvent.press(getByText('ボタン'));
    expect(onPress).toHaveBeenCalled();
  });

  test('labelが正しく表示される', () => {
    const { getByText } = render(
      <Button onPress={() => {}} label="テストボタン" />
    );

    expect(getByText('テストボタン')).toBeTruthy();
  });
});

@testing-library/react-nativerender関数を使うと、コンポーネントをレンダリングして要素を取得するためのクエリ関数を使うことができます。クエリ関数には以下のようなものがあります:

  • getByText: テキストで要素を検索
  • getByTestId: testIDで要素を検索
  • getByRole: アクセシビリティロールで要素を検索
  • queryByText: 要素が存在しない場合にnullを返す(存在チェックに使用)
  • findByText: 非同期で要素を検索

またfireEventを使うことで、ボタンのタップやテキスト入力などのユーザー操作をシミュレートできます。主な操作には以下のようなものがあります:

  • fireEvent.press: タップ操作
  • fireEvent.changeText: テキスト入力
  • fireEvent.scroll: スクロール操作

プラットフォーム固有の処理のテスト

React NativeではPlatform.select.ios.ts.android.tsといった拡張子を使ってプラットフォーム固有の実装を書くことがよくあります。これらのコードをテストする場合、Jestのモック機能を使ってプラットフォームを切り替える必要があります。

getPlatformText.ts
import { Platform } from 'react-native';

export const getPlatformText = () => {
  return Platform.select({
    ios: 'iOS',
    android: 'Android',
    default: 'unknown',
  });
};

このような場合、Jestのモックを使ってプラットフォームを切り替えることができます。テストの実行環境ではプラットフォーム情報が実際のデバイスと異なる可能性があるため、テスト内で明示的に設定する必要があります。

getPlatformText.test.ts
import { Platform } from 'react-native';
import { getPlatformText } from './getPlatformText';

describe('getPlatformText', () => {
  const originalPlatform = Platform.OS;

  // テスト後に元の状態に戻す
  afterEach(() => {
    Platform.OS = originalPlatform;
  });

  test('iOSの場合', () => {
    Platform.OS = 'ios';
    expect(getPlatformText()).toBe('iOS');
  });

  test('Androidの場合', () => {
    Platform.OS = 'android';
    expect(getPlatformText()).toBe('Android');
  });

  test('その他のプラットフォームの場合', () => {
    Platform.OS = 'web';
    expect(getPlatformText()).toBe('unknown');
  });
});

また.ios.ts.android.tsの拡張子を使っている場合は、JestのmoduleFileExtensions設定を使って適切なファイルが読み込まれるようにする必要があります。

jest.config.js
module.exports = {
  preset: 'react-native',
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'ios.ts', 'android.ts'],
};

ネイティブモジュールのモック

カメラやGPS、プッシュ通知など、ネイティブの機能を使うモジュールをテストする場合は、それらをモックする必要があります。実際のデバイスの機能は使えないため、期待する動作をモックで再現します。

useLocation.ts
import { useEffect, useState } from 'react';
import Geolocation from '@react-native-community/geolocation';

export const useLocation = () => {
  const [location, setLocation] = useState<null | { 
    latitude: number;
    longitude: number;
  }>(null);

  useEffect(() => {
    Geolocation.getCurrentPosition(
      position => {
        setLocation({
          latitude: position.coords.latitude,
          longitude: position.coords.longitude,
        });
      },
      error => {
        console.error(error);
      }
    );
  }, []);

  return location;
};
useLocation.test.ts
import { renderHook } from '@testing-library/react-hooks';
import { useLocation } from './useLocation';

// ネイティブモジュールのモック
jest.mock('@react-native-community/geolocation', () => ({
  getCurrentPosition: jest.fn(success => success({
    coords: {
      latitude: 35.6812,
      longitude: 139.7671,
    },
  })),
}));

describe('useLocation', () => {
  test('現在地を取得できる', async () => {
    const { result, waitFor } = renderHook(() => useLocation());
    
    await waitFor(() => {
      expect(result.current).toEqual({
        latitude: 35.6812,
        longitude: 139.7671,
      });
    });
  });

  test('エラーハンドリング', async () => {
    const consoleSpy = jest.spyOn(console, 'error');
    const mockGeolocation = require('@react-native-community/geolocation');
    
    mockGeolocation.getCurrentPosition.mockImplementationOnce((_, error) => 
      error(new Error('位置情報の取得に失敗しました'))
    );

    renderHook(() => useLocation());
    
    expect(consoleSpy).toHaveBeenCalled();
    consoleSpy.mockRestore();
  });
});

スナップショットテスト

UIの変更を検知するためのスナップショットテストも書くことができます。スナップショットテストは、コンポーネントのレンダリング結果をファイルに保存し、変更があった場合にテストが失敗するようにします。

Profile.test.tsx
import { render } from '@testing-library/react-native';
import { Profile } from './Profile';

describe('Profile', () => {
  test('スナップショットが一致する', () => {
    const { toJSON } = render(
      <Profile
        name="山田太郎"
        bio="フロントエンドエンジニア"
        avatarUrl="https://example.com/avatar.png"
      />
    );
    expect(toJSON()).toMatchSnapshot();
  });

  test('異なるデータでもスナップショットが一致する', () => {
    const { toJSON } = render(
      <Profile
        name="鈴木花子"
        bio="バックエンドエンジニア"
        avatarUrl="https://example.com/another-avatar.png"
      />
    );
    expect(toJSON()).toMatchSnapshot();
  });
});

スナップショットテストは便利ですが、以下のような点に注意が必要です。基本的にはユニットテストで動作を保証し、必要に応じて使っていくのがちょうどいいかなと思います。

  • スタイルの微細な変更でも失敗する
  • スナップショットファイルがバージョン管理に含まれる
  • 失敗したときの原因特定が難しい
  • アニメーションやランダムな値を含むコンポーネントではうまく機能しない

非同期処理のテスト

APIリクエストなどの非同期処理をテストする場合は、actを使って状態の更新を待つ必要があります。actを使わないと、テストが完了する前に非同期処理が終わっていない可能性があります。

useUser.test.ts
import { renderHook, act } from '@testing-library/react-hooks';
import { useUser } from './useUser';

// APIリクエストのモック
jest.mock('../api', () => ({
  fetchUser: jest.fn(() => 
    Promise.resolve({
      id: 1,
      name: '山田太郎',
    })
  ),
}));

describe('useUser', () => {
  test('ユーザー情報を取得できる', async () => {
    const { result } = renderHook(() => useUser());
    
    await act(async () => {
      await result.current.fetchUser(1);
    });
    
    expect(result.current.user).toEqual({
      id: 1,
      name: '山田太郎',
    });
  });

  test('ローディング状態が正しく変化する', async () => {
    const { result } = renderHook(() => useUser());
    
    expect(result.current.isLoading).toBe(false);
    
    act(() => {
      result.current.fetchUser(1);
    });
    
    expect(result.current.isLoading).toBe(true);
    
    await act(async () => {
      await result.current.fetchUser(1);
    });
    
    expect(result.current.isLoading).toBe(false);
  });
});

並列実行時の注意点

Jestはデフォルトで並列にテストを実行します。グローバルな状態を変更するテストがある場合は、テストケース間で競合が起きないように気をつける必要があります。

jest.config.js
module.exports = {
  preset: 'react-native',
  maxWorkers: 1, // 並列実行を無効化
  // または特定のファイルパターンだけ直列実行
  testSequencer: './custom-sequencer.js',
};

並列実行に関する主な注意点:

  • グローバル変数の変更
  • ファイルシステムへのアクセス
  • データベースの状態変更
  • タイマーやインターバルの使用
  • ネットワークリクエストのモック

これらの問題を回避するために、以下のような対策が考えられます:

  • テストケースごとにデータをリセット
  • テスト用のデータベースを分ける
  • モックのクリーンアップを確実に行う
  • 依存関係の分離を徹底する

まとめ

React Nativeのユニットテストは、基本的にJestとreact-native-testing-libraryを使って書くことができます。ただしネイティブの機能を使う部分はモックを使う必要があるなど、Webとは少し違った考慮が必要になります。
非同期処理のテストではactを使って状態の更新を待つ必要があるのですが、これを忘れがちなので気をつけましょう。逆にスナップショットテストは便利な反面使いすぎると辛くなってくるので、本当に必要な部分だけに絞って使うのがおすすめです。
また並列実行時のグローバルな状態の競合には要注意です。私も何度かハマったことがありますが、テストが時々失敗するようになったら真っ先に疑ってみると良いかもしれません。

Discussion