普段、さまざまな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_connection
のdatabase
に: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
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_s
とto_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で挙動が変わったことが明らかにわかります。この問題は以下のプルリクエストで修正してもらいました。
ActiveRecord::Bitemporal
弊社で開発している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
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 を作りあげていく仲間を募集中です!
少しでも興味を持っていただけたら、カジュアル面談でざっくばらんにお話ししましょう!
*1:実はこれはActive Record Autosave Associationのドキュメントに書いてあるのですが...
*2:あわせて読みたい: ActiveRecord::Base.transaction(joinable: false)を使ってはいけない