with_model dynamically builds an Active Record model (with table) before each test in a group and destroys it afterwards.
with_model is actively maintained. It is quite stable, so while updates may appear infrequent, it is only because none are needed.
Install as usual: gem install with_model or add gem 'with_model' to your Gemfile. See .github/workflows/ci.yml for supported (tested) Ruby versions.
Extend WithModel into RSpec:
require"with_model"RSpec.configuredo |config|
config.extendWithModelendExtend WithModel into minitest/spec and set the test runner explicitly:
require"with_model"WithModel.runner=:minitestclassMinitest::SpecextendWithModelendAfter setting up as above, call with_model and inside its block pass it a table block and a model block.
require"spec_helper"moduleMyModule;end# A pre-existing modelclassCar < ActiveRecord::Baseself.abstract_class=trueenddescribe"A blog post"dowith_model:BlogPostdo# The table block (and an options hash) is passed to Active Record migration’s `create_table`.tabledo |t|
t.string:titlet.timestampsnull: falseend# The model block is the Active Record model’s class body.modeldoincludeMyModulehas_many:commentsvalidates_presence_of:titledefself.some_class_method"chunky"enddefsome_instance_method"bacon"endendend# with_model classes can have associations.with_model:Commentdotabledo |t|
t.string:textt.belongs_to:blog_postt.timestampsnull: falseendmodeldobelongs_to:blog_postendendit"can be accessed as a constant"doexpect(BlogPost).tobeendit"has the module"doexpect(BlogPost.include?(MyModule)).tobetrueendit"has the class method"doexpect(BlogPost.some_class_method).toeq"chunky"endit"has the instance method"doexpect(BlogPost.new.some_instance_method).toeq"bacon"endit"can do all the things a regular model can"dorecord=BlogPost.newexpect(record).not_tobe_validrecord.title="foo"expect(record).tobe_validexpect(record.save).tobetrueexpect(record.reload).toeqrecordrecord.comments.create!(text: "Lorem ipsum")expect(record.comments.count).toeq1end# with_model classes can have inheritance. Car is abstract, so it has no table# and Ford gets one of its own. To inherit a concrete superclass's table# instead, see "Single table inheritance" below.with_model:Ford,superclass: Cardotableendit"has a specified superclass"doexpect(Ford.new).tobe_a(Car)endenddescribe"with_model can be run within RSpec :all hook"dowith_model:BlogPost,scope: :alldotabledo |t|
t.string:titleendendbefore:alldoBlogPost.create# without scope: :all these will failendit"has been initialized within before(:all)"doexpect(BlogPost.count).toeq1endenddescribe"another example group"doit"does not have the constant anymore"doexpect(defined?(BlogPost)).tobe_falsyendenddescribe"with table options"dowith_model:WithOptionsdotableid: falsedo |t|
t.string"foo"t.timestampsnull: falseendendit"respects the additional options"doexpect(WithOptions.columns.map(&:name)).not_toinclude("id")endendPass table(false) to create no table at all. Active Record's own inheritance
then supplies the superclass's table, which is what single table inheritance
needs.
The superclass can be another with_model model. Its constant does not exist yet
when the superclass: argument is read, so name it with a String or a Symbol, or
pass a callable returning it, and it will be resolved once per example.
describe"with_model supports Single Table Inheritance"dowith_model:Sandwichdotabledo |t|
t.string"type"t.string"bread"endendwith_model:ChunkyBacon,superclass: :Sandwichdotable(false)endit"shares the superclass's table"doexpect(ChunkyBacon.table_name).toeqSandwich.table_nameendit"stores its own type"dosandwich=ChunkyBacon.create!(bread: "rye")expect(sandwich.reload.type).toeq"ChunkyBacon"expect(Sandwich.first).tobe_aChunkyBaconendendA superclass: that cannot be used raises WithModel::InvalidSuperclass: one
that is not an Active Record class, one with no table of its own
(ActiveRecord::Base, or an abstract class such as a Rails app's
ApplicationRecord), or one whose table has no inheritance column to tell a
subclass's rows apart. A name that resolves to nothing raises
WithModel::MissingSuperclass, a kind of InvalidSuperclass, quoting the
constant Ruby could not find — for a namespaced name, that is the segment which
is actually missing. Both are ArgumentErrors.
A superclass whose table does not exist yet is allowed, since the table may be created later in the example, and Active Record reports its absence clearly enough on its own. Rows the model wrote are deleted when it goes away, since they name a class that is about to stop existing and would make the superclass unloadable.
foreign_key: true asks Active Record to infer the table a foreign key points
at, and it infers authors from author_id. Generated table names are unique
rather than conventional, so name the table instead:
describe"with_model supports foreign keys"dowith_model:Authordotableendwith_model:Bookdotabledo |t|
t.references:author,foreign_key: {to_table: Author.table_name}endmodeldobelongs_to:authorendendit"has a foreign key"doexpect{Book.create!(author_id: 0)}.toraise_errorActiveRecord::InvalidForeignKeyendendThis reads Author.table_name, so declare Author first: models are created in
the order they are declared, and destroyed in the reverse order.
See the gemspec metadata for dependency requirements. RSpec and minitest are indirect dependencies, and with_model should support any maintained version of both.
- A unique table name is used for tables generated via
with_model/WithModel::Model.new. This allowswith_model(when limited to this API) to run concurrently (in processes or threads) with a single database schema. While there is a possibility of collision, it is very small. - A user-supplied table name is used for tables generated via
with_table/WithModel::Table.new. This may cause collisions at runtime if tests are run concurrently against a single database schema, unless the caller takes care to ensure the table names passed as arguments are unique across threads/processes. - Generated models are created in stubbed constants, which are global; no guarantee is made to the uniqueness of a constant, and this may be unsafe.
- Generated classes are Active Record subclasses:
- This library makes no guarantee as to the thread-safety of creating Active Record subclasses concurrently.
- This library makes no guarantee as to the thread-safety of cleaning up Active Record/Active Support’s internals which are polluted upon class creation.
In general, with_model is not guaranteed to be thread-safe, but is, in certain usages, safe to use concurrently across multiple processes with a single database schema.
Bug reports and pull requests are welcome. See the CONTRIBUTING guide for how to run the tests against a particular Ruby or Active Record.
with_model uses Semantic Versioning 2.0.0.
Copyright © 2010–2026 Casebook PBC. Licensed under the MIT license, see LICENSE file.