Tuesday, February 21, 2012
How to test code in your ActiveRecord after_commit callbacks disabling transactional fixtures per-test without hiding bugs
Okay, so at Goodreads we're finally moving to rails 3.2, and in the process we've discovered the cool
So we had reason to try converting some of our
Okay, but what about testing? By default, "transactional fixtures" are enabled. I'm not really sure why that's the terminology, as we use this behavior, but I really, really hate testing with fixtures. In fact, I just spent about 18 hours straight yesterday ripping a ton of them out of our codebase to get this all working. But I digress.
The problem is that if we want our after_commit callbacks to fire, they can't be wrapped by transactions around the entire test, because the commit never happens (the transaction gets rolled back after the test runs, pass or fail). So we had to find another way. The only options we could come up with were:
As with any good blog post, we chose door number 3. Reasoning:
So we're using rails 3.2.1, hopefully this is stable across several revisions, I'd hate to have to chase this down again. :( But here's the key:
So two things:
And here's the code I wrote. I created a module that we just monkey-patch/mix in to
gist here
A few notes:
You just need to call a single method at the top of your test (or a setup method if you want to apply it to a all tests in a suite--again, here's a place I prefer rspec, as you can effectively have a separate setup method for an arbitrary grouping of tests within a given test suite, but meh):
---
Goodreads is hiring! Please check us out and make the world a better place for readers!
after_commit
and after_rollback
callbacks that easily let you execute things outside the transaction that wraps an active record save/destroy. This was exactly what we needed for a few cases where we were doing non-mission-critical updates in callbacks that can on occasion take a bit of time (hitting memcached or redis servers when our resque queue was overwhelmed) inside transactions.So we had reason to try converting some of our
after_(save|update|destroy|create)
callbacks to after_commit calls. The easy step is figuring out how to deal with code that used to do name_changed? or name_was helpers (they retain their expected behavior only within the transaction; once the commit takes place they all get reset). So all we did was keep some after_xxx
methods around that set instance variables, then let the after_commit
methods read those instance variables (then reset to some disabled state lest we re-process the same code again if models get saved repeatedly for some reason).Okay, but what about testing? By default, "transactional fixtures" are enabled. I'm not really sure why that's the terminology, as we use this behavior, but I really, really hate testing with fixtures. In fact, I just spent about 18 hours straight yesterday ripping a ton of them out of our codebase to get this all working. But I digress.
The problem is that if we want our after_commit callbacks to fire, they can't be wrapped by transactions around the entire test, because the commit never happens (the transaction gets rolled back after the test runs, pass or fail). So we had to find another way. The only options we could come up with were:
- Hack the callbacks to call the commit callbacks without actually committing the records (a la something like this)
- Disable all transactional fixtures and handle the data cleanup ourselves (set
use_transactional_fixtures = false
in yourTestCase
class) - hack
Test::Unit
to not wrap (or unwrap) the tests in transactions
As with any good blog post, we chose door number 3. Reasoning:
- The first option leaves us wide open to bugs, especially of the variety where we might depend on name_changed?-type logic, which may still return true if the transaction hasn't yet been committed yet...which would result in tests passing even if the code would fail in production (!). This was too much of a risk for me to stomach
- The second option sounds like a lot of work, a pain every time you add a new model/table, and potentially slow if we're deleting data from every table after each test (could do "
TRUNCATE TABLE foo
" to be fairly fast, but it seemed to be about 0.5 sec each time we did this for our schema. With thousands of tests, it starts to add up. We're also kind of sick of having "data leakage" between tests, and this approach seems to encourage it. One note: I prefer rspec syntax, but we're kinda stuck with Test::Unit for historical reasons...I'm not sure if rspec's hierarchical structure would allow enabling/disabling transactional fixtures at a more granular level? I guess I think I remember having to do it at the class-level there too (all tests are either transactional or not, not determined at the individual test level). - So option three: find a way to hack Test::Unit to not wrap the tests in transactions, allowing us to select on a per-test basis whether to wrap in a transaction or not. I actually never dug deep enough to find where that code was...and considered monkey-patching or subclassing to maintain a separate queue of tests that are to be run outside transactions. In the end I took a bit of a shortcut and actually rollback the transactions at the beginning of the test. Yeah, the first thing we do is rollback the transaction, turn off transactional fixtures (so activerecord doesn't try to roll things back), run test, then restore everything the way it was (minus the transaction....it appears ActiveRecord plays well and doesn't try to rollback transactions that aren't there. Bam, done!
So we're using rails 3.2.1, hopefully this is stable across several revisions, I'd hate to have to chase this down again. :( But here's the key:
# activerecord-3.2.1/lib/active_record/fixtures.rb:
...
module ActiveRecord
module TestFixtures
...
def run_in_transaction?
use_transactional_fixtures &&
!self.class.uses_transaction?(method_name)
end
...
def teardown_fixtures
return unless defined?(ActiveRecord) && !ActiveRecord::Base.configurations.blank?
unless run_in_transaction?
ActiveRecord::Fixtures.reset_cache
end
# Rollback changes if a transaction is active.
if run_in_transaction?
@fixture_connections.each do |connection|
if connection.open_transactions != 0
connection.rollback_db_transaction
connection.decrement_open_transactions
end
end
@fixture_connections.clear
end
ActiveRecord::Base.clear_active_connections!
end
...
end
end
So two things:
- run_in_transaction?: by setting use_transactional_fixtures = false, we can force this method to return false
- so in teardown_fixtures, it won't bother trying to do any rollback at all...so we can "safely" roll back the transaction before even beginning our test
And here's the code I wrote. I created a module that we just monkey-patch/mix in to
ActiveSupport::TestCase
. I aimed for a one-liner to deactivate fixtures (and not require an explicit cleanup call at the end, cause someone's gonna forget).gist here
A few notes:
setup_fixtures
is a complement to teardown_fixtures, in ActiveRecord::TestFixtures. I'm just calling it here to restore any fixture data that tests actually needdelete_everything
is a method specific to our code that knows which tables/models to delete. There are a few options for this...maybe just do a "SHOW TABLES" query and delete what you've got (except maybe schema_migrations ;) ). Maybe query all descendant classes of ActiveRecord::Base. We chose to maintain a list of models and tables so we can be a little more selective of which tables we clear out (to save a little processing time). It'll mean a little more maintenance, but we're a little more stable in terms of our schema, so saving a few minutes on running our full set of tests is probably worth it.
You just need to call a single method at the top of your test (or a setup method if you want to apply it to a all tests in a suite--again, here's a place I prefer rspec, as you can effectively have a separate setup method for an arbitrary grouping of tests within a given test suite, but meh):
test "some_method is supposed to do something interesting" do
disable_transactional_fixtures # optionally pass in args telling specific tables/models to delete
# do your tests...
# everything gets deleted on magically on its own! :)
end
---
Goodreads is hiring! Please check us out and make the world a better place for readers!
Labels: fixtures, rails 3.2, testing, transactions
Comments:
<< Home
I know this post is pretty old in Internet time, but have you heard of the test_after_commit gem?
https://github.com/grosser/test_after_commit
Post a Comment
https://github.com/grosser/test_after_commit
<< Home