RSpecでDDLを伴うテストをしたらクリーンアップ処理がうまくいかなくなった

## はじめに
Rails + MySQLの構成で、RSpecを用いて動的に`CREATE TABLE`を行うような処理に対してテストを書きたかったのですが、MySQLのDDLの性質とRSpecのデータベースのクリーンアップ処理について正しく理解していないがためにハマったので、それについて書きます。

## 概要
以下のように、Rails Application越しに動的にテーブルデータを作成するような処理が必要でした。

```ruby
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
```
```ruby
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することによって、作成・更新・削除したデータベースの変更が永続化されることなく元に戻ります。

https://github.com/rspec/rspec-rails/blob/main/features/Transactions.md

> 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を用いてクリーンアップ処理が行われていることが確認できます。
```ruby
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
```
```sql
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は効かないことになります。

https://dev.mysql.com/doc/refman/8.0/ja/implicit-commit.html

- DML

```sql
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

```sql
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を実行していたからでした。
はじめに掲載したテストのログを確認すると以下のようになります。
```sql
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
```spec/rails_helper.rb
RSpec.configure do |config|
  config.use_transactional_fixtures = false
end
```

外部ツールとしてよく使われるのが[database_cleaner](https://github.com/DatabaseCleaner/database_cleaner)です。transactionだけでなくtruncationとdeletionも使うことができます。

```spec/rails_helper.rb
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を使うのが良さそうです。

https://github.com/DatabaseCleaner/database_cleaner?tab=readme-ov-file#what-strategy-is-fastest

> 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ごとにクリーンアップ戦略を使い分けるのが良さそうでした。

```spec/rails_helper.rb

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
```
```ruby
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
```

## 参考

https://stackoverflow.com/questions/13161394/activerecord-error-savepoint-active-record-1-does-not-exist