SmartHR Tech Blog

SmartHR 開発者ブログ

実行可能なバグレポートを支えるbundler/inlineのすすめ

普段、さまざまなgemを使っていると「あれ、この時ってどうなるんだっけ?」「これバグじゃない?」と思うような場面に出くわすことがあります。例えば、以下のような3パターンのActive Recordのモデル定義を見てみましょう。

class Supplier < ApplicationRecord
  has_one :account
end
class Supplier < ApplicationRecord
  has_one :account, autosave: true
end
class Supplier < ApplicationRecord
  has_one :account, autosave: false
end

これらはautosaveオプションが異なるhas_one関連付けで、それぞれ少し違う挙動をします。しかし、その違いを自信を持って説明できる人は少ないのではないでしょうか。自信がなければ実際に動かして確認したくなりますが、テーブルを追加して新しいモデルを作ったり、既存のモデルを少し弄って辻褄をあわせたり、というのは結構大変です。

なんとか動作を確認できたとしても、その結果や覚えた違和感を人に共有するのはもっと大変です。特にオープンソースプロジェクトでは、メンテナーは業務時間外で開発していることも多いため、確認に手間のかかるバグレポートは後回しになりがちです。相手は「あなたの環境」や「あなたにとっての当たり前」を知らないので、さまざまな可能性を疑います。例えば、

  • 意図しないgemがインストールされていて、挙動が書き換えられているのではないか?
  • このプロジェクトに対する期待値がズレているのでは?
  • そもそも、名前/役割が似ている別のgemと勘違いしていないか?

などなどです。

このような疑いの余地のないバグレポートは素早く確認できるので、反応が返ってきやすくなり、問題解決が早くなります。わかりやすいバグレポートを書くことは、最終的に自分たちのためにもなるのです。

本記事では、疑う余地が少なく、再現性の高い「実行可能なバグレポート」を書く技術と、それを支えるbundler/inlineを紹介します。

bundler/inlineとは

bundler/inlineとはBundlerの機能のひとつで、1つのファイル中にインラインでGemfileを書くことができます。

require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'
  gem 'json', require: false
  gem 'nap', require: 'rest'
  gem 'cocoapods', '~> 0.34.1'
end

puts 'Gems installed and loaded!'
puts "The nap gem is at version #{REST::VERSION}"

Bundler: How to use Bundler in a single-file Ruby script

実行してみると、gemがインストールされて読み込まれていることがわかります。

% ruby test.rb 
Gems installed and loaded!
The nap gem is at version 0.8.0

「gemをインストールするためには、まずbundle initして、bundle installして、bundle execして...」と思い込んでいた人にとっては驚きかもしれません。

実行可能なバグレポートを書いてみる

bundler/inlineを使うと、先程のautosaveの例は簡単に確認できます。早速、以下にコードを示します。

require "bundler/inline"

# 1. Gemfileの定義: 確認したいgemと動作に必要な最小限の依存を宣言する
gemfile(true) do
  source "https://rubygems.org"

  gem "activerecord", "7.1.3.3"
  gem "sqlite3", "~> 1.4"
end

# 2. Active Recordとminitestのセットアップ
require "active_record"
require "minitest/autorun"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")

# 3. データベーススキーマの宣言: 確認に必要なテーブルを作る
ActiveRecord::Schema.define do
  create_table :suppliers, force: true
  create_table :accounts, force: true do |t|
    t.integer :supplier_id
    t.string :name
  end
end

# 4. モデルの定義: 確認に必要なモデルを実装する
class Supplier < ActiveRecord::Base
  has_one :account
  has_one :autosaved_account, class_name: 'Account', autosave: true
  has_one :unautosaved_account, class_name: 'Account', autosave: false
end
class Account < ActiveRecord::Base; end

# 5. テストの実装: 期待する挙動を書く
class Test < Minitest::Test
  def teardown
    Supplier.delete_all
    Account.delete_all
  end

  def test_without_autosave
    # autosaveの宣言がなければ、has_one関連付けは自動で新規作成される
    supplier = Supplier.new
    supplier.build_account(name: "before")
    supplier.save!
    assert_equal 1, Supplier.count
    assert_equal 1, Account.count

    # autosaveの宣言がなければ、has_one関連付けへの変更は自動で保存されない
    supplier.account.name = "after"
    supplier.save!
    assert_equal "before", supplier.reload.account.name
  end

  def test_with_autosave_true
    # autosave: trueならば、has_one関連付けは自動で新規作成される
    supplier = Supplier.new
    supplier.build_autosaved_account(name: "before")
    supplier.save!
    assert_equal 1, Supplier.count
    assert_equal 1, Account.count

    # autosave: trueならば、has_one関連付けへの変更は自動で保存される
    supplier.autosaved_account.name = "after"
    supplier.save!
    assert_equal "after", supplier.reload.autosaved_account.name
  end

  def test_with_autosave_false
    # autosave: falseならば、has_one関連付けは自動で新規作成されない
    supplier = Supplier.new
    supplier.build_unautosaved_account(name: "before")
    supplier.save!
    assert_equal 1, Supplier.count
    assert_equal 0, Account.count
  end
