AnyFixture
Fixtures are a great way to increase your test suite performance, but for a large project, they are very hard to maintain.
We propose a more general approach to lazy-generate the global state for your test suite – AnyFixture.
With AnyFixture, you can use any block of code for data generation, and it will take care of cleaning it out at the end of the run.
Consider an example:
# The best way to use AnyFixture is through RSpec shared contexts
RSpec.shared_context "account", account: true do
# You should call AnyFixture outside of transaction to re-use the same
# data between examples
before(:all) do
# The provided name ("account") should be unique.
@account = TestProf::AnyFixture.register(:account) do
# Do anything here, AnyFixture keeps track of affected DB tables
# For example, you can use factories here
FactoryBot.create(:account)
# or with Fabrication
Fabricate(:account)
# or with plain old AR
Account.create!(name: "test")
end
end
# Use .cached to retrieve the fixiture record
let(:account) { TestProf::AnyFixture.cached(:account) }
end
# You can enhance the existing database cleaning. Posts will be deleted before fixtures reset
TestProf::AnyFixture.before_fixtures_reset do
Post.delete_all
end
# Or after reset
TestProf::AnyFixture.after_fixtures_reset do
Post.delete_all
end
# Then in your tests
# Active this fixture using a tag
describe UsersController, :account do
# ...
end
# This test also uses the same account record,
# no double-creation
describe PostsController, :account do
# ...
endSee real life example.
Instructions
RSpec
In your spec_helper.rb (or rails_helper.rb if you have one):
require "test_prof/recipes/rspec/any_fixture"Now you can use TestProf::AnyFixture in your tests.
Minitest
When using AnyFixture with Minitest, you should take care of cleaning the database after each test run by yourself. For example:
# test_helper.rb
require "test_prof/any_fixture"
at_exit { TestProf::AnyFixture.clean }DSL
We provide an optional syntactic sugar (through Refinement) to make it easier to define fixtures and use callbacks:
require "test_prof/any_fixture/dsl"
# Enable DSL in RSpec
RSpec.configure do |config|
config.include TestProf::AnyFixture::DSL
end
# Minitest
class MyBaseTestCase < Minitest::Test
include TestProf::AnyFixture::DSL
end
# and then you can use `fixture` method (which is just an alias for `TestProf::AnyFixture.register`)
before(:all) { fixture(:account) { create(:account) } }
# You can also use it to fetch the record (instead of storing it in instance variable)
let(:account) { fixture(:account) }
# You can just use `before_fixtures_reset` or `after_fixtures_reset` callbacks
before_fixtures_reset { Post.delete_all }
after_fixtures_reset { Post.delete_all }Note that the #fixture method also refinds Active Record objects on read, i.e., the following two expressions works similarly:
let(:account) { fixture(:account) }
# similar to
let(:account) { Account.find(TestProf::AnyFixture.cached(:account).id) }Temporary disable fixtures
Some of your tests might rely on clean database. Thus running them along with AnyFixture-dependent tests, could produce failures.
You can disable (or delete) all created fixture while running a specified example or group using the :with_clean_fixture shared context:
context "global state", :with_clean_fixture do
# or include explicitly
# include_context "any_fixture:clean"
specify "table is empty or smth like this" do
# ...
end
endHow does it work? It wraps the example group into a transaction (using before_all) and calls TestProf::AnyFixture.clean and TestProf::AnyFixture.disable! before running the examples and then call TestProf::AnyFixture.enable! at the context exit. The disable!/enable! method toggle the cache state. That makes it possible to re-use blocks passed during registration like there is no AnyFixture:
# Here we create a new account if AnyFixture is disabled
let(:account) { fixture(:account) { create(:account) } }Reseting fixtures (i.e., delete data from the affected tables) can be heavy. Try to avoid such situations and write specs independent of the global state.
Usage report
AnyFixture collects the usage information during the test run and could report it at the end:
[TEST PROF INFO] AnyFixture usage stats:
key build time hit count saved time
user 00:00.004 4 00:00.017
post 00:00.002 1 00:00.002
Total time spent: 00:00.006
Total time saved: 00:00.019
Total time wasted: 00:00.000The reporting is off by default, to enable the reporting set TestProf::AnyFixture.config.reporting_enabled = true (or you can invoke it manually through TestProf::AnyFixture.report_stats).
You can also enable reporting through the ANYFIXTURE_REPORT=1 env variable.
Using auto-generated SQL dumps
@since v1.0, experimental
AnyFixture is designed to generate data once per a test suite run (and cleanup in the end). It still could be time-consuming (e.g., for system or performance tests); thus, we want to optimize further.
We provide another way of speeding up test data called #register_dump. It works similarly to #register for the first run: it accepts a block of code and tracks SQL queries made within it. Then, it generates a plain SQL dump representing the data creating or modified during the call and uses this dump to restore the database state for the subsequent test runs.
Let's consider an example:
RSpec.shared_context "account", account: true do
# You should call AnyFixture outside of transaction to re-use the same
# data between examples
before(:all) do
# The block is called once per test run (similary to #register)
TestProf::AnyFixture.register_dump("account") do
# Do anything here, AnyFixture keeps track of affected DB tables
# For example, you can use factories here
account = FactoryBot.create(:account, name: "test")
# or with Fabrication
account = Fabricate(:account, name: "test")
# or with plain old AR
account = Account.create!(name: "test")
# updates are also tracked
account.update!(tag: "sql-dump")
end
end
# Here, we MUST use a custom way to retrieve a record: since we restore the data
# from a plain SQL dump, we have no knowledge of Ruby objects
let(:account) { Account.find_by!(name: "test") }
endAnd that's what happened when we run tests:
# first run
$ bundle exec rspec
# AnyFixture.register_dump is called:
# - is SQL dump present? No
# - run block and write all modifying queries to a new SQL dump
# AnyFixture.clean is called:
# - clean all the affected tables
# second run
$ bundle exec rspec
# AnyFixture.register_dump is called:
# - is SQL dump present? Yes
# - restore dump (do not run block)
# AnyFixture.clean is called:
# - clean all the affected tablesRequirements
Currently, only PostgreSQL 12+ and SQLite3 are supported.
Dump invalidation
The generated dump could become out of date for many reasons: database schema changed, fixture block has been updated, etc. To deal with invalidation, we use file content digests as cache keys (dump file name suffixes).
By default, AnyFixture watches db/schema.rb, db/structure.sql and the file that calls #register_dump.
The list of default watch files could be updated by modifying the default_dump_watch_paths configuration parameter:
TestProf::AnyFixture.configure do |config|
# you can use exact file paths or globs
config.default_dump_watch_paths << Rails.root.join("spec/factories/**/*")
endAlso, you add watch files to a specific #register_dump call via the watch option:
TestProf::AnyFixture.register_dump("account", watch: ["app/models/account.rb", "app/models/account/**/*,rb"]) do
# ...
endNOTE: When you use the watch option, the current file is not added to the watch list. You should use __FILE__ explicitly for that.
Finally, if you want to forcefully re-generate a dump, you can use the ANYFIXTURE_FORCE_DUMP environment variable:
ANYFIXTURE_FORCE_DUMP=1will force all dumps regeneration.ANYFIXTURE_FORCE_DUMP=accountwill force regeneration only of the matching dumps (i.e., matching/account/).
Cache keys
It's possible to provide custom cache keys to be used as a part of a digest:
# cache_key could be pretty much anything that responds to #to_s
TestProf::AnyFixture.register_dump("account", cache_key: ["str", 1, {key: :val}]) do
# ...
endHooks
before / after
Before hooks are called either before calling a fixture block or before restoring a dump. One particular use case is to re-create a tenant in a multi-tenant app:
TestProf::AnyFixture.register_dump(
"account",
before: proc do
begin
Apartment::Tenant.create("test")
rescue
nil
end
Apartment::Tenant.create("test")
end
) do
# ...
endSimilarly, after hooks are called either after calling a fixture block or after restoring a dump.
You can also specify global before and after hooks:
TestProf::AnyFixture.configure do |config|
config.before_dump do |dump:, import:|
# dump is an object containing information about the dump (e.g., dump.digest)
# import is true if we're restoring a dump and false otherwise
# do something
end
config.after_dump do |dump:, import:|
# ...
end
endNOTE: after callbacks are always executed, even if dump creation failed. You can use the dump.success? method to determine whether data generation succeeds or not.
skip_if
This callback is available only as of the #register_dump option and could be used to ignore the fixture completely. This is useful when you want to preserve the database state between test runs (i.e., do not clean the DB).
Here is a complete example:
TestProf::AnyFixture.register_dump(
"account",
# do not track tables for AnyFixture.clean (though other fixtures could affect this)
clean: false,
skip_if: proc do |dump:|
Apartment::Tenant.switch!("test")
# if the current account has matching meta — the database is in actual state
Account.find_by!(name: "test").meta["dump-version"] == dump.digest
end,
before: proc do
begin
Apartment::Tenant.create("test")
rescue
nil
end
Apartment::Tenant.create("test")
end,
after: proc do |dump:, import:|
# do not persist dump version if dump failed or we're restoring data
next if import || !dump.success?
Account.find_by!(name: "test").then do |account|
account.meta["dump-version"] = dump.digest
account.save!
end
end
) do
# ...
endConfiguration
There a few more configuration options available:
TestProf::AnyFixture.configure do |config|
# Where to store dumps (by default, TestProf.artifact_path + '/any_dumps')
config.dumps_dir = "any_dumps"
# Include mathing queries into a dump (in addition to INSERT/UPDATE/DELETE queries)
config.dump_matching_queries = /^$/
# Whether to try using CLI tools such as psql or sqlite3 to restore dumps or not (and use ActiveRecord instead)
config.import_dump_via_cli = false
endNOTE: When using CLI tools to restore dumps, it's not possible to track affected tables and thus clean them via AnyFixture.clean.