rspec + capybara + seleniumのsystem specでaxiosのAPIリクエストが終わるまで待つ方法

# この記事について

rspec + capybara + seleniumはRailsのE2Eテストでよくある構成です。しかしこのテスト構成は、リッチなフロントエンドを作ろうとした時に、JSの処理によるAPI待ちに関連したflakyなテスト（＝ランダムに落ちるテスト）が起きがちです。

例えば、specでログアウトメソッドを実行した直後に、直前でアクセスしたページのAPIがサーバーに届いて、セッションが無効なのでログイン画面にリダイレクトされて想定と違う画面が出てくるとか。テスト内で削除したリソースに、削除前に発行したAPIが到達してエラーが発生するとか。私はそういったflaky testをよく目にしました。

それを防ぐためのモダンなアプローチの例は、seleniumの代わりにplaywrightを使うことが挙げられます。Railsは7.1から[system specでplaywrightがサポートされるようになりました](https://github.com/rails/rails/pull/48950)。 

しかし、なんらかの理由で簡単にplaywrightへの移行ができないケースでも、APIのリクエストにaxiosを利用している場合はAPIを待つこと自体は可能です。誰かの役に立つかもしれないので、その方法をここに記しておきます。

# 機能概要

axiosにはinterceptorsという機能があり、リクエスト前とレスポンス前に処理を差し込むことができます。リクエスト側のinterceptorsを利用して、「リクエストを開始したAPI」をsessionStorageに記録します。そしてAPIのレスポンスが返ってきたらsessionStorageから消します。

rspec上からは`page.driver.browser.session_storage`でsessionStorageの中身が見られます。sessionStorageにレコードが残っていたらAPIが完了していないということなので、レコードが消えるまで待つようなコードを書きます。

sessionStorageはドメイン切り替えやタブ切り替えをしないと中身が完全に破棄されないので、前のページのリクエストが完了しないうちに次のページに遷移する場合を考慮し、ページ読み込み時にsessionStorageのレコードを破棄する処理も書きます。

# axiosの挙動の変更

axiosのためのinterceptorを定義します。

```ts:utility/rspec_axios_synchronizer
import { AxiosRequestConfig, AxiosResponse } from 'axios';

// RAS: Rspec Axios Synchronizer
const key = 'RAS';
const removeHttpProtocol = (url: string) => url.replace(/(^\w+:|^)\/\//, '');

export const rasRequestInterceptor = (request: AxiosRequestConfig) => {
  localStorage.setItem(`[${key}]${request.url}`, '');
};

export const rasResponseInterceptor = (response: AxiosResponse | null) => {
  // ネットワークエラーなどでレスポンスが存在しない場合がある
  if (!response) return;
  const url = removeHttpProtocol(response.config.url || '');
  sessionStorage.removeItem(`[${key}]${url}`);
};

// 前のページで残ったリクエストのレコードを消す
export const rasClearRecord = () => {
  const remaining = getSessionStorageKeys(`[${key}]`);
  remaining.forEach(keyUrl => {
    sessionStorage.removeItem(keyUrl);
  });
};

const getSessionStorageKeys = (prefix = '') => {
  return Object.keys(sessionStorage).filter(key => key.startsWith(prefix));
};

```

axiosに処理を差し込みます。どこでどう設定すべきかはプロジェクトによって異なるため、よしなに判断してください。

```ts
import {
  rasRequestInterceptor,
  rasResponseInterceptor,
  rasClearRecord,
} from '@/utility/rspec_axios_synchronizer';

// クリア処理
rasClearRecord();

// リクエストのinterceptor
axios.interceptors.request.use(request => {
  rasRequestInterceptor(request);
  return request;
});

// レスポンスのinterceptor
axios.interceptors.response.use(
  response => {
    rasResponseInterceptor(response);
    return response;
  },
  error => {
    rasResponseInterceptor(getResponseFromError(error));
    console.log(error);
    return Promise.reject(error);
  }
);

export default axios;

const getResponseFromError = (error: unknown): AxiosResponse | null =>
  (axios.isAxiosError(error) && error.response) || null;
```

# Rspec側の設定

```rb:spec/support/wait_helper.rb
module WaitHelper
  module_function
  # ページ内で発行されたAPIが全て終わるまで待つ（axios interceptor と sessionStorage を利用している）
  # app/.../utility/rspec_axios_synchronizer.ts を参照
  def wait_axios(timeout: 10)
    Timeout.timeout(timeout) do
      sleep 0.5 while page.driver.browser.session_storage.keys.any? { |k| k.start_with?('[RAS]') }
    end
  end
end

RSpec.configure do |config|
  config.include WaitHelper, type: :system
end
```

サポートとして定義したので、サポートモジュールは有効にする必要があります。

```rb:spec/rails_helper.rb
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
```

# Rspec側の利用方法

サポートで定義したwait_axiosを呼び出すことで、axiosのリクエストを待つことが可能です。

```rb
require 'rails_helper'

RSpec.describe 'Users', type: :system do 
  describe do
    it do
      visit users_path

      # ここで待つ
      wait_axios

      expect(page).to have_text('APIが終わった後に出てくる文字列')
    end
  end
end
```

# 参考

- https://github.com/YusukeIwaki/capybara-playwright-driver
- https://justin.searls.co/posts/running-rails-system-tests-with-playwright-instead-of-selenium/