end

パッと見るとなんだか難しいことをやっているように見えますが、ひとつひとつ見ていきましょう。

1. Gemfileの定義

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  gem "activerecord", "7.1.3.3"
  gem "sqlite3", "~> 1.4"
end

最初に紹介したbundler/inlineを使って、確認したいgemのバージョンを指定します。今回はActive Recordの動作を確認したいので、本記事執筆時点の最新版であるv7.1.3.3を指定しました。データベースを簡単に動かすために、sqlite3も宣言しておきます。

この宣言によって、動作確認をするすべての開発者の間で、同じバージョンとgemの組み合わせが利用されることが保証されます。報告を受けた開発者は「どのバージョンの話をしているのだろう?」とか「他に影響を与えるようなgemは入っていないだろうか?」とかいったことを考えなくて済むようになります。

2. Active Recordとminitestのセットアップ

require "active_record"
require "minitest/autorun"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")

Active Recordを読み込んで、接続を確立します。establish_connectiondatabase:memory:を指定すると、インメモリデータベースを利用します。これによって、Active Recordの動作を確認するために、データベースを別でセットアップする手間が省けます。

minitest/autorunは宣言されたテストを自動で実行します。ただファイルを実行するだけでテストが走るようにしています。

3. データベーススキーマの宣言

ActiveRecord::Schema.define do
  create_table :suppliers, force: true
  create_table :accounts, force: true do |t|
    t.integer :supplier_id
    t.string :name
  end
end

動作確認に必要なテーブルを作成します。ActiveRecord::Schema.defineの中では、慣れ親しんだデータベースマイグレーションの宣言ができます。これもデータベースのセットアップの手間を省く手段です。

4. モデルの定義

class Supplier < ActiveRecord::Base
  has_one :account
  has_one :autosaved_account, class_name: 'Account', autosave: true
  has_one :unautosaved_account, class_name: 'Account', autosave: false
end
class Account < ActiveRecord::Base; end

動作確認に使うモデルを実装します。書き方は普段のRailsアプリケーションと変わりません。今回はautosaveの挙動の違いを確かめたいので、それぞれのオプションごとにhas_oneを宣言します。これによって、報告を受けた開発者は「モデルに他の影響を与える実装があるのではないか?」といったことを考えなくてよくなります。

5. テストの実装

class Test < Minitest::Test
  def teardown
    Supplier.delete_all
    Account.delete_all
  end

  def test_without_autosave
    # autosaveの宣言がなければ、has_one関連付けは自動で新規作成される
    supplier = Supplier.new
    supplier.build_account(name: "before")
    supplier.save!
    assert_equal 1, Supplier.count
    assert_equal 1, Account.count

    # autosaveの宣言がなければ、has_one関連付けへの変更は自動で保存されない
    supplier.account.name = "after"
    supplier.save!
    assert_equal "before", supplier.reload.account.name
  end

# ... 以下略...

ここまでで、gemのインストール、データベースの定義、モデルの実装と必要なステップはすべて完了しているので、早速動作確認をしていきます。 期待値を正確に記述する手段として優れているのはやはりテストでしょう。minitestを使って「何を期待しているか」を記述します。

これによって、報告を受けた開発者は「期待値がズレているのではないか」といったことを考えなくてよくなります。なぜなら、期待値はプログラムされており、一目瞭然だからです。


これを実行すると、以下のような出力が得られます。

% ruby test.rb
Fetching gem metadata from https://rubygems.org/........
Resolving dependencies...
-- create_table(:suppliers, {:force=>true})
   -> 0.0096s
-- create_table(:accounts, {:force=>true})
   -> 0.0001s
Run options: --seed 14137

# Running:

...

Finished in 0.013038s, 230.0966 runs/s, 613.5910 assertions/s.

3 runs, 8 assertions, 0 failures, 0 errors, 0 skips

この結果から

  • autosaveが宣言されていなければ、紐づく新規作成されるレコードは自動で保存されるが、変更されたレコードは保存されない
  • autosave: trueならば、新規作成されるレコードだけでなく、変更されたレコードも保存される
  • autosave: falseならば、どちらも保存されない

