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) | 複数オブジェクトでの動作確認が容易 |
テスト実行速度を改善するためのおさらい:
- ロジックの分離: 複雑なビジネスロジックはモデルやサービスクラスに切り出す
- request specは最小限に: 認可・認証・エラーハンドリングの確認に限定
- スキーマ直接実行の活用: request specより軽量にGraphQL実行をテスト可能。ただし使えないケースがあることも覚えておくこと
-
BatchLoaderは
.syncを活用: 遅延実行を同期化して単体テストで検証
テストを効率よく動かして、年末年始も風邪をひかないようにやっていきましょうワッショイ!
Discussion