💨

GraphQL RSpecを最適化する: request specと単体テストの使い分け ~BatchLoaderを添えて~

に公開

TL;DR

  • request specの対象にする:
    • Resolver/Mutationの認可・認証
    • エラーハンドリング
    • エンドツーエンドの統合テスト
  • request specの対象にしない:
    • Typeのフィールド定義や認可、ビジネスロジック
    • ResolverやMutationのビジネスロジック
  • BatchLoaderもrequestなしでテスト可能
  • 責務の分離は基本だからこそ大事

はじめに

GraphQLって使ってみると便利だけどそれなりに学習コストがかかりますよね。
私はGLOBISに入社して初めてGraphQLに触れまして、typeのspecってrequestせずにテストできないと変だよな?🤔などと調べながら少しずつ学んできたのですが、あまりこの辺りまとまった記事がない印象だったので自分のメモついでに書いておくことにします。

request specの対象にするもの

request specはHTTP経由でGraphQLエンドポイント全体をテストするため、以下のケースに適しています。

  • 認証・認可
  • エラーハンドリング
  • 統合テスト

1. 認可・認証のテスト

例: Punditポリシーが正しく適用されているか、権限のないユーザーがアクセスした場合のエラーハンドリングをテストする

RSpec.describe 'mutation createPost' do
  subject { post_graphql query:, variables: }

  let(:query) do
    <<~GRAPHQL
      mutation CreatePost($input: CreatePostInput!) {
        createPost(input: $input) {
          post { id title }
        }
      }
    GRAPHQL
  end

  context '権限のないユーザーの場合' do
    before { sign_in unauthorized_user }

    it '権限エラーが返される' do
      subject
      expect(json['errors'].first['message']).to include('permissions')
    end
  end

  context '管理者ユーザーの場合' do
    before { sign_in admin_user }

    it '投稿が作成される' do
      expect { subject }.to change { Post.count }.by(1)
    end
  end
end

2. エラーハンドリング

例: GraphQLレイヤーでのバリデーションエラーが正しくクライアントに返されるかをテストする

context '空のタイトルの場合' do
  let(:variables) { { input: { title: '' } } }

  it 'バリデーションエラーが返される' do
    subject
    expect(json).to have_graphql_error_code('INVALID_INPUT')
  end
end

3. エンドツーエンドの統合テスト

認証→認可→ビジネスロジック→レスポンス構築の一連の流れが正しく動作するかの最終確認として使用。
ビジネスロジックの細部などは見ず、ハッピーパス等テストケースは最小限に抑えます。

request specの対象にしないもの

1. GraphQL Typeのフィールド定義

Typeのフィールド定義は、HTTPリクエストを介さずに直接テストできます。

RSpec.describe Types::PostType do
  describe 'フィールド定義' do
    subject { described_class.fields }

    it '期待されるフィールドを持つこと' do
      expect(subject.keys).to include('id', 'title', 'body', 'author', 'comments')
    end

    it '型が正しく定義されていること' do
      expect(subject['id'].type.to_type_signature).to eq('ID!')
      expect(subject['title'].type.to_type_signature).to eq('String!')
      expect(subject['comments'].type.to_type_signature).to eq('[CommentType!]!')
    end
  end
end

2. Resolverのビジネスロジック

Resolverのフィルタリングやソートのロジックは、Resolverクラスを直接テストします。

RSpec.describe Resolvers::PostsResolver do
  describe '#resolve' do
    subject { resolver.resolve(status: status_filter) }

    # resolver_instanceはテスト用ヘルパーで生成
    # context には current_user などを含む
    let(:resolver) { described_class.new(object: nil, context: context, field: nil) }

    let!(:published_post) { create(:post, status: :published) }
    let!(:draft_post) { create(:post, status: :draft) }

    context 'statusでフィルターする場合' do
      let(:status_filter) { 'published' }

      it '該当するステータスの投稿のみ返す' do
        expect(subject).to contain_exactly(published_post)
      end
    end
  end
end

3. 複雑なMutationのビジネスロジック

Mutationの複雑なビジネスロジックは、モデル側で巻き取るか必要に応じてサービスクラスに切り出すなどして単体テストに寄せます。