という挙動が簡単に確認できます*1。面倒なgemのインストール手順も、データベースのセットアップも、モデルの調整も何もいりません。ただ実行するだけです。

そしてこれはバグレポートにも活用できます。バグだと思わしき挙動があれば、それによって落ちるテストを書くだけです。これをGitHubのissueに貼り付ければ、実行可能なバグレポートの出来上がりです。複雑なセットアップ手順も無く、ただ実行するだけで簡単に再現できます。

実はこれらの技術の多くはRuby on Railsのバグレポートテンプレートを参考にしています。普段からrails/railsにissueを立てている人にとっては馴染み深いテクニックではないでしょうか。これもRailsコアチームが膨大なバクレポートを効率よく処理するための仕組みですね。

実行可能なバグレポートの例

最後に、筆者が実際に作成したバグレポートの例をいくつか紹介します。

Ruby on Rails

github.com

Rails 7.0でto_s(format)が非推奨になった際に、非推奨化のロジックに不備があり、6系と互換性が失われてしまった問題を報告したものです。

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  # Activate the gem you are reporting the issue against.
  gem "activesupport", "7.0.5"
end

require "active_support"
require "active_support/core_ext/date"
require "minitest/autorun"

# Add default format
Date::DATE_FORMATS[:default] = '%Y/%m/%d'

class DateDeprecatedConversionsTest < Minitest::Test
  def test_implicit_default_conversions
    assert_equal Date.new(2023, 6, 21).to_s, '2023/06/21'
  end

  def test_explicit_default_conversions
    assert_equal Date.new(2023, 6, 21).to_s(:default), '2023/06/21'
  end
end

実行すると、to_sto_s(:default)で同じになるべき結果が同じにならないことが簡単にわかります。

# Running:

DEPRECATION WARNING: Date#to_s(:default) is deprecated. Please use Date#to_fs(:default) instead. (called from test_explicit_default_conversions at test.rb:27)
.F

Finished in 0.101925s, 19.6223 runs/s, 19.6223 assertions/s.

  1) Failure:
DateDeprecatedConversionsTest#test_implicit_default_conversions [test.rb:23]:
--- expected
+++ actual
@@ -1,3 +1 @@
-# encoding: US-ASCII
-#    valid: true
-"2023-06-21"
+"2023/06/21"


2 runs, 2 assertions, 1 failures, 0 errors, 0 skips

また、v6にバージョンを変更するとテストが通ることも確認できるため、v7で挙動が変わったことが明らかにわかります。この問題は以下のプルリクエストで修正してもらいました。

github.com

ActiveRecord::Bitemporal

github.com

弊社で開発しているActiveRecord::Bitemporalのような、Active Recordと組み合わせて動くgemでも実行可能なバグレポートを書くことができます。これは削除時に実際にトランザクションがコミットされる前にafter_commitが呼ばれてしまう、という問題を修正したものです*2

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  gem "activerecord", "7.1.3"
  gem "activerecord-bitemporal", "5.0.0"
  gem "sqlite3"
end

require "active_record"
require "minitest/autorun"

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :employees, force: true do |t|
    t.integer :bitemporal_id
    t.datetime :valid_from, precision: 6
    t.datetime :valid_to, precision: 6
    t.datetime :transaction_from, precision: 6
    t.datetime :transaction_to, precision: 6
  end
end

class Employee < ActiveRecord::Base
  include ActiveRecord::Bitemporal

  after_commit -> { puts "after_commit is invoked" }
end

class BugTest < Minitest::Test
  def test_destroy
    employee = Employee.create
    employee.destroy!
  end
end

ActiveRecord::Base.logger = Logger.new(STDOUT)があるおかげで、実際にSQLが発行されるタイミングもはっきりとわかるようになります。

