factory_botでactive_model_serializers用のPORO (Plain-Old Ruby Object)のテストデータを作成する
最近、DB の存在しない Rails プロジェクト下で API を作る機会がありました。
外部から fetch してきたデータから PORO (Plain-Old Ruby Object)を作って、 active_model_serializersでひたすらシリアライズしまくるみたいな感じです。 特にテストまわりで若干の工夫が必要だったので、その時のメモです。
環境
- (なぜか) Rails 6.0.0.beta3
- API モード
- ActiveRecord 使わない
- Rspec
- active_model_serializers 0.10.9
- factory_bot 5.0.2
現時点でまだ本リリースされていない Rails 6 を無駄に使っている。
構成
- app/models
- PORO を定義していく
- app/serializers
- シリアライザを定義していく
- app/controllers
- エンドポイントを定義していく
モデル
サンプルとして URL とサイズの情報を持つImage
モデルを定義してシリアライズするケースを考えてみる。
こちらに書かれているように active_model_serializers では PORO 用に ActiveModelSerializers::Model
を定義してくれているので、シンプルなものであればこれを使って簡単に実装できる。
よりファンキーな実装が求められる状況であっても、 こちらの仕様に沿った自前モデルさえ作れれば問題なくワークすると思う。
# app/models/image.rb class Image < ActiveModelSerializers::Model attributes :url, :size end # app/models/image/size.rb class Image::Size < ActiveModelSerializers::Model attributes :width, :height end
シリアライザ
# app/serializers/image_serializer.rb class ImageSerializer < ActiveModel::Serializer attributes :url, :type has_one :size end # app/serializers/image/size_serializer.rb class Image::SizeSerializer < ActiveModel::Serializer attributes :width, :height end
has_one
みたいにリレーションを定義しておくと、include オプションとかが良い感じに使える。
例えば、 render json: image, include: '*'
とするとsize
が含まれた JSON が返るし、 render json: image, include: ''
とすると size
が含まれない JSON が返る。
コントローラ
コントローラではインスタンスを作って、そいつをrender
メソッドに渡せば active_model_serializers が最適なシリアライザを見つけてシリアライズ -> JSON を返却してくれる。
実際のプロジェクトでは外部からデータを取ってくる実装 (下の fetch_data_somehow
の部分) が一番ツラかったのだが、本題ではないので割愛。
# app/controllers/v1/images_controller.rb module V1 class ImagesController < ApplicationController def show render json: image, include: params[:include] end private def image @image ||= Image.new(image_attrs) end def image_attrs @image_attrs ||= fetch_data_somehow end end end
テストまわり
ActiveRecord
のモデルのテストとかだと factory_bot
を使って
FactoryBot.define do factory :user do name { Faker::Name.name } end end
> user = FactoryBot.create(:user)
みたいにさくっとテストデータを用意できると思う。
ただ今回作成した PORO の場合、create
メソッドを呼んでも保存する場所がない。また、ActiveModelSerializers::Model
はActiveModel
をベースにしているのでnew
のタイミングで attributes
を渡してやらないと attributes = {}
がアサインされてしまって、そのままだと意図通りにシリアライズされない。
# spec/factories/images.rb FactoryBot.define do factory :image do url { Faker::Internet.url } size { build(:image_size) } end end # spec/factories/image/sizes.rb FactoryBot.define do factory :image_size, class: 'Image::Size' do width { rand(100..500) } height { rand(100..500) } end end
> image = FactoryBot.create(:image) => NoMethodError: undefined method `save!`... > image = FactoryBot.build(:image) > image.attributes => {} > image.to_json => "{}"
factory_bot はイニシャライザをオーバライドできる initialize_withや create
をスキップできる skip_createを提供してくれているので、これらをありがたく使わせてもらい解決できた。
# spec/factories/images.rb FactoryBot.define do factory :image do skip_create initalize_with { new(attributes) } url { Faker::Internet.url } size { build(:image_size) } end end # spec/factories/image/sizes.rb FactoryBot.define do factory :image_size, class: 'Image::Size' do skip_create initalize_with { new(attributes) } width { rand(100..500) } height { rand(100..500) } end end
> image = FactoryBot.create(:image) => #<Image:...> `build`と同じ結果になり、エラーは発生しない > image = FactoryBot.build(:image) > image.attributes => { "url" => ..., "size" => ... } > image.to_json => "{\"url\":...,\"size\":...}"
毎回initialize_with
やskip_create
を書くのはだるいので、最終的には以下のような DSL を用意してそいつを使うことにした。
# config/initializers/factory_bot.rb if defined?(FactoryBot) module FactoryBot module Syntax module Default class DSL # Custom DSL for ActiveModelSerializers::Model # Original: https://github.com/thoughtbot/factory_bot/blob/v5.0.2/lib/factory_bot/syntax/default.rb#L15-L26 def serializers_model_factory(name, options = {}, &block) factory = Factory.new(name, options) proxy = FactoryBot::DefinitionProxy.new(factory.definition) if block_given? proxy.instance_eval do skip_create initialize_with { new(attributes) } instance_eval(&block) end end FactoryBot.register_factory(factory) proxy.child_factories.each do |(child_name, child_options, child_block)| parent_factory = child_options.delete(:parent) || name serializers_model_factory(child_name, child_options.merge(parent: parent_factory), &child_block) end end end end end end end
# spec/factories/images.rb FactoryBot.define do serializers_model_factory :image do url { Faker::Internet.url } size { build(:image_size) } end end
あとは以下のようにテストで使える。
# spec/serializers/image_serializer_spec.rb require 'rails_helper' RSpec.describe ImageSerializer, type: :serializer do let(:resource) { ActiveModelSerializers::SerializableResource.new(model, options) } let(:model) { build(:image) } let(:options) { { include: '*' } } describe '#url' do subject { resource.serializable_hash[:url] } it { is_expected.to eq model.url } end ... end
まとめ
PORO かわいい。