一から勉強させてください

最下級エンジニアが日々の学びをアウトプットしていくだけのブログです

factory_botでactive_model_serializers用のPORO (Plain-Old Ruby Object)のテストデータを作成する

最近、DBの存在しないRailsプロジェクト下でAPIを作る機会がありました。

外部からfetchしてきたデータからPORO (Plain-Old Ruby Object)を作って、 active_model_serializersでひたすらシリアライズしまくるみたいな感じです。 特にテストまわりで若干の工夫が必要だったので、その時のメモです。

環境

現時点でまだ本リリースされていない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::ModelActiveModelをベースにしているので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_withcreateをスキップできる 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_withskip_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かわいい。

参照