2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

外部サービスを直接モックして時間を溶かした話

Posted at

1. はじめに

こんにちは!Reactの学習のために学習時間記録アプリを作っているのですが、テストがなかなか通せず、かなりの時間をつかってしまいました。
CLaudeでもGPTでも解決できず、とても苦戦してしまったので、記事にまとめていきます。
TypeScript, React, Supabase, Jestで学習タスクの登録、表示、編集、削除するアプリを作っている過程で遭遇した内容です。

1. 遭遇したこと

テストコードを書く際に、単純にSupabaseクライアントをモックしようとしました。
が、それが難しいことだと知らず、多くの時間を費やすことになりました。
Claudeに相談しながらテストコードを書いたら、Supabaseクライアントをモックする方に誘導され、そのまま頑張り続けてしまったが、その方向がそもそも間違っていた。という話です。
アプローチを変更することで、テストの作成が格段に容易になり、開発効率が向上しました。

2. 最初に試みたこと:Supabaseクライアントの直接モック

最初に、私はSupabaseクライアントを直接モックしようとしました。以下は、その方法の例です:

jest.mock("../supabaseClient", () => ({
  from: jest.fn().mockReturnThis(),
  select: jest.fn()
    .mockImplementationOnce(() => Promise.resolve({ data: mockData, error: null }))
    .mockImplementationOnce(() => Promise.resolve({ data: mockDataEdit, error: null })),
  insert: jest.fn().mockImplementation((newData) => {
    const insertedData = { id: mockData.length + 1, ...newData[0] };
    mockData.push(insertedData);
    return Promise.resolve({ data: [insertedData], error: null });
  }),
  update: jest.fn().mockImplementation(() => ({
    eq: jest.fn().mockImplementation((_, id) => {
      const index = mockData.findIndex(item => item.id === id);
      if (index !== -1) {
        mockData[index] = { ...mockData[index], ...mockDataEdit[0] };
        return Promise.resolve({ data: [mockData[index]], error: null });
      }
      return Promise.resolve({ data: null, error: 'Record not found' });
    })
  })),
  delete: jest.fn().mockReturnThis(),
  eq: jest.fn().mockImplementation(() => {
    mockData = mockData.filter(item => item.id !== 1);
    return Promise.resolve({ error: null });
  }),
}));

何度もこのモック部分を書いて数時間使ったのですが、10個のテストのうち8個は通るが残り2個が通らない。
その2個を通すためにモックを書き直すともともとテストが通っていたところが通らなくなる、という無限ループで時間を溶かしてしまいました...
Claude,GPT,Gemini,CommandR などあらゆるAIに聞いたのですが、なぜかどのAIも解決できずいよいよ手詰まりに。
この時点で、何が原因で複雑になっているのか私が理解できていないので、ただしくAIに質問できないので正確な答えもでないという状態でした。

解決できた今となっては、この方法には以下の問題がありました:

  1. 複雑性: Supabaseクライアントの内部実装の詳細を理解する必要があり、モックの作成と管理が難しくなる。

  2. 脆弱性: Supabaseの仕様変更があった場合、多くのテストコードを修正する必要があります。これは長期的なメンテナンスの観点から問題。

  3. テストの信頼性: 実際のアプリケーションでは問題なく動作するコードでも、テストが失敗することがありました。これは、モックが実際の動作を正確に再現しておらずテストとして不十分。

  4. 可読性の低下: モックが複雑になるほど、テストコードの可読性が低下。

  5. テストのセットアップの煩雑さ: 各テストケースで、複雑なモックのセットアップが必要になり、テストの作成と管理に多くの時間を要した。

実際、この方法でテストを書いていた際、以下のような問題に直面しました:

  • モックの戻り値が期待通りにならず、テストが頻繁に失敗する
  • Supabaseクライアントの複雑な連鎖メソッド(例:from().select().eq())のモックが特に難しい
  • 非同期処理のテストで、タイミングの問題が頻発する

これらの問題により、テストの作成と保守に多くの時間を費やすことになり、開発効率が著しく低下しました。

3. 解決策:API関数の抽象化とモック

JISOUで相談したところ、数分でヒントを教えてもらいました。
「この方法も間違いじゃないけど、Supabaseの操作を抽象化したAPI関数を作成し、それらをモックする方法に移行した方が良い」
(JISOUの教材の動画にはたぶん最初からその方法が説明されているが、私が読み飛ばしていただけ)

これで一気に楽になりました。ああ、なるほど...

4. やったこと1:API関数の作成

まず、api/studyRecordApi.tsというファイルを作成し、Supabaseの操作を抽象化します:

import supabase from '../supabaseClient';
import { Record, StudyFormData } from '../types';

export const fetchRecords = async (): Promise<Record[]> => {
  const { data, error } = await supabase
    .from('study-record')
    .select('*');

  if (error) throw error;
  return data as Record[];
};

export const createRecord = async (record: StudyFormData): Promise<Record> => {
  const { data, error } = await supabase
    .from('study-record')
    .insert([record])
    .select();

  if (error) throw error;
  return data[0] as Record;
};

