はじめに
Rails + MySQLの構成で、RSpecを用いて動的にCREATE TABLE
を行うような処理に対してテストを書きたかったのですが、MySQLのDDLの性質とRSpecのデータベースのクリーンアップ処理について正しく理解していないがためにハマったので、それについて書きます。
概要
以下のように、Rails Application越しに動的にテーブルデータを作成するような処理が必要でした。
class Hoge < ApplicationRecord
def self.create_temp_table_data
connection.drop_table(:temp) if connection.table_exists?(:temp)
connection.create_table :temp do |t|
t.string :temp_column
end
connection.execute("INSERT INTO `temp` (`temp_column`) VALUES ('Hello World')")
end
end
RSpec.describe Hoge, type: :model do
describe '.create_temp_table_data' do
subject { described_class.create_temp_table_data }
it 'create temporary table' do
subject
expect(described_class.connection.table_exists?(:temp)).to eq(true)
expect(
described_class.connection.execute("SELECT COUNT(*) FROM `temp`").size
).to eq(1)
end
end
end
上記のようにテストを書いて問題なく通ることを確認できましたが、クリーンアップ処理が正しく行われずにデータが残ってしまっていたために、他のテストで落ちてしまうケースが出てきました。
rspec-railsにおけるデータベースのクリーンアップ処理
rspec-railsを用いてテストを実行する場合、デフォルトではtransactionを用いてクリーンアップ処理が行われます。
exampleが終了したタイミングでrollbackすることによって、作成・更新・削除したデータベースの変更が永続化されることなく元に戻ります。
What it really means in Rails is "run every test method within a transaction." In the context of rspec-rails, it means "run every example within a transaction."
The idea is to start each example with a clean database, create whatever data is necessary for that example, and then remove that data by simply rolling back the transaction at the end of the example.
なんのテストにもなっていないですが一連の処理を実際に行い、ログを確認するとtransactionを用いてクリーンアップ処理が行われていることが確認できます。
RSpec.describe Hoge, type: :model do
describe 'example' do
it 'create a record' do
Hoge.create(fuga: "fuga")
expect(Hoge.count).to eq(1)
end
end
end
TRANSACTION (0.1ms) BEGIN
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Hoge Create (1.1ms) INSERT INTO `hoges` (`fuga`, `created_at`, `updated_at`) VALUES ('fuga', '2024-12-10 14:41:38.931764', '2024-12-10 14:41:38.931764')
TRANSACTION (2.3ms) RELEASE SAVEPOINT active_record_1
Hoge Count (0.8ms) SELECT COUNT(*) FROM `hoges`
TRANSACTION (1.5ms) ROLLBACK
MySQLのDDLにおける暗黙のcommit
MySQLではCREATE TABLE
やDROP TABLE
などのDDLを実行する際に、暗黙的なcommitが実行されます。なのでDDLに対するtransactionは効かないことになります。
- DML
mysql> SELECT * FROM `hoges`;
Empty set (0.00 sec)
mysql> BEGIN;
mysql> INSERT INTO `hoges` (`fuga`, `created_at`, `updated_at`) VALUES ('hello world', '2020-01-01 10:10:10', '2020-01-01 10:10:10');
mysql> ROLLBACK;
-- Rollbackされたためテーブルにデータが存在しない
mysql> SELECT * FROM `hoges`;
Empty set (0.00 sec)
- DDL
mysql> SHOW TABLES LIKE 'temp';
Empty set (0.00 sec)
mysql> CREATE TABLE `temp` (
-> `id` bigint AUTO_INCREMENT,
-> `temp_column` VARCHAR(255),
-> PRIMARY KEY(`id`)
-> );
mysql> ROLLBACK;
-- Robllbackされないため、テーブルが作成されている
mysql> show tables like 'temp';
+----------------------------------+
| Tables_in_app_development (temp) |
+----------------------------------+
| temp |
+----------------------------------+
1 row in set (0.01 sec)
クリーンアップ処理がうまくいかなかった理由
クリーンアップ処理の戦略としてtransactionを用いている中で、transactionが効かないDDLを実行していたからでした。
はじめに掲載したテストのログを確認すると以下のようになります。
TRANSACTION (0.1ms) BEGIN
(3.0ms) DROP TABLE `temp`
(4.8ms) CREATE TABLE `temp` (`id` bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, `temp_column` varchar(255))
(1.0ms) INSERT INTO `temp` (`temp_column`) VALUES ('Hello World')
(0.9ms) SELECT COUNT(*) FROM `temp`
TRANSACTION (0.2ms) ROLLBACK
transaction, truncation, deletion
rspec-railsのデフォルトではtransactionでクリーンアップ処理が行われますが、外部ツールを使うことによってtransactionではない方法でクリーンアップ処理を行うことができます。
その際には設定ファイルを以下のように変更する必要があります。
https://github.com/rspec/rspec-rails/blob/main/features/Transactions.md#disabling-transactions
RSpec.configure do |config|
config.use_transactional_fixtures = false
end
外部ツールとしてよく使われるのがdatabase_cleanerです。transactionだけでなくtruncationとdeletionも使うことができます。
RSpec.configure do |config|
config.use_transactional_fixtures = false
config.before(:suite) do
DatabaseCleaner.strategy = # (:transaction|:truncation|:deletion)
end
config.around(:each) do |example|
DatabaseCleaner.cleaning do
example.run
end
end
end
transaction, trunaction, deletionの中で最も早いのはtransactionのようなので、基本的にはtransactionを使うのが良さそうです。
For the SQL libraries the fastest option will be to use :transaction as transactions are simply rolled back. If you can use this strategy you should.
今回のようなDDLを伴うようなテストがある場合は、テストにタグをつけるなどしてexampleごとにクリーンアップ戦略を使い分けるのが良さそうでした。
RSpec.configure do |config|
config.use_transactional_fixtures = false
config.around(:each) do |example|
DatabaseCleaner.strategy = if example.metadata[:deletion]
:deletion
else
:transaction
end
DatabaseCleaner.cleaning do
example.run
end
end
end
RSpec.describe Hoge, type: :model do
describe '.create_temp_table_data' do
subject { described_class.create_temp_table_data }
it 'create temporary table', :deletion do
subject
expect(described_class.connection.table_exists?(:temp)).to eq(true)
expect(
described_class.connection.execute("SELECT COUNT(*) FROM `temp`").size
).to eq(1)
end
end
end
参考