D, [2024-02-08T18:47:42.760262 #60695] DEBUG -- :   TRANSACTION (0.0ms)  begin transaction
D, [2024-02-08T18:47:42.760340 #60695] DEBUG -- :   Employee Load (0.1ms)  SELECT "employees".* FROM "employees" WHERE "employees"."transaction_from" <= ? AND "employees"."transaction_to" > ? AND "employees"."valid_from" <= ? AND "employees"."valid_to" > ? AND "employees"."bitemporal_id" = ? LIMIT ?  [["transaction_from", "2024-02-08 09:47:42.759508"], ["transaction_to", "2024-02-08 09:47:42.759508"], ["valid_from", "2024-02-08 09:47:42.759482"], ["valid_to", "2024-02-08 09:47:42.759482"], ["bitemporal_id", 1], ["LIMIT", 1]]
D, [2024-02-08T18:47:42.760660 #60695] DEBUG -- :   Employee Update (0.0ms)  UPDATE "employees" SET "transaction_to" = ? WHERE "employees"."id" = ?  [["transaction_to", "2024-02-08 09:47:42.759482"], ["id", 1]]
D, [2024-02-08T18:47:42.760870 #60695] DEBUG -- :   TRANSACTION (0.0ms)  SAVEPOINT active_record_1
D, [2024-02-08T18:47:42.760925 #60695] DEBUG -- :   Employee Create (0.1ms)  INSERT INTO "employees" ("bitemporal_id", "valid_from", "valid_to", "transaction_from", "transaction_to") VALUES (?, ?, ?, ?, ?) RETURNING "id"  [["bitemporal_id", 1], ["valid_from", "2024-02-08 09:47:42.758438"], ["valid_to", "2024-02-08 09:47:42.759482"], ["transaction_from", "2024-02-08 09:47:42.759482"], ["transaction_to", "9999-12-31 00:00:00"]]
D, [2024-02-08T18:47:42.760980 #60695] DEBUG -- :   TRANSACTION (0.0ms)  RELEASE SAVEPOINT active_record_1
after_commit is invoked
D, [2024-02-08T18:47:42.761034 #60695] DEBUG -- :   TRANSACTION (0.0ms)  commit transaction

他のActive Recordのバージョンとの組み合わせも簡単にテストできるため、特定のバージョン固有の問題なのか、すべてのバージョンで起きる問題なのか、などを簡単に切り分けができます。

CarrierWave

github.com

CarrierWave v3で不正なファイルをアップロードすると、changed?にならないため、バリデーションエラーとして検出できない、という問題を報告したものです。

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  gem "activerecord", "7.1.3.2"
  gem "sqlite3"
  # gem "carrierwave", "2.2.5"
  gem "carrierwave", "3.0.5"
end

require "active_record"
require "carrierwave"
require "carrierwave/orm/activerecord"
require "minitest/autorun"

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")

ActiveRecord::Schema.define do
  create_table :users, force: true do |t|
    t.string :name
  end

  create_table :profiles, force: true do |t|
    t.integer :user_id
    t.string :avatar
  end
end

class AvatarUploader < CarrierWave::Uploader::Base
  storage :file

  def extension_allowlist
    %w(jpg jpeg gif png)
  end
end

class User < ActiveRecord::Base
  has_one :profile, autosave: true
end

class Profile < ActiveRecord::Base
  mount_uploader :avatar, AvatarUploader
end

class BugTest < Minitest::Test
  def test_validation
    user = User.create!(name: "Taro", profile: Profile.new)
    user.profile.avatar = Tempfile.new(["test", ".txt"])
    
    assert user.invalid?
  end

  def test_changed
    user = User.create!(name: "Taro", profile: Profile.new)
    user.profile.avatar = Tempfile.new(["test", ".txt"])
    
    assert user.profile.changed?
  end
end

任意のRubyのコードが書けるので、Active Recordに限らず、CarrierWaveのuploaderの定義も書くことができます。ファイルはTempfileを使うことで、準備なしでアップロードをテストできるようにしています。

# Running:

FF

Finished in 0.016239s, 123.1603 runs/s, 123.1603 assertions/s.

  1) Failure:
BugTest#test_validation [test.rb:53]:
Expected false to be truthy.

  2) Failure:
BugTest#test_changed [test.rb:60]:
Expected false to be truthy.

2 runs, 2 assertions, 2 failures, 0 errors, 0 skips

この結果から、不正なファイルを渡しても、changed?にもinvalid?にもならないことがわかります。また、他のバージョンでの挙動も簡単にテストできます。

良いバグレポートはみんなを幸せにする

あまり慣れていないプロジェクトで実行可能なバグレポートを書くのは、少し骨の折れる作業になるかもしれません。しかし、一度動くものが作れれば、その後の確認がぐっと楽になります。また、バグレポートを書く中で、bundler/inlineやインメモリのSQLiteを使うテクニックなど、多くのことを学ぶ機会も得られるでしょう。良いバグレポートを書いて、豊かな生活を手に入れましょう。

We Are Hiring!

SmartHR では一緒に SmartHR を作りあげていく仲間を募集中です!

少しでも興味を持っていただけたら、カジュアル面談でざっくばらんにお話ししましょう!

hello-world.smarthr.co.jp