Techouse Developers Blog

テックハウス開発者ブログ|マルチプロダクト型スタートアップ|エンジニアによる技術情報を発信|SaaS、求人プラットフォーム、DX推進

STIを利用したRailsアプリのテストが実行順序で失敗する問題を解決した話

ogp

はじめに

こんにちは、Techouseにインターンとして入社し、クラウドハウス労務にてエンジニアをしている谷本宙矢です。

弊社では、数万件におよぶ自動テストをGitHub Actionsを利用してランダム実行しています。並列実行されるテストにおいて、特定の順序で実行されると一部のテストが失敗するケースがありました。私がその原因を調査し、修正に取り組むことになったのですが、この失敗は、STI(Single Table Inheritance)を使用したデータベース設計と関連していることがわかりました。本記事では、その問題の詳細と解決に至るまでのプロセスを紹介します。

テストケースの順序依存性について

並列実行されるテストでは、テスト間に依存関係がある場合や、同一のデータベースリソースにアクセスする場合に競合が発生し、実行順序によって予期しないエラーが発生することがあります。特に今回、STIを使用しているテーブルで発生した競合が原因となり、テストの失敗に繋がりました。

STI

STIとはSingle Table Inheritanceの略で、日本語では「単一テーブル継承」と訳されます。STIでは、異なるクラスのオブジェクトを単一のテーブルに格納し、クラスの情報をデータベース内で管理します。これにより、複数のクラスが同じテーブルを共有し、共通のカラムを持ちつつ、クラスごとに異なる挙動を持つことができます。

例えば、以下のようなテーブルが存在するとしましょう。

CREATE TABLE animals (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
    name VARCHAR(255) NOT NULL,
    type  STRING NOT NULL COMMNET '動物の種類',
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL
)

このエンティティに対応するモデルとして、以下のような定義をします。

class Animal < ApplicationRecord
end

このとき、animals クラスに動物の鳴き声を表現する speak メソッドを実装することになったとしましょう。このとき、animals のインスタンスは動物の種類によって speak メソッドの実装を分ける必要があります。 例えば、type == 'dog' のときは、その animals インスタンスには animals.speak == 'ワンワン!' が期待されます。同様に、 type == 'cat' ならば animals.speak == 'ニャーニャー!' が期待されるわけですし、 type == 'chicken' ならば animals.speak == 'コケコッコー!' が期待されるわけです。

さて、これを実装するときに、 type によって条件分岐を書いていたのでは、骨が折れますね。実装も大変になりますし、毎回振る舞い毎に条件分岐をしていては、テストも大変になります。

この問題を解決してくれるのが、 STI という設計手法です。上述のようなケースを、STI を利用すると以下のように実装できます。

class Dog < Animal
  def speak
    "ワンワン!"
  end
end
class Cat < Animal
  def speak
    "ニャーニャー!"
  end
end
class Chicken < Animal
  def speak
    "コケコッコー!"
  end
end

それぞれの type による振る舞いの違いが、クラス毎に実装されていることがわかりますね。 こうすることで、コード内で条件分岐する必要がなくなり、それぞれのクラスの単体テストによって挙動を保証できるので、メソッドの実装が単純化されます。モデルのロジックが肥大化する事も避けられ、コードの複雑化を防ぐことが出来ているわけです。

STIの基本構造

STIを使用する場合、次のような構造になります。

  1. 親クラス(ベースクラス)
    定義: 親クラスは、STIのベースとなるクラスであり、すべてのサブクラスがこのクラスを継承します。親クラスには共通の属性やメソッドが定義され、これらはすべてのサブクラスで共有されます。
    テーブル: データベース内では、親クラス用のテーブルが作成されます。このテーブルには、親クラスとそのサブクラスのすべてのデータが格納されます。

  2. サブクラス
    定義: サブクラスは親クラスから継承し、特定の属性やメソッドを追加します。これにより、サブクラスは親クラスの特性を持ちながらも、独自の機能を持つことができます。
    テーブル: サブクラスのデータは、親クラスのテーブル内に格納されます。各サブクラスのデータは、親クラスのテーブルに共通のカラム(たとえば、typeカラムなど)を使って区別されます。

STIの利用価値

  1. データベース設計のシンプルさ
    STIを使用することで、複数のクラスを1つのテーブルで管理できるため、データベース設計がシンプルになります。これにより、データの整合性を保ちつつ、管理が容易になります。

  2. コードの再利用
    共通の属性やメソッドを親クラスで定義し、サブクラスでそれを継承することで、コードを再利用することが出来ます。これにより、同じような処理を複数のクラスで繰り返し実装する必要がなくなります。

  3. ポリモーフィズム
    STIを使用すると、親クラスのインターフェースを通じて異なるサブクラスのオブジェクトを扱うことができます。これにより、プログラムの柔軟性が向上し、異なるサブクラス間で共通の操作を統一的に扱うことができます。

  4. データのクエリ
    STIを使用すると、親クラスのテーブルからサブクラスのデータを簡単にクエリできます。たとえば、全ての従業員や管理者を同時に取得する際に、1つのテーブルをクエリするだけで済みます。

クラスの構造

今回のケースでは、以下の3つのクラス(仮名)がSTIで同じテーブルを共有していました。

  • Human クラス (共通の親クラス)
  • Employee クラス
  • Manager クラス

これらのクラスはすべて、同じテーブルのorder_indexカラムを共有しており、このカラムには一意制約が設定されていたため、各クラス間で同じorder_indexを持つことは許されませんでした。

