こんにちは。2021年4月に新卒入社したネゴロです。
先日上司と一緒に高田馬場にある韓国料理屋さんに行きました。
サムギョプサルなどを美味しくいただく中、18歳未満は食べることが禁止されている「超激辛爆弾チヂミ」というメニューがどうしても気になりました。試しに食したところ上司も私も涙と震えが止まらなくなり、とても刺激的な会食になりました。
さて、今回はBackend API(Ruby on Rails)で一部採用しているGraphQLについて、クエリを自動的にテストするgem(graphql-autotest)導入の取り組みをご紹介します。
また、AWS RDSへの通信を必要とするRspecテストのCI環境をGitHub Actionsで構築したため合わせてご紹介します。
目次
GraphQLとは
アウモでは比較サイト(ホテル、グルメ、 レジャー)と呼ばれるユーザの口コミを活用したおでかけCGMを展開しています。比較サイトはFrontendはVue.js、Backend APIはRuby on Railsで開発しており、Web APIの規格としてGraphQLを採用しています。
GraphQLとは、Facebook社が2012年から開発しているWeb APIのための規格になります。詳しくはこちら。
GraphQLの簡単な実例としては、単一のエンドポイントに対して、以下の左側のようなクエリでリクエストすると、右側のようなレスポンスが返ってきます。
比較サイトではRailsでGraphQLを扱うためgem graphql-ruby を採用しています。
Frontend(Vue.js)でクエリを指定して、Backend API(Rails)ではこのgemを用いてクエリの内容を処理しています。
比較サイトのBackend APIにおいて、graphql-rubyを用いたクエリ内容の処理フローは以下の通りです。
- graphql_controller.rb
- クライアントから単一エンドポイントでリクエストされたクエリをAumoTravelAppSchema.execute(query, …)で実行
- aumo_travel_app_schema.rb
- 比較サイト独自のスキーマ定義
- class AumoTravelAppSchema < GraphQL::Schema
- graphql/types/query_type.rb
- 親type(型定義)、データ操作ロジック
- class Types::QueryType < Types::BaseObject
- StringやInt等のデフォルトの型に加え、次のgraphql/types/xxx_type.rbで定義されている型のフィールドを持つ
- graphql/types/xxx_type.rb
- 子type(型定義)、データ操作ロジック
- class Types::XxxType < Types::BaseObject
- StringやInt等のデフォルトの型に加え、子typeの中のフィールドで、別の子typeが定義されている場合もある(ネスト)
- graphql/resolvers/xxx.rb
- Resolver、ロジックの分離
- class Resolvers::Xxx < GraphQL::Schema::Resolver
フィールドごとにデータを処理するロジックに関しては基本的にgraphql/typesディレクトリ以下の型定義ファイルに記載しています。ロジックが複雑でquery_type.rbが肥大化してしまう場合などは、Resolver(graphql/resolversディレクトリ以下のファイル)に処理を分離しています。
GraphQLの主なメリットとしては、やはりクライアントがクエリを通じて必要なフィールドを柔軟に指定できる点が挙げられます。
一方でN+1が発生しやすい等のデメリットもあります。 N+1を解決するためのgem graphql-batch なども提供されており、詳細はこちらのスライドをご参照ください。
GraphQLはどのようにテストするか
アウモではRailsのテストフレームワークに、デファクトスタンダードである rspec-rails を採用しています。
RspecでGraphQLのテストを書く方法として、主に以下の3つ挙げられます。(参考記事: GraphQLのテストいろいろ)
※ 以下の例では /graphql を単一エンドポイントとしています
※ request_queryは以下のような形式を想定しています
def request_query(id:)
<<~GRAPHQL
query {
article(id: "#{id}"){
id
title
}
}
GRAPHQL
end
- APIを叩き、結果をjsonでパースして確認する
post '/graphql', params: { query: request_query }
json = JSON.parse(response.body)
2. Schemaを直接executeして確認する
result = YourSchema.execute(request_query)
data = ret['data']['xxx']
3. ロジック(Resolver, modelクラス等)の入出力を確認する
1の方法はrspec実行時にrails server でプロセスを起動している必要があります。またRspecのexpectメソッドで実行結果を確認する際、json形式の結果を確認する必要があり、実行結果が複雑な構造の場合は確認が面倒そうです。
3は個々のResolver, クラス別に単体テストを書く必要があり、ある程度テストの網羅率を上げるには時間がかかります。
時間的制約がある中、後述するGraphQLのクエリを自動的にテストしてくれるgemが2の方法を採用しているため、今回は2の方法でテスト環境を構築していきます。
gem「graphql-autotest」によるクエリの自動テスト
RailsでGraphQLのクエリを自動的にテストするため、graphql-autotest というgemを導入しました。
こちらのgemのREADMEには、使い方として以下のサンプルコードが記載されています。
require 'graphql/autotest'
class YourSchema < GraphQL::Schema
end
runner = GraphQL::Autotest::Runner.new(
schema: YourSchema,
# The context that is passed to GraphQL::Schema.execute
context: { current_user: User.first },
)
# * Generate queries from YourSchema
# * Then execute the queries
# * Raise an error if the results contain error(s)
runner.report!
このコードでは、定義したYourSchemaに対して、自動的にクエリを生成・実行し、エラー有無の検証(runner.report!)を行っています。
クエリの生成はGraphQL::Autotest::QueryGeneratorクラスで実装されています。
以下は定義したスキーマからクエリを生成するサンプルコードになります。
class YourSchema < GraphQL::Schema
end
fields = GraphQL::Autotest::QueryGenerator.generate(document: YourSchema.to_document)
# Print all generated queries
fields.each do |field|
puts field.to_query
end
graphql-autotestの最大の特徴は、この「スキーマからクエリを自動的に生成する」ところです。
スキーマに新しいフィールドを定義した際も、自動的にそのフィールドを検出してクエリを生成してくれるため、基本的にテストを追記する必要がありません。(引数が必須のフィールドを追加した際は、別途テスト用に引数を設定する必要があります)
またrunner.report!では、クエリの生成・実行を行い、エラーが発生した場合は例外を投げます。
graphql-autotestのできること/できないこと
graphql-autotestはクエリを自動的に生成・実行、エラー検出まで行ってくれる一方、できないこともあります。
- できること
- メソッド名や変数名のタイポの検出
- e.g. userr => NoMethodError
- 存在しないリレーション・スコープの検出
- e.g. ユーザーがいいねしたコメントを取得するuser.liked_commentsのリレーション名がuser.like_commentsに変更されたにもかかわらず、古いuser.liked_commentsがコードに残ってしまっている場合
- メソッド名や変数名のタイポの検出
- できないこと
- 関数の出力値の検証
- e.g. 「plus関数で1と2が渡されたら3が出力される」など
- 関数の出力値の検証
できないことに関しては、Resolverやクラスの単体テスト等(「GraphQLのテストはどのように行うか」の方法3)で補完していく必要があります。
Rspec x graphql-autotest (Specファイルのサンプルもご紹介)
今回は、Rspec x graphql-autotest でテスト環境を構築しました。
rspecのファイルは以下のようになっています。
require "rails_helper"
describe 'QueryType' do
MAX_DEPTH = 3 # クエリの最大の深さ
it do
args = proc do |field|
next { id: 1 } if field.name == 'article' && field.arguments.any? {|arg| ['id'].include?(arg.name) }
next { category: 1, limit: 10 } if field.name == 'searchArticles'
...
end
skip_fields = proc do |field|
field.name == 'defectiveField1' ||
field.name == 'defectiveField2' ||
...
end
runner = GraphQL::Autotest::Runner.new(
schema: AumoTravelAppSchema,
max_depth: MAX_DEPTH,
arguments_fetcher: GraphQL::Autotest::ArgumentsFetcher.combine(
args,
GraphQL::Autotest::ArgumentsFetcher::DEFAULT,
),
skip_if: skip_fields,
logger: Logger.new(STDOUT)
)
runner.report!
end
end
GraphQL::Autotest::Runner.newに指定しているオプションについて解説します。
max_depth:ではクエリの最大の深さを指定できます。
これは以下のように相互に参照しているTypeが存在する場合、GraphQL::Autotest::QueryGeneratorによるクエリの生成処理で循環してしまうことを防ぐためのオプションになります。デフォルトでは10が設定されています。
class Types::RegionType < Types::BaseObject
field :id, ID, 'ID', null: false
field :name, String, '名前', null: false
field :prefectures, [Types::PrefectureType], '都道府県', null: false # ここでPrefectureTypeを指定している
end
class Types::PrefectureType < Types::BaseObject
field :id, ID, 'ID', null: false
field :name, String, '都道府県名', null: false
field :region, Types::RegionType, ' リージョン', null: false # ここでRegionTypeを指定している
end
arguments_fetcher:では指定したフィールドに引数を渡したいときに指定します。複数のfieldを指定する方法がgemのREADMEにも記載がなく、procの中でifとnextを利用して実現しました。
args = proc do |field|
next { id: 1 } if field.name == 'article' && field.arguments.any? {|arg| ['id'].include?(arg.name) }
next { category: 1, limit: 10 } if field.name == 'searchArticles'
...
end
skip_if:ではクエリの生成時に無視したいフィールドを指定します。どうしてもエラーが発生してしまうフィールドなど不都合なフィールドを無視することができます。
skip_fields = proc do |field|
field.name == 'defectiveField1' ||
field.name == 'defectiveField2' ||
...
end
logger:ではロガーを指定します。今回はデバックがしやすいよう標準出力(Logger.new(STDOUT))を指定しています。
以上がspecファイルの説明になります。
また、今回はrspec実行時のテスト用データに関して、FactoryBotではなく、本番とほぼ同様のデータが入っているステージング環境のDB(AWS RDS)を利用しています。理由としては主に以下の通りです。
- データの構造が複雑で、FactoryBotでテスト用データを用意するには時間がかかる(今回の取り組みではなるべく短期間でGraphQLの自動テスト環境を構築したかった)
- GraphQLを参照系の処理でのみ使用しておりDBのデータへの影響がない
- 本番に近い環境でテストできる
以上、bundle exec rspecを実行することで、定義されているGraphQLのスキーマを自動的にテストする環境ができました。
GitHub ActionsによるCI環境の構築
今回はbundle exec rspecでテストを実行するだけでなく、gitの特定のブランチにプッシュされたら自動的にテストが走るようにしたかったので、GitHub ActionsでCI環境を構築しました。
GitHub Actionsとは、GitHubが提供するCI/CDサービスです。
.github/workflows/ディレクトリ以下にymlの設定ファイルを置くだけで、簡単にCI/CD環境を構築することが可能です。
以下、今回実装したymlファイルの一部になります。
<.github/workflows/ci.yml>
name: RSpec
on:
push:
branches: [ トリガーとなるブランチ ]
jobs:
rspec:
runs-on: ubuntu-latest
services:
redis:
image: redis:alpine
...
container:
image: ruby:2.x.x
env:
RAILS_ENV: test
...
steps:
- name: Create AWS credential Environment Variables
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-1
# GitHub ActionsのワーカーのGlobalIPアドレスを取得
- name: Install Public IP Lib
id: ip
uses: haythem/public-ip@v1.2
- name: Checkout
uses: actions/checkout@v2
...
# RDSのセキュリティグループに対して、GitHub Actionsのワーカーからの通信を許可する
- name: Open RDS security group ingress from this IP
run: bundle exec ruby lib/security_group_ingress.rb sg-xxxx ${{ steps.ip.outputs.ipv4 }}/32 open # steps.ip.outputs.ipv4でIPを参照できる
- name: Run rspec
run: bundle exec rspec
# RDSのセキュリティグループに対して、GitHub Actionsのワーカーからの通信を閉じる
- name: Close RDS security group ingress from this IP
if: always() # alwaysをつけることにより、アクションが失敗しても必ず実行されるようにする
run: bundle exec ruby lib/security_group_ingress.rb sg-xxxx ${{ steps.ip.outputs.ipv4 }}/32 close
...
GitHub Actionsに関する詳細な解説は他のブログでも多く紹介されているため割愛します。
ここではAWS RDSに対してGitHub Actionsのワーカーで実行されているRailsから通信できるようにした点を解説します。(参考記事: GitHub ActionsでSrc IP制限のかかったサービス(AWS RDS)につなぐ)
今回構築したテスト環境ではAWS RDSに接続する必要がありますが、RDSはセキュリティーグループで特定のIPからのみ通信を許可しています。
しかしGitHub ActionsのワーカーのグローバルIPアドレスは毎回変動するため、セキュリティーグループに固定のIPを設定することができません。
そこでGitHub Actionsのワーカー実行毎に、RDSのセキュリティーグループにIPを設定・削除するスクリプト(security_group_ingress.rb)を作成し、workflowに追加しました。
以下、security_group_ingress.rbのコードになります。
#!/usr/bin/env ruby
# encoding: utf-8
require 'aws-sdk'
class Argv
require 'resolv'
attr_reader :security_group_id, :ip_range, :method
def initialize(argv)
raise 'ARGV length must be 3' if argv.length != 3
@security_group_id = ARGV[0]
@ip_range = ARGV[1]
@method = ARGV[2]
validate
end
private def validate
validate_security_group_id
validate_ip_range
validate_method
end
private def validate_security_group_id
raise 'Invalid security_group_id in ARGV[0]' unless @security_group_id.start_with?('sg-')
end
private def validate_ip_range
ip, subnetmask = @ip_range.split('/')
raise 'Invalid ip_range in ARGV[1]' if ip&.match(Resolv::IPv4::Regex).nil? || subnetmask&.match('\d+').nil?
end
private def validate_method
raise 'Invalid method in ARGV[2]' unless ['open', 'close'].include?(@method)
end
end
def fetch_rds_cidr_ips(security_group)
security_group.ip_permissions.find {|perm| perm.from_port == 3306 }
&.ip_ranges
&.map(&:cidr_ip) || []
end
def ip_permissions_arg(cidr_ip_arr)
ip_ranges = cidr_ip_arr.map {|ip| { cidr_ip: ip } }
[
{
ip_protocol: 'tcp',
from_port: '3306',
to_port: '3306',
ip_ranges: ip_ranges
}
]
end
argv = Argv.new(ARGV)
sg = Aws::EC2::SecurityGroup.new(argv.security_group_id)
case argv.method
when 'open'
sg.authorize_ingress(ip_permissions: ip_permissions_arg([argv.ip_range])) # セキュリティーグループにIPアドレスを追加
when 'close'
sg.revoke_ingress(ip_permissions: ip_permissions_arg([argv.ip_range])) # セキュリティーグループからIPアドレスを削除
end
Aws::EC2::SecurityGroupのauthorize_ingressメソッドでIPの設定、revoke_ingressメソッドでIPの削除を行なっています。
また、Github ActionsのワーカーのグローバルIPアドレスの取得は uses: haythem/public-ip@v1.2 で行っており、steps.ip.outputs.ipv4 で取得することができます。
以上、無事CI環境の構築も完了しました。
最後に
今回は、GraphQLのクエリを自動的に生成・実行、そしてエラー検証を行ってくれるgem graphql-autotestの導入と、GitHub ActionsによるCI環境の構築の取り組みをご紹介いたしました。
外部サービス(AWS RDS)にクエリを投げるオーバーヘッドへの対策や単体テストの整備などまだやるべきことは多いですが、今後もソフトウェアの品質と柔軟性向上のため、コツコツとGraphQLのテスト環境の整備に取り組んでいきます。
また、2021年12月15日(水) 18:00 ~ グリーグループ広告メディア事業のアウモ株式会社、Glossom株式会社、グリーライフスタイル株式会社の3社合同で初のエンジニア向けウェビナーを開催します。
私の方からはこの記事でご紹介した内容についてお話しさせていただきます。
興味のある方はぜひ以下のWantedlyページから参加ご応募ください。