NG例:Mutationのロジックをrequest specで網羅しようとする

Mutationに直接ロジックを書くと、request specでその分岐を全て網羅する必要があり、テストが肥大化します。

# request specでビジネスロジックの分岐を全てテストしようとする
RSpec.describe 'mutation publishPost', type: :request do
  subject { post_graphql query:, variables: }

  # ... query定義 ...

  context '下書き状態の投稿を公開する場合' do
    let(:post) { create(:post, :draft) }
    it '投稿が公開される' do
      subject
      expect(json.dig('data', 'publishPost', 'post', 'status')).to eq('published')
    end
  end

  context '既に公開済みの投稿の場合' do
    let(:post) { create(:post, :published) }
    it 'エラーが返される' do
      subject
      expect(json['errors'].first['message']).to include('not_draft')
    end
  end

  context '権限がない場合' do
    # ...
  end

  # 分岐が増えるたびにrequest specが増えていく...
end

条件分岐が増えるたびにHTTPリクエストを伴う重いテストが増え、CI時間が肥大化します。

OK例:サービスクラスに切り出して単体テストに寄せる

ビジネスロジックはサービスクラスに切り出し、そちらで網羅的にテストします。

# spec/services/posts/publish_service_spec.rb
RSpec.describe Posts::PublishService do
  describe '#call' do
    subject { described_class.new(post: post, user: user).call }

    context '下書き状態の投稿を公開する場合' do
      let(:post) { create(:post, :draft) }
      let(:user) { create(:user, :author) }

      it '投稿が公開される' do
        expect { subject }.to change { post.reload.status }.from('draft').to('published')
      end
    end

    context '既に公開済みの投稿の場合' do
      let(:post) { create(:post, :published) }
      let(:user) { create(:user, :author) }

      it '失敗を返す' do
        expect(subject).to be_failure
        expect(subject.error).to eq(:not_draft)
      end
    end

    # 分岐のテストはここで高速に網羅できる
  end
end

Mutation側のrequest specは、認可とサービス呼び出しの確認に留めます。

# spec/requests/graphql/mutations/publish_post_spec.rb
RSpec.describe 'mutation publishPost', type: :request do
  subject { post_graphql query:, variables: }

  context '権限がない場合' do
    before { sign_in unauthorized_user }

    it '権限エラーが返される' do
      subject
      expect(json['errors'].first['extensions']['code']).to eq('FORBIDDEN')
    end
  end

  context '権限がある場合' do
    before { sign_in author }

    it 'サービスが呼び出されて投稿が公開される' do
      expect { subject }.to change { post.reload.status }.to('published')
    end
  end
end

責務を分離することで、ビジネスロジックは高速な単体テストで網羅し、request specは最小限に抑えられます。

4. スキーマ直接実行によるMutationテスト

request specを使わず、スキーマを直接実行することでMutationをテストすることも可能です。

RSpec.describe Mutations::PublishPost do
  let(:result) { Schema.execute(mutation, variables: variables, context: context) }
  let(:mutation) do
    <<~GRAPHQL
      mutation PublishPost($input: PublishPostInput!) {
        publishPost(input: $input) {
          post { id status }
        }
      }
    GRAPHQL
  end
  let(:context) { { current_user: author } }

  it '投稿が公開される' do
    expect(result.dig('data', 'publishPost', 'post', 'status')).to eq('published')
  end
end

この方法はrequest specより軽量で、Pundit認可のテストも可能です。

ただし、スキーマ直接実行ではcontextを手動で渡すため、認証フロー(トークン検証やセッション確認)はテストされません。認証の検証が必要な場合はrequest specを使用してください。

BatchLoaderのテスト方法

BatchLoaderはN+1問題を解決するために使用されますが、遅延実行される性質上、テストには工夫が必要です。

実装例

# app/graphql/types/post_type.rb
class Types::PostType < Types::BaseObject
  field :author, Types::UserType, null: false
  field :comments_count, Integer, null: false

  def author
    BatchLoader::GraphQL.for(object.author_id).batch do |author_ids, loader|
      User.where(id: author_ids).each { |user| loader.call(user.id, user) }
    end
  end

  def comments_count
    BatchLoader::GraphQL.for(object.id).batch(default_value: 0) do |post_ids, loader|
      Comment.where(post_id: post_ids).group(:post_id).count.each do |post_id, count|
        loader.call(post_id, count)
      end
    end
  end