export const updateRecord = async (id: number, record: StudyFormData): Promise<Record> => {
  const { data, error } = await supabase
    .from('study-record')
    .update(record)
    .eq('id', id)
    .select();

  if (error) throw error;
  return data[0] as Record;
};

export const deleteRecord = async (id: number): Promise<void> => {
  const { error } = await supabase
    .from('study-record')
    .delete()
    .eq('id', id);

  if (error) throw error;
};

4. やったこと2: コンポーネントの更新

次に、Appコンポーネントを更新して、これらのAPI関数を使用するようにします:

import React, { useState, useEffect } from 'react';
import { fetchRecords, createRecord, updateRecord, deleteRecord } from './api/studyRecordApi';
// ... 他のインポート

function App() {
  // ... 他のステートとフック

  useEffect(() => {
    const loadRecords = async () => {
      setLoading(true);
      try {
        const data = await fetchRecords();
        setRecords(data);
      } catch (error) {
        console.error("Error fetching data: ", error);
        setError("データの取得に失敗しました");
      } finally {
        setLoading(false);
      }
    };

    loadRecords();
  }, []);

  const onSubmit: SubmitHandler<StudyFormData> = async (data) => {
    try {
      if (editRecordId !== null) {
        await updateRecord(editRecordId, { ...data, time: Number(data.time) });
      } else {
        const newRecord = await createRecord({ ...data, time: Number(data.time) });
        setNewRecordId(newRecord.id);
      }
      const updatedRecords = await fetchRecords();
      setRecords(updatedRecords);
      setIsModalOpen(false);
      reset();
      setEditRecordId(null);
      setError("");
    } catch (error) {
      console.error("Error inserting/updating data: ", error);
      setError("データの保存に失敗しました");
    }
  };

  const handleDelete = async (id: number) => {
    try {
      await deleteRecord(id);
      const updatedRecords = await fetchRecords();
      setRecords(updatedRecords);
      setError("");
    } catch (error) {
      console.error("Error deleting data: ", error);
      setError("レコードの削除に失敗しました");
    }
  };

  // ... レンダリング部分
}

export default App;

4. やったこと3: テストの更新

最後に、テストファイルを更新して、これらのAPI関数をモックします:

import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import App from '../App';
import { fetchRecords, createRecord, updateRecord, deleteRecord } from '../api/studyRecordApi';

jest.mock('../api/studyRecordApi', () => ({
  fetchRecords: jest.fn(),
  createRecord: jest.fn(),
  updateRecord: jest.fn(),
  deleteRecord: jest.fn(),
}));

let mockData = [{ id: 1, title: 'テスト学習', time: 2 }];

describe('App コンポーネント', () => {
  beforeEach(() => {
    jest.resetAllMocks();
    mockData = [{ id: 1, title: 'テスト学習', time: 2 }];
    (fetchRecords as jest.Mock).mockResolvedValue(mockData);
  });

  test('新規登録ができる', async () => {
    (createRecord as jest.Mock).mockResolvedValueOnce({ id: 2, title: '新規学習', time: 1 });
    (fetchRecords as jest.Mock).mockResolvedValueOnce([...mockData, { id: 2, title: '新規学習', time: 1 }]);

    render(<App />);
    
    await waitFor(() => {
      expect(screen.getByText('入力する')).toBeInTheDocument();
    }, { timeout: 2000 });
  
    fireEvent.click(screen.getByText('入力する'));
  
    await waitFor(() => {
      expect(screen.getByLabelText('学習内容')).toBeInTheDocument();
      expect(screen.getByLabelText('学習時間')).toBeInTheDocument();
    }, { timeout: 2000 });
  
    fireEvent.change(screen.getByLabelText('学習内容'), { target: { value: '新規学習' } });
    fireEvent.change(screen.getByLabelText('学習時間'), { target: { value: '1' } });
  
    fireEvent.click(screen.getByText('保存'));
  
    await waitFor(() => {
      expect(screen.getByText('新規学習')).toBeInTheDocument();
    }, { timeout: 2000 });
  
    expect(createRecord).toHaveBeenCalledWith({ title: '新規学習', time: 1 });
    expect(fetchRecords).toHaveBeenCalled();
  }, 10000);

  // ... 他のテストケース
});

5. 何が改善されたか

  • シンプル: 各API関数は単一の責任を持ち、テストも簡潔に。モックの設定が大幅に簡素化され、テストの可読性が向上。あれだけ苦戦していたテストコード開発が数分で終了。

  • 保守性: Supabaseの内部実装が変更されても、API関数の修正だけで対応できる。

  • テストの信頼性: 変更前はモックの部分を書きすぎて何がなんだかわからなくなっていたのが、すっきり分かりやすく。

6. 結果

  • テストの作成時間が約90%短縮。
  • テストのデバッグにかかる時間が劇的に減少。
  • 新しい機能の追加時、既存のテストの修正が最小限で済むように。

7. 最後に

テストのエラーが解消できて、無事にデプロイできました。以下の様な学習時間記録アプリになりました。

2024-09-01-09-35-39.gif

JISOUのメンバー募集中🔥

プログラミングコーチングJISOUではメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
気になる方はぜひHPからライン登録お願いします!👇

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?