Reactアプリケーションのテスト：学習記録アプリの削除機能をテストする

## はじめに

Reactで作成した学習記録アプリケーションのテストを書く過程で、特に削除機能のテストに苦戦しました。その過程と解決方法、React Testing Libraryを使用したテストの書き方について記載します。

## テスト対象のコード

```App.jsx
function App() {
  const [records, setRecords] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchRecords();
  }, []);

  const fetchRecords = async () => {
    setLoading(true);
    const { data, error } = await supabase
      .from('study-record')
      .select('*');

    if (error) {
      console.error("Error fetching data: ", error);
    } else {
      if (Array.isArray(data)) {
        setRecords(data);
      }
    }
    setLoading(false);
  };

  const handleDelete = async (id) => {
    const { error } = await supabase
      .from('study-record')
      .delete()
      .eq('id', id);

    if (error) {
      console.error("Error deleting data: ", error);
    } else {
      setRecords(records.filter(record => record.id !== id));
    }
  };

  // ... render部分 ...
}
```

このコードでは：

1. `fetchRecords`関数でSupabaseから学習記録を取得し、`records`状態を更新しています。
2. `handleDelete`関数で特定のIDの記録を削除し、成功した場合は`records`状態から該当の記録を除外しています。
3. コンポーネントのマウント時に`fetchRecords`を呼び出して初期データを読み込んでいます。

これらの機能、特に削除機能が正しく動作することをテストしていきます。

## 問題

初めに作成したテストコードは以下：

```javascript
test('学習記録を削除できること', async () => {
  const mockData = [
    { id: 1, title: '既存の学習内容1', time: 1 },
    { id: 2, title: '既存の学習内容2', time: 2 },
  ];

  jest.spyOn(supabase, 'from').mockImplementation(() => ({
    select: jest.fn().mockResolvedValue({ data: mockData, error: null }),
    insert: jest.fn().mockResolvedValue({ data: [{ id: 3, title: '新しい学習内容', time: 3 }], error: null }),
    delete: jest.fn().mockResolvedValue({ error: null }),
  }));

  render(<ChakraProvider><App /></ChakraProvider>);

  await screen.findByText(/Github actionsでデプロイした学習記録アプリ/i);

  expect(screen.getAllByText(/既存の学習内容/)).toHaveLength(2);

  const deleteButtons = screen.getAllByLabelText('delete');
  fireEvent.click(deleteButtons[0]);

  await waitFor(() => {
    expect(screen.getAllByText(/既存の学習内容/)).toHaveLength(1);
  });

  expect(screen.queryByText('既存の学習内容1')).not.toBeInTheDocument();
  expect(screen.getByText('既存の学習内容2')).toBeInTheDocument();
});
```

しかし、このテストを実行すると以下のようなエラーが発生しました：

1. `supabase is not defined`
2. `TypeError: Cannot destructure property 'data' of '(intermediate value)' as it is undefined.`
3. `_supabaseClient.default.from(...).delete(...).eq is not a function`

## 原因

これらのエラーの主な原因は以下でした：

1. supabaseクライアントのモックが適切に設定されていなかった
2. 非同期処理の扱いが適切ではなかった
3. テスト環境でのレンダリングと状態更新の扱いが不適切だった

## 解決策

問題を解決するために、以下の修正を実施しています

1.supabaseクライアントのモックを適切に設定：

```javascript
jest.mock('../supabaseClient', () => ({
  from: jest.fn().mockReturnValue({
    select: jest.fn().mockResolvedValue({ data: [], error: null }),
    insert: jest.fn().mockResolvedValue({ data: [], error: null }),
    delete: jest.fn().mockReturnValue({
      eq: jest.fn().mockResolvedValue({ error: null })
    })
  })
}));
```

2.テストケースの修正：

```javascript
test('学習記録を削除できること', async () => {
  const mockData = [
    { id: 1, title: '既存の学習内容1', time: 1 },
    { id: 2, title: '既存の学習内容2', time: 2 },
  ];

  supabase.from.mockReturnValue({
    select: jest.fn().mockResolvedValue({ data: mockData, error: null }),
    delete: jest.fn().mockReturnValue({
      eq: jest.fn().mockResolvedValue({ error: null })
    })
  });

  await act(async () => {
    render(
      <ChakraProvider>
        <App />
      </ChakraProvider>
    );
  });

  await screen.findByText(/Github actionsでデプロイした学習記録アプリ/i);

  expect(screen.getAllByText(/既存の学習内容/)).toHaveLength(2);

  const deleteButtons = screen.getAllByLabelText('delete');
  await act(async () => {
    fireEvent.click(deleteButtons[0]);
  });

  await waitFor(() => {
    expect(screen.getAllByText(/既存の学習内容/)).toHaveLength(1);
  });

  expect(screen.queryByText('既存の学習内容1')).not.toBeInTheDocument();
  expect(screen.getByText('既存の学習内容2')).toBeInTheDocument();
});
```

3.`App.jsx` の `fetchRecords` 関数の修正：

```javascript
const fetchRecords = async () => {
  setLoading(true);
  try {
    const { data, error } = await supabase
      .from('study-record')
      .select('*');

    if (error) throw error;

    if (Array.isArray(data)) {
      setRecords(data);
    }
  } catch (error) {
    console.error("Error fetching data: ", error);
  } finally {
    setLoading(false);
  }
};
```

これらの修正により、テストが正常に通過するようになりました。特に、削除後にレコードの数が1つ減っていることを確認できるようになりました。

## おわりに
テストそのものの経験がほとんどなかったので苦戦しました。中でも外部APIを使用する部分のテストは難しい場合がある様で、適切なモッキングと非同期処理の扱いという概念を学ぶまでに時間がかかりました。

主に以下のことを学びました：

1. 外部依存関係（今回ははsupabase）の適切なモッキングの重要性
2. React Testing Libraryでの非同期処理の扱い方
3. `act` を使用した状態更新の適切な処理

削除の他にも、
・タイトルの表示
・レコードの追加
・入力エラー
についてもテストすることができました。
このテストに不随して、CIも終わらせることができたので、CI/CD、テストと一連を学ぶことができプログラミングスキルが少し上がった気がします。