スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

FactoryBotでのデータ生成を今一度見直す

FactoryBotでのデータ生成を今一度見直す

こんにちは。 プロダクトプラットフォームチームの@kazu9suです。 今回は、DX(Developer Experience)改善活動の一環として、テストにおけるデータ生成を見直したお話をしたいと思います。(所要時間5分程度で読める内容です)

背景

スタディサプリでは、新規サービスにおいてはGoなど別言語での開発も推進していますが、今でもメインのシステムはRuby on Railsで構成されており、テスト時のデータ生成には FactoryBotを利用しています。 スタディサプリチームはCIでの並列実行によるテスト実行時間の短縮など足回りはとても整備されているのですが、テストを書くという体験そのものにはまだ改善の余地があるのではないかと思われました。 そこで改善施策の一環として、FactoryBotを活用したデータ生成を見直してみることにしました。

Transient機能と、相性の良いデータ構造

FactoryBotにはtransientという機能があり、まずはこれを利用できないかと考えました。 transientについては、紹介記事も多数あるので詳しくは触れませんが、基本的には単一の祖先を持つ、ツリー構造のデータ生成を行うのに便利な機能です。 よくあるEコマースのデータ構造はこれに当てはまると思います。 例を下記に示します。

class Order
  one :order_recipient
  many :order_suppliers
end

class OrderRecipient
  belongs_to :order
end

class OrderSupplier
  belongs_to :order
  many :order_items
end

class OrderItem
  belongs_to :order_supplier
  many :order_item_amounts
end

class OrderItemAmount
  belongs_to :order_item
end

この関係性においては、Orderを頂点にリソースツリーが構成されます。

Order -- OrderRecipient
       └ OrderSuppliers
            └ OrderItems
              └ OrderItemAmounts

RubyのHashでツリーを記述した例を挙げるとこうです。

{
  order: {
    id: 1,
    order_recipient: { id: 1 },
    order_suppliers: [
      {
        id: 1,
        order_items: [
          {
            id: 1,
            order_item_amounts: [{id: 1}, {id: 2}]
          },
          {
            id: 2,
            order_item_amounts: [{id: 3}, {id: 4}]
          }

        ]
      },
      {
        id: 2,
        order_items: [
          {
            id: 3,
            order_item_amounts: [{id: 5}, {id: 6}]
          },
        ]
      },

    ]
  }
}

この関係性であれば、FactoryBotのtransient機能を利用して、下記のようにトップダウン的なデータ生成方針を採ることができます。

FactoryBot.define do
  factory :order, class: Order do
    transient do
       order_recipient { association :order_recipient }
       order_suppliers { [] }
    end

    after(:build) do |order, evaluator|
      evaluator.order_suppliers do |order_supplier|
        build(:order_supplier, order_supplier.merge!(order: order))
      end
    end
  end
  
  factory :order_supplier, class: OrderSupplier do
    tranient do
      order_items { [] }
    end
    
    after(:build) do |order_supplier, evaluator|
      evaluator.order_items do |order_item|
        build(:order_item, order_item.merge!(order_supplier: order_supplier))
      end
    end
  end
  
  ...
end

実際のユースケースは以下のようになります。

describe "a test case" do
  let(:order) {
    create(:order, {
      order: {
        order_suppliers: [
          {
            order_items: [
              {
                order_item_amounts: [{amount: 100, unit: 'cents'}, {amount: 200, unit: 'cents'}]
              },
              {
                order_item_amounts: [{amount: 300, unit: 'cents'}, {amount: 400, unit: 'cents'}]
              }
            ]
          },
          {
            order_items: [
              {
                order_item_amounts: [{amount: 100, unit: 'cents'}, {amount: 200, unit: 'cents'}]
              },
            ]
          },
        ]
      }
    })
  }
end

コード量自体はtransientを用いない場合と変わらないかもしれませんが、用意したいデータに集中して書き下したい場合に便利です。