今回発生した問題

今回は、以下のような例外が出力されました。この例外は毎回出るわけではなく、確率的に発生していました。

ActiveRecord::RecordInvalid: Validation failed: order_index has already been taken
./spec/models/employee_spec.rb:42:in `block (3 levels) in <top (required)>'
./spec/rails_helper.rb:29:in `block (2 levels) in <top (required)>'

問題のトリガーとなっていたのは、STIで使用しているorder_indexカラムが、一意制約になっていた点です。この設定により、STIを使用するすべてのクラスでorder_indexが重複するとバリデーションエラーが発生します。

STI

上の画像は、今回のSTI構造のイメージです。左下の従業員テーブルと右下の管理者テーブルは実際には共通の親テーブルにまとめられてます。ここで、番号という列に一意制約があるとすると、このままでは番号が被っているので行の追加ができません。このようなことがテストケースで起きていました。

この問題が発生した背景には、弊社のRSpecテストが「継ぎ足しで作られている」という点があります。最初に作成されたテストでは問題がなかったものが、後から追加されたテストの影響で、テストの実行順序により問題が表面化しました。

問題の詳細

自動テストのFactoryBotで以下のように定義されていたsequenceが、STIの構造において問題を引き起こしていました。

FactoryBot.define do
  factory :employee do
    sequence(:order_index)
    # その他の設定
  end
end

ManegerクラスとHumanクラスについても、同様にインスタンスを生成しています。

このコードは、テストで使うemployeeオブジェクトを生成するための設定となっており、sequence(:order_index)では、このオブジェクトを複数作るときに、自動で採番しています(最初に作られたemployeeオブジェクトのorder_indexは1,次につくられたemployeeオブジェクトのorder_indexは2といった形です)

問題の実行順序によって落ちるテストのletによる変数宣言は以下のようになっていました。

let(:employee1) do
    create(:employee, その他の設定)
end
let(:employee2) do
    create(:employee, その他の設定)
end
let(:manager1) do
    create(:manager, order_index: 3, その他の設定)
end

また、RSpec本体で書くsetupではおそらくこのsequenceを用いてテストを書こうとし、order_indexはすでに使われていると怒られためorder_indexを明示的に書くことで、一時的に例外を回避したのだと推測出来ます。

テストコード本体の例としては、以下のような構成です。

it 管理者のテスト do
    ## ここでanother_employeeが作られる
    let(:another_employee)
        create(:employee, その他の設定)
    end

    another_employeeとmanagerを用いたテスト
end

it 従業員のテスト do
    employee1とemployee2を用いたテスト
end

order_indexにのみ着目して考えると、管理者のテスト⇨従業員のテストの順で行われた場合、

人の名前 order_index
another_employee 1
manager 3
employee1 2
employee2 3

employee2とmanagerでorder_indexが被っていますが、他のテストなので落ちません。

従業員のテスト⇨管理者のテストの順で行われた場合、

人の名前 order_index
employee1 1
employee2 2
another_employee 3
manager 3

この場合、another_employeeとmanagerのorder_indexが被っており、同じテストなのでこれはバリデーションに引っかかり落ちます。

解決策

問題としては、テストを書くときに、STIの関係を考えていなかったこと、その場しのぎでorder_indexを明示したことにより、後々テストを追加(今回のanother_employee)した際に、実行順序によって落ちるテストが生成されてしまったことです。

モデルにおいては継承によってクラス間の包含関係を明示しますが、同様に FactoryBot でも、明示的に継承関係を記述することが出来ます。classを指定した記述ができ、さらにはFactoryBotのparentオプションを用いて、クラス間の親子関係も明示できます。この記述により、親クラスで定義されたsequenceを子クラスでも利用できるようになったため、テストの実行順序に関係なく、STIを使用した全てのクラスでorder_indexが重複することを防ぎました。具体的には以下の通りです。

親クラス(Human)

FactoryBot.define do
  factory :Human, class: 'Human' do
    sequence(:order_index)
    # その他の設定
  end
end

子クラス(Employee,Manager)

FactoryBot.define do
  factory :employee, parent: 'Human', class; 'Employee' do
    # order_indexの設定をしない
    # その他の設定
  end
end

このように記述することによって、子クラスであるEmployee,Managerは、Humanのsequenceを使うため、番号が被ることはなくなりました。また、それに伴い、これらのorder_indexを明示している(sequenceを用いず、create内でorder_index: 3などとしている箇所のこと)setupからその記述を消し、全て自動採番で行うこととしました。

これにより、STIを使ったクラス間で同じsequenceを共有でき、order_indexの重複が解消されます。

管理者のテスト⇨従業員のテストの順で行われた場合、

人の名前 order_index
another_employee 1
manager 2
employee1 3
employee2 4

従業員のテスト⇨管理者のテストの順で行われた場合、

人の名前 order_index
employee1 1
employee2 2
another_employee 3
manager 4

となりますね。

教訓

今回の問題から得られた教訓として、STIを利用する場合、テストデータが競合しないように十分な注意が必要であることが分かりました。特に、テストコードを追加・修正する際には、既存のテストやデータベース制約を理解し、設計段階から意識的に競合を避けることが重要です。また、テストが増え続けるプロジェクトでは、並列実行やランダムな順序で実行される可能性を考慮し、テスト間の依存関係やデータベースアクセスに関する競合に注意すべきです。


Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。

jp.techouse.com