end

BatchLoaderのテスト1: .syncによる同期実行

BatchLoaderの結果は.syncメソッドで同期的に取得できます。

RSpec.describe Types::PostType do
  describe '#author' do
    let(:post) { create(:post) }
    let(:type_instance) { described_class.authorized_new(post, context) }

    it '著者を返す' do
      result = type_instance.author.sync
      expect(result).to eq(post.author)
    end
  end

  describe '#comments_count' do
    let(:post) { create(:post) }
    let!(:comments) { create_list(:comment, 3, post: post) }
    let(:type_instance) { described_class.authorized_new(post, context) }

    it 'コメント数を返す' do
      result = type_instance.comments_count.sync
      expect(result).to eq(3)
    end
  end
end

BatchLoaderのテスト2: 複数オブジェクトでのバッチ処理確認

BatchLoaderの真価は複数オブジェクトを効率的に処理することにあります。複数インスタンスを作成してテストします。

RSpec.describe Types::PostType do
  describe '#author(バッチ処理)' do
    let(:author1) { create(:user) }
    let(:author2) { create(:user) }
    let(:post1) { create(:post, author: author1) }
    let(:post2) { create(:post, author: author2) }
    let(:post3) { create(:post, author: author1) }

    it '複数の投稿に対して正しく著者を取得できる' do
      type1 = described_class.authorized_new(post1, context)
      type2 = described_class.authorized_new(post2, context)
      type3 = described_class.authorized_new(post3, context)

      # まとめてsyncすることでバッチ処理が走る
      results = [type1.author, type2.author, type3.author].map(&:sync)

      expect(results[0]).to eq(author1)
      expect(results[1]).to eq(author2)
      expect(results[2]).to eq(author1)
    end
  end
end

BatchLoaderのテスト3: N+1クエリの検証

実際にクエリ数が削減されているかは、BatchLoader実行時のクエリ数をカウントすることで確認できます。

RSpec.describe 'PostsQuery' do
  let(:query) do
    <<~GRAPHQL
      query {
        posts { nodes { id author { id name } } }
      }
    GRAPHQL
  end

  it 'N+1が発生しないこと' do
    create_list(:post, 10)

    expect {
      Schema.execute(query, context: context)
    }.to make_database_queries(
      matching: /users/,
      count: 1  # BatchLoaderにより1回のクエリで全著者を取得
    )
  end
end

default_valueの動作確認

念の為、データが存在しない場合の挙動もテストしておきましょう。

describe '#comments_count' do
  context 'コメントがない場合' do
    let(:post) { create(:post) }
    let(:type_instance) { described_class.authorized_new(post, context) }

    it 'default_valueの0を返す' do
      result = type_instance.comments_count.sync
      expect(result).to eq(0)
    end
  end
end

まとめ

テスト対象 推奨テスト方法 理由
認可・認証 request spec 実際のリクエストフローで検証が必要
バリデーションエラー request spec GraphQLレスポンス形式の確認が必要
Typeのフィールド定義 単体テスト HTTPオーバーヘッドが不要
Resolverのロジック 単体テスト ロジックの詳細なテストが可能
Mutationのロジック サービスクラス + 単体テスト テスト粒度を細かくできる
BatchLoader 単体テスト(.sync) 複数オブジェクトでの動作確認が容易

テスト実行速度を改善するためのおさらい:

  1. ロジックの分離: 複雑なビジネスロジックはモデルやサービスクラスに切り出す
  2. request specは最小限に: 認可・認証・エラーハンドリングの確認に限定
  3. スキーマ直接実行の活用: request specより軽量にGraphQL実行をテスト可能。ただし使えないケースがあることも覚えておくこと
  4. BatchLoaderは.syncを活用: 遅延実行を同期化して単体テストで検証

テストを効率よく動かして、年末年始も風邪をひかないようにやっていきましょうワッショイ!

GLOBIS Tech

Discussion