スタディサプリのデータ構造

一方で、スタディサプリのメインのデータである学習データは単純なツリー構造ではなく、複数のモデルが相互に依存している構造をしています。下記に例を示します。

class Topic
  many :questions
end

class Question
  belongs_to :topic

  many :question_usages
end

class QuestionUsage
  belongs_to :topic_usage
  belongs_to :question
end

class TopicUsage
  belongs_to :topic

  many :question_usages
end

上記の例では、QuestionUsageはTopicUsageとQuestionに依存しており、TopicUsageとQuestionはそれぞれ独立して、Topicに依存しています。*1

Topic -- TopicUsage ┐
       │            ├ QuestionUsage
       └ Question   ┘

このデータ構造においてQuestionUsageを生成するには、(すでに存在しているはずである)依存先のTopicUsage, Questionを明示的に指定する必要があります。 このようなデータ構造は、ツリーで表現することができず、transientを活用したトップダウン式の方針を採るのに適していません。 実際にツリーで表現しようとしてできない様子をRubyのHashで示したのが以下です。

{
  topic: {
    id: 1,
    topic_usages: [
      {
        id: 1,
        question_usages: [{id: 1}] # question_usagesが、topic_usageとquestionを親に持つことを表現できない
      }
    ],
    questions: [
      {
        id: 1,
        question_usages: [{id: 1}]
      }
    ]
  }
}

また、FactoryBotの公式ドキュメントでも、interconnected(相互依存)のオブジェクトを扱うには適していないことがあると言及されています。

There are limitless ways objects might be interconnected, and factory_bot may not always be suited to handle those relationships. In some cases it makes sense to use factory_bot to build each individual object, and then to write helper methods in plain Ruby to tie those objects together.

以上により、スタディサプリにおけるデータ生成には、簡素なRubyメソッドを用意して、適宜それを使っていく方針を取るほうがよいと判断しました。

ヘルパーモジュールを用意する

ヘルパーモジュールを用意し、繰り返し書かれていたデータ生成ロジックのいくつかをメソッドとして定義し直しました。以下では「動画を視聴した状態」を作るメソッドを例として上げています。

# spec/helpers/factory_with_relation.rb
Module FactoryWithRelation
  def self.watch_video(student:, topic:, topic_usage:, chapter: chapter)
    chapter_usage = FactoryBot.create(:chapter_usage, topic: topic, topic_usage: topic_usage, student: student)
    FactoryBot.create(:video_chapter_usage, student: student, topic: topic, topic_usage: topic_usage, chapter: chapter, chapter_usage: chapter_usage)
    ...
  end
end

「動画を視聴する」行為はスタディサプリでの核となる行動であり、システム内では 1. Topic, TopicUsage, Student, Chapterに紐付いたChapterUsageを生成する 2. Topic, TopicUsage, Student, Chapter, ChapterUsageに紐付いたVideoChapterUsageを生成する 3. 以降処理が続く

という一連の処理を行うことで、「その生徒は動画を視聴した」とみなしています。 このデータ生成はスタディサプリのテスト内で頻繁に行われるため、複数のテストファイル内で似たようなメソッドが定義されている状態でした。 FactoryWithRelationモジュールはシンプルに、そのような繰り返し定義されうる、共通化したいデータ生成メソッドを定義する場所として機能することを期待しています。 また、複数箇所に定義されたメソッドを集約するという他にも、「動画を視聴する状態を作る」ロジックをカプセル化しているので、将来的に「動画を視聴した状態」の定義変更が容易になるなどのメリットもあります。

参考に、FactoryWithRelationを定義する前と後のコードを載せておきます。

before

複数のファイルに、watch_videoロジックが定義されている状態でした。

