取り組みの背景:Rork Maxアプリのテストがなぜ重要か
Rork MaxはAIプロンプトやビジュアルビルダーからReact Nativeコードを生成し、モバイルアプリ開発を加速します。しかし、AI生成コードには独特のテスト課題があります:自動生成されたコンポーネントは微細なバグを含む可能性があり、依存関係は急速にバージョン更新され、統合ポイントは慎重な検証が必要です。生成されたアプリであっても、本番品質にはテストが不可欠です。
モバイルアプリのテストピラミッドは3つのレイヤーで構成されます:
- ユニットテスト(70%) — 個別のコンポーネント、フック、ユーティリティを隔離してテスト
- 統合テスト(20%) — 複数のコンポーネントの連携、API呼び出しをテスト
- E2Eテスト(10%) — 実デバイス/シミュレータ上で完全なユーザーフローをテスト
このガイドでは、Jest(ユニット/統合テスト)とDetox(E2Eテスト)を使用し、GitHub Actionsで継続的な品質保証を実装します。
Part 1:ユニットテスト用Jest設定
テスト依存関係のインストール
Rork Maxプロジェクトは既にJestを持っていますが、必要なライブラリがすべてあることを確認します:
npm install --save-dev @testing-library/react-native @testing-library/jest-native jest-expo babel-jestまたはYarnで:
yarn add --dev @testing-library/react-native @testing-library/jest-native jest-expo babel-jestjest.config.js の設定
プロジェクトルートに jest.config.js を作成または更新します:
module.exports = {
preset: 'jest-expo',
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testPathIgnorePatterns: ['/node_modules/', '/android/', '/ios/'],
collectCoverageFrom: [
'app/**/*.{ts,tsx}',
'!app/**/*.d.ts',
'!app/**/index.ts',
],
coverageThreshold: {
global: {
branches: 60,
functions: 60,
lines: 60,
statements: 60,
},
},
};jest.setup.js の作成
import '@testing-library/jest-native/extend-expect';最初のテストの実行
テストファイル app/__tests__/App.test.tsx を作成します:
import React from 'react';
import { render, screen } from '@testing-library/react-native';
import App from '../App';
describe('Appコンポーネント', () => {
it('ウェルカムメッセージをレンダリングする', () => {
render(<App />);
expect(screen.getByText(/Welcome/i)).toBeTruthy();
});
});テストを実行します:
npm testPart 2:コンポーネントのユニットテスト
ボタンコンポーネントのテスト
ほとんどのRork Maxアプリはボタンコンポーネントを含みます。カスタムボタンをテストしましょう:
// app/components/Button.tsx
import React from 'react';
import { TouchableOpacity, Text } from 'react-native';
interface ButtonProps {
title: string;
onPress: () => void;
disabled?: boolean;
}
export const Button: React.FC<ButtonProps> = ({ title, onPress, disabled }) => (
<TouchableOpacity onPress={onPress} disabled={disabled}>
<Text>{title}</Text>
</TouchableOpacity>
);テストを作成します:
// app/__tests__/Button.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react-native';
import { Button } from '../components/Button';
describe('ボタンコンポーネント', () => {
it('正しいタイトルでレンダリングされる', () => {
render(<Button title="押してください" onPress={jest.fn()} />);
expect(screen.getByText('押してください')).toBeTruthy();
});
it('押下時にonPressが呼ばれる', () => {
const mockPress = jest.fn();
render(<Button title="押してください" onPress={mockPress} />);
const button = screen.getByRole('button', { name: /押してください/i });
fireEvent.press(button);
expect(mockPress).toHaveBeenCalledTimes(1);
});
it('無効化時はonPressが呼ばれない', () => {
const mockPress = jest.fn();
render(
<Button title="押してください" onPress={mockPress} disabled={true} />
);
const button = screen.getByRole('button');
fireEvent.press(button);
expect(mockPress).not.toHaveBeenCalled();
});
});フォームのテスト
フォームはアプリで重要です。フォーム送信をテストします:
// app/__tests__/LoginForm.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
import { LoginForm } from '../components/LoginForm';
describe('ログインフォームコンポーネント', () => {
it('メールとパスワードでフォームが送信される', async () => {
const mockSubmit = jest.fn();
render(<LoginForm onSubmit={mockSubmit} />);
const emailInput = screen.getByPlaceholderText('メール');
const passwordInput = screen.getByPlaceholderText('パスワード');
const submitButton = screen.getByText('サインイン');
fireEvent.changeText(emailInput, 'user@example.com');
fireEvent.changeText(passwordInput, 'password123');
fireEvent.press(submitButton);
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123',
});
});
});
it('メールが無効な場合はエラーを表示', async () => {
render(<LoginForm onSubmit={jest.fn()} />);
const emailInput = screen.getByPlaceholderText('メール');
fireEvent.changeText(emailInput, 'invalid-email');
await waitFor(() => {
expect(screen.getByText(/無効なメール/i)).toBeTruthy();
});
});
});Part 3:renderHookでフックをテストする
カスタムフックはReact Nativeアプリで一般的です。renderHookでテストします:
// app/hooks/useAuth.ts
import { useState, useCallback } from 'react';
export const useAuth = () => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const login = useCallback(async (email: string, password: string) => {
setLoading(true);
try {
// API呼び出し
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const data = await response.json();
setUser(data.user);
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(() => {
setUser(null);
}, []);
return { user, loading, login, logout };
};フックをテストします:
// app/__tests__/useAuth.test.ts
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { useAuth } from '../hooks/useAuth';
describe('useAuthフック', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('nullユーザーとfalseロード状態で初期化される', () => {
const { result } = renderHook(() => useAuth());
expect(result.current.user).toBeNull();
expect(result.current.loading).toBe(false);
});
it('ログイン中はloadingがtrueになる', async () => {
const { result } = renderHook(() => useAuth());
act(() => {
result.current.login('user@example.com', 'password123');
});
expect(result.current.loading).toBe(true);
});
it('ログイン成功後、ユーザーが更新される', async () => {
const mockUser = { id: '123', email: 'user@example.com' };
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ user: mockUser }),
})
) as jest.Mock;
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.login('user@example.com', 'password123');
});
await waitFor(() => {
expect(result.current.user).toEqual(mockUser);
expect(result.current.loading).toBe(false);
});
});
it('ログアウト時はユーザーがクリアされる', () => {
const { result } = renderHook(() => useAuth());
act(() => {
result.current.logout();
});
expect(result.current.user).toBeNull();
});
});Part 4:APIとサービスのモッキング
外部サービス(Supabaseなど)のモッキングは必須です。jest.mock()を使用します:
// app/services/supabaseClient.ts
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
process.env.EXPO_PUBLIC_SUPABASE_URL!,
process.env.EXPO_PUBLIC_SUPABASE_KEY!
);テストでモックします:
// app/__tests__/supabaseService.test.ts
import { supabase } from '../services/supabaseClient';
jest.mock('../services/supabaseClient', () => ({
supabase: {
from: jest.fn(),
},
}));
describe('Supabaseサービス', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('投稿を正常に取得する', async () => {
const mockPosts = [
{ id: 1, title: '投稿1' },
{ id: 2, title: '投稿2' },
];
(supabase.from as jest.Mock).mockReturnValue({
select: jest.fn().mockResolvedValue({
data: mockPosts,
error: null,
}),
});
const { data, error } = await supabase
.from('posts')
.select('*');
expect(data).toEqual(mockPosts);
expect(error).toBeNull();
});
it('エラーを正常に処理する', async () => {
const mockError = { message: 'ネットワークエラー' };
(supabase.from as jest.Mock).mockReturnValue({
select: jest.fn().mockResolvedValue({
data: null,
error: mockError,
}),
});
const { data, error } = await supabase
.from('posts')
.select('*');
expect(data).toBeNull();
expect(error).toEqual(mockError);
});
});Part 5:スナップショットテスト
スナップショットテストはコンポーネント出力をキャプチャし、回帰を検出します:
// app/__tests__/ProfileCard.snapshot.test.tsx
import React from 'react';
import { render } from '@testing-library/react-native';
import { ProfileCard } from '../components/ProfileCard';
describe('プロフィールカードスナップショット', () => {
it('正しくレンダリングされる', () => {
const tree = render(
<ProfileCard
name="太郎"
bio="開発者"
avatar="https://example.com/avatar.jpg"
/>
).toJSON();
expect(tree).toMatchSnapshot();
});
});npm test -- -uを実行すると、Jestはスナップショットファイル(ProfileCard.snapshot.test.tsx.snap)を保存します。その後の実行では、Jestは出力をスナップショットと比較します。
スナップショットを使うべき場合:
- ✅ 構造が安定したUIコンポーネント
- ✅ デザイン変更の回帰検出
スナップショットを避けるべき場合:
- ❌ 動的コンテンツ(タイムスタンプ、ID)
- ❌ 頻繁に変わるUI
Part 6:Detox E2Eセットアップ
Detoxは実デバイスまたはシミュレータ上でアプリをエンドツーエンドテストします。インストールします:
npm install --save-dev detox-cli detox detox-build-configuration.detoxrc.js の設定
プロジェクトルートに.detoxrc.jsを作成します:
module.exports = {
testRunner: 'jest',
apps: {
ios: {
type: 'ios.app',
binaryPath: 'artifacts/build/Build/Products/Release-iphonesimulator/YourApp.app',
build: 'xcodebuild -workspace ios/YourApp.xcworkspace -scheme YourApp -configuration Release -sdk iphonesimulator -derivedDataPath artifacts/build',
},
},
configurations: {
'ios.sim.debug': {
device: {
type: 'iPhone 14',
},
app: 'ios',
},
},
testRunner: 'jest',
};アプリのビルド
detox build-framework-cache
detox build-app --configuration ios.sim.debug最初のE2Eテストの実行
e2e/firstTest.e2e.jsを作成します:
describe('最初のE2Eテスト', () => {
beforeAll(async () => {
await device.launchApp();
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('ウェルカムスクリーンを表示すべき', async () => {
await expect(element(by.text('Welcome'))).toBeVisible();
});
});テストを実行します:
detox test e2e/firstTest.e2e.js --configuration ios.sim.debugPart 7:Detox E2Eテストの例
ログインフロー テスト
// e2e/loginFlow.e2e.js
describe('ログインフロー', () => {
beforeAll(async () => {
await device.launchApp();
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('有効な認証情報でログインに成功すべき', async () => {
// ログイン画面に移動
await element(by.id('loginButton')).multiTap();
// メールを入力
await element(by.id('emailInput')).typeText('user@example.com');
// パスワードを入力
await element(by.id('passwordInput')).typeText('password123');
// サインインボタンをタップ
await element(by.id('signInButton')).multiTap();
// ダッシュボードをチェックして、ログイン成功を確認
await waitFor(element(by.text('ダッシュボード')))
.toBeVisible()
.withTimeout(5000);
});
it('無効なメールでエラーを表示すべき', async () => {
await element(by.id('loginButton')).multiTap();
await element(by.id('emailInput')).typeText('invalid-email');
await element(by.id('signInButton')).multiTap();
await expect(element(by.text('無効なメール'))).toBeVisible();
});
});ナビゲーション フロー テスト
// e2e/navigation.e2e.js
describe('ナビゲーションフロー', () => {
beforeAll(async () => {
await device.launchApp();
});
it('タブ間をナビゲートすべき', async () => {
// ホームタブから開始
await expect(element(by.text('ホーム'))).toBeVisible();
// プロフィールタブをタップ
await element(by.id('profileTab')).multiTap();
await expect(element(by.text('プロフィール'))).toBeVisible();
// 設定タブをタップ
await element(by.id('settingsTab')).multiTap();
await expect(element(by.text('設定'))).toBeVisible();
// ホームに戻る
await element(by.id('homeTab')).multiTap();
await expect(element(by.text('ホーム'))).toBeVisible();
});
});フォーム送信テスト
// e2e/formSubmission.e2e.js
describe('フォーム送信', () => {
beforeAll(async () => {
await device.launchApp();
});
it('すべてのフィールドが入力されたフォームを送信すべき', async () => {
// フォームに移動
await element(by.id('openFormButton')).multiTap();
// フォームフィールドを入力
await element(by.id('nameInput')).typeText('太郎');
await element(by.id('emailInput')).typeText('john@example.com');
await element(by.id('messageInput')).typeText('こんにちは');
// 送信
await element(by.id('submitButton')).multiTap();
// 成功メッセージを確認
await waitFor(element(by.text('フォームが正常に送信されました')))
.toBeVisible()
.withTimeout(5000);
});
it('必須フィールドが空の場合、検証エラーを表示すべき', async () => {
await element(by.id('openFormButton')).multiTap();
await element(by.id('emailInput')).typeText('john@example.com');
await element(by.id('submitButton')).multiTap();
await expect(element(by.text('名前は必須です'))).toBeVisible();
});
});Part 8:カバレッジレポート
カバレッジレポートの生成
カバレッジ付きでJestを実行します:
npm test -- --coverageこれにより、coverage/ディレクトリにカバレッジレポートが生成されます。カバレッジメトリクスには以下が含まれます:
- Statements — 実行されたステートメントの割合
- Branches — テストされた条件分岐の割合
- Functions — 呼ばれた関数の割合
- Lines — 実行された行の割合
カバレッジしきい値の設定
jest.config.jsで最小しきい値を設定します:
coverageThreshold: {
global: {
branches: 70,
functions: 75,
lines: 80,
statements: 80,
},
'./app/services/': {
branches: 90,
functions: 90,
lines: 90,
statements: 90,
},
};カバレッジがしきい値以下の場合、Jestはコード1で終了し、ビルドが失敗します。
ブラウザでカバレッジレポートを表示
カバレッジの実行後、ブラウザでcoverage/lcov-report/index.htmlを開き、詳細でインタラクティブなカバレッジレポートを確認します。
Part 9:GitHub Actions CI統合
.github/workflows/test.yml を作成
name: Jest テスト
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v4
- name: Node.js ${{ matrix.node-version }} をセットアップ
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: 依存関係をインストール
run: npm ci
- name: テストを実行
run: npm test -- --coverage
- name: Codecovにカバレッジをアップロード
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
flags: unittests
name: codecov-umbrella
fail_ci_if_error: falseすべてのPRで実行
ワークフローは自動的にプルリクエストで実行されます。テストが失敗するとPRは赤いXを表示します。テストが成功すると、緑のチェックマークが表示されます。

```</div></div>
---
## Part 10:CI内でのE2Eテスト
CIでDetoxを実行するにはAndroid/iOSビルドツールが必要です。iOSの最小例:
```yaml
name: Detox E2E テスト
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Node.js をセットアップ
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: 依存関係をインストール
run: npm ci
- name: Detoxをビルド
run: |
detox build-framework-cache
detox build-app --configuration ios.sim.debug
- name: E2Eテストを実行
run: detox test e2e --configuration ios.sim.debug --cleanup個人開発者の視点から(実体験メモ)
まとめ:Rork Maxアプリの成功するテスト戦略
Rork Maxアプリが本番対応であることを確認するために:
- ユニットテスト(Jest) — コンポーネント、フック、ユーティリティをカバーします。80%以上の行カバレッジを目指しましょう。
- コンポーネントテスト — ユーザーインタラクション(ボタンクリック、フォーム送信)をテストします。
- モッキング — 外部APIとサービスをモックして、ロジックを隔離します。
- E2Eテスト(Detox) — 重要なユーザーフロー(ログイン、支払い、コア機能)をテストします。
- CI/CD — GitHub Actionsですべてのプルリクエストでテストを自動化します。
- カバレッジレポート — カバレッジを追跡し、しきい値を実装します。
AI生成コードは完璧ではありませんが、包括的なテストにより、問題を早期に検出できます。Jestユニットテストから始め、重要なパスに対するE2Eテストを追加し、アプリの成長に合わせて反復します。
次のステップ:
- Rork MaxプロジェクトにJestをセットアップする
- 最も重要なコンポーネントのテストを書く
- GitHub Actionsで継続的なテストを統合する
- アプリが成長するにつれて、段階的にカバレッジを増やす
テストを楽しんでください!