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を日々改善していく仲間を募集しています。ご興味ある方はぜひ