# spec/test_a.rb
describe 'test A' do
  let(:topic) { create(:topic) }
  let(:student) { create(:student) }
  let(:topic_usage) { create(:topic_usage, topic: topic) }
  let(:chapter) { create(:chapter, topic: topic) }

  let(:watch_video) do
    -> (student:, topic:, topic_usage:, chapter: chapter) {
       chapter_usage = FactoryBot.create(:chapter_usage, topic: topic, topic_usage: topic_usage, student: student)
       FactoryBot.create(:video_chapter_usage, student: student, topic: topic, topic_usage: topic_usage, chapter: chapter, chapter_usage: chapter_usage)
       ...
    }
  end

  before do
    watch_video.call(student: student, topic: topic, topic_usage: topic_usage, chapter: chapter)
  end
end

# defined in another file.
# spec/test_b.rb
describe 'test B' do
  let(:topic) { create(:topic) }
  let(:student) { create(:student) }
  let(:topic_usage) { create(:topic_usage, topic: topic) }
  let(:chapter) { create(:chapter, topic: topic) }

  let(:watch_video) do
    -> (student:, topic:, topic_usage:, chapter: chapter) {
       chapter_usage = FactoryBot.create(:chapter_usage, topic: topic, topic_usage: topic_usage, student: student)
       FactoryBot.create(:video_chapter_usage, student: student, topic: topic, topic_usage: topic_usage, chapter: chapter, chapter_usage: chapter_usage)
       ...
    }
  end

  before do
    watch_video.call(student: student, topic: topic, topic_usage: topic_usage, chapter: chapter)
  end
end

after

FactoryWithRelationモジュールに共通化されたデータ生成ロジックを呼び出しています。

# spec/test_a.rb
require 'spec/helpers/factory_with_relation.rb'

describe 'test A' do
  let(:topic) { create(:topic) }
  let(:student) { create(:student) }
  let(:topic_usage) { create(:topic_usage, topic: topic) }
  let(:chapter) { create(:chapter, topic: topic) }

  before do
    FactoryWithRelation.watch_video(student: student, topic: topic, topic_usage: topic_usage, chapter: chapter)
  end
end

# spec/test_b.rb
require 'spec/helpers/factory_with_relation.rb'

describe 'test B' do
  let(:topic) { create(:topic) }
  let(:student) { create(:student) }
  let(:topic_usage) { create(:topic_usage, topic: topic) }
  let(:chapter) { create(:chapter, topic: topic) }

  before do
    FactoryWithRelation.watch_video(student: student, topic: topic, topic_usage: topic_usage, chapter: chapter)
  end
end

とてもシンプルで素直な変更です。

布教する

こういう便利な仕組みは多くの人に共有されるとより有益でしょう。しかし、スタディサプリの開発チームはフルリモートワーク環境下で働いており、今後もそれが継続されるため、直接顔を合わせて成果を共有する機会は多くありません。社内の勉強会など、様々な手段で、このような課題の解決手段を伝えることが必要です。何を隠そうこのブログもその1つとしての役割を期待して執筆しています。
※ 内容は一般的に適用可能かと思いますので、ご覧いただいた皆様にも、テストデータ生成周りの改善をしたいなと思ったときに参考にしていただければ幸いです。

終わりに

スタディサプリにおけるデータ構造のお話と、データ構造に応じてFactoryBotの機能を活用するのかシンプルなRubyロジックを書くのか、その切り分けについて見ていきました。 何事も銀の弾丸というわけにはいかないですが、ツールの特性を理解し、プロダクトの特性に応じて柔軟に対応していくことで、楽をすることができると思います。

スタディサプリのプロダクトプラットフォームチームでは、開発チームの認知負荷を減らし、Developer Experienceを日々改善していく仲間を募集しています。ご興味ある方はぜひ

*1:スタディサプリでは学習する内容を Topic と Question という名前で呼んでおり、1つの Topic が複数の Question を持ちます。Topic とは例えば英文法〜のような講座で、Question はその中の問題にあたります。そしてそれぞれの進捗率を Usage をいうデータで管理しています