Let's write a small Rails application to illustrate:

# app/models/article.rb
# rails g scaffold article title content:text

class Article < ApplicationRecord
  has_many :comments, dependent: :destroy

  after_create_commit -> { comments.create!(content: "First comment of #{title}") }
end
# app/models/comment.rb
# rails g scaffold comment article:references content:text

class Comment < ApplicationRecord
  belongs_to :article
end
# spec/rails_helper.rb

# ...
RSpec.configure do |config|
  # ...

  # If you're not using ActiveRecord, or you'd prefer not to run each of your
  # examples within a transaction, remove the following line or assign false
  # instead of true.
  config.use_transactional_fixtures = false

  # ...
end

I used to think that the following test would fail:

# spec/models/article_spec.rb

require 'rails_helper'

RSpec.describe Article, type: :model do
  describe '.create' do
    subject { described_class.create!(title: 'Hello', content: 'world') }

    it 'creates a first comment' do
      expect { subject }.to change(Comment, :count).by(1)
    end
  end
end

Why? Because:

  • after_create_commit (or any after_commit) is supposed to be run after the transaction is committed
  • RSpec is configured to run tests within transactions and roll them back at the end instead of committing them
  • No commit, no after_commit. No after_commit, no comment should be created in the above example.

I was wrong. The comment is created. It does not seem logical, yet this is how it behaves. A little investigation got me to understand why.

Let's output the SQL queries executed during the example to see exactly what happens:

it 'creates a first comment' do
  ActiveRecord::Base.logger = Logger.new(STDOUT)
  expect { subject }.to change(Comment, :count).by(1)
end

Output:

D, [2023-04-23T15:51:25.707540 #346392] DEBUG -- :   Comment Count (0.0ms)  SELECT COUNT(*) FROM "comments"
D, [2023-04-23T15:51:25.709992 #346392] DEBUG -- :   TRANSACTION (0.0ms)  SAVEPOINT active_record_1
D, [2023-04-23T15:51:25.710339 #346392] DEBUG -- :   Article Create (0.1ms)  INSERT INTO "articles" ("title", "content", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["title", "Hello"], ["content", "world"], ["created_at", "2023-04-23 13:51:25.709772"], ["updated_at", "2023-04-23 13:51:25.709772"]]
D, [2023-04-23T15:51:25.710480 #346392] DEBUG -- :   TRANSACTION (0.0ms)  RELEASE SAVEPOINT active_record_1
D, [2023-04-23T15:51:25.713710 #346392] DEBUG -- :   TRANSACTION (0.0ms)  SAVEPOINT active_record_1
D, [2023-04-23T15:51:25.713915 #346392] DEBUG -- :   Comment Create (0.0ms)  INSERT INTO "comments" ("article_id", "content", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["article_id", 1], ["content", "First comment of Hello"], ["created_at", "2023-04-23 13:51:25.713563"], ["updated_at", "2023-04-23 13:51:25.713563"]]
D, [2023-04-23T15:51:25.714024 #346392] DEBUG -- :   TRANSACTION (0.0ms)  RELEASE SAVEPOINT active_record_1
D, [2023-04-23T15:51:25.714173 #346392] DEBUG -- :   Comment Count (0.0ms)  SELECT COUNT(*) FROM "comments"
D, [2023-04-23T15:51:25.725359 #346392] DEBUG -- :   TRANSACTION (0.1ms)  rollback transaction

As you can see, there's no commit, yet the comment creation within the after_create_commit callback is executed.

Let's do the same thing outside of RSpec. Here are the SQL queries for an article creation in a rails console:

D, [2023-04-23T16:10:41.783389 #347699] DEBUG -- :   TRANSACTION (0.0ms)  begin transaction
D, [2023-04-23T16:10:41.783845 #347699] DEBUG -- :   Article Create (0.1ms)  INSERT INTO "articles" ("title", "content", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["title", "second article"], ["content", nil], ["created_at", "2023-04-23 14:10:41.782927"], ["updated_at", "2023-04-23 14:10:41.782927"]]
D, [2023-04-23T16:10:41.803780 #347699] DEBUG -- :   TRANSACTION (19.7ms)  commit transaction
D, [2023-04-23T16:10:41.814425 #347699] DEBUG -- :   TRANSACTION (0.0ms)  begin transaction
D, [2023-04-23T16:10:41.814751 #347699] DEBUG -- :   Comment Create (0.1ms)  INSERT INTO "comments" ("article_id", "content", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["article_id", 1], ["content", "First comment of second article"], ["created_at", "2023-04-23 14:10:41.814223"], ["updated_at", "2023-04-23 14:10:41.814223"]]
D, [2023-04-23T16:10:41.817173 #347699] DEBUG -- :   TRANSACTION (2.3ms)  commit transaction

Now the behavior matches what I expected.

So what's happening within RSpec? Is RSpec voluntarily messing things up?

Well, not really.

What RSpec does when use_transactional_fixtures is enabled is that it wraps the example execution within an ActiveRecord transaction. Not a database transaction, an ActiveRecord transaction.

Is there a difference?

Yes.

Is this why the logic seems messed up regarding commits?

Yes. Let me explain.

If you're not familiar with how ActiveRecord handles transactions, and especially nested transactions, I strongly advise you read my article about this very topic.

TL;DR: Most database engines don't support nested transactions. When you nest ActiveRecord transactions, it cheats. It emulates nested transactions by using savepoints.

Back to our issue, we can summarize the logic as follows:

article = Article.new(title: 'Hello', content: 'world')
ApplicationRecord.transaction(requires_new: true) do # Transaction that wraps the save part of the article creation
  article.save!
end
ApplicationRecord.transaction(requires_new: true) do # Transaction that wraps the after commit part of the article creation
  article.comments.create!(content: "First comment of #{title}")
end

No nested transaction, no magic:

  • one commit per transaction
  • after_commit logic runs after a SQL commit is executed
  • Obvious behavior.

Now within an RSpec example when use_transactional_fixtures set to true

ApplicationRecord.transaction do # RSpec "use_transactional_fixtures" transaction
  article = Article.new(title: 'Hello', content: 'world')
  ApplicationRecord.transaction(requires_new: true) do # Transaction that wraps the save part of the article creation
    article.save!
  end
  ApplicationRecord.transaction(requires_new: true) do # Transaction that wraps the after commit part of the article creation
    article.comments.create!(content: "First comment of #{title}")
  end
  raise ActiveRecord::Rollback
end

Nested transactions, ActiveRecord magic:

  • SQL commits of nested transactions replaced by the release of a savepoint
  • after_commit logic runs although no SQL commit has been executed because it depends on an ActiveRecord transaction being successful, not committed.
  • Not so obvious behavior.

Now I know why the comment is created.

Thank you for reading!
Younes SERRAJ