Oaken is fixtures + factories + seeds for your Rails development & test environments.
Oaken takes inspiration from Rails' fixtures' approach of storytelling about your app's object graph, but replaces the nightmare YAML-based UX with Ruby-based data scripts. This makes data much easier to reason about & connect the dots.
In Oaken, you start by creating a root-level model, typically an Account, Team, or Organization etc. to group everything on, in a scenario. From your root-level model, you then start building your object graph by mirroring how your app works.
So what comes next in your account flow? Maybe it's creating Users on the account.
You can go further if you need to. Is the Account about selling something, like donuts? Maybe you add a menu and some items.
It'll look like this:
account = accounts.create :kaspers_donuts, name: "Kasper's Donuts"
kasper = users.create :kasper, name: "Kasper", email_address: "[email protected]", accounts: [account]
coworker = users.create :coworker, name: "Coworker", email_address: "[email protected]", accounts: [account]
menu = menus.create(account:)
plain_donut = menu_items.create menu:, name: "Plain", price_cents: 10_00
sprinkled_donut = menu_items.create menu:, name: "Sprinkled", price_cents: 10_10
Note
create
takes an optional symbol label. This makes the record accessible in tests, e.g. users.create :kasper
lets tests do setup { @kasper = users.kasper }
.
With fixtures, this would be 4 different files in test/fixtures
for accounts
, users
, menus
, and menu_items
. It would be ~20 lines of YAML versus ~6 lines of Ruby for this data.
Another issue in fixture files, is that objects from different scenarios are all mixed together making it hard to get a picture of what's going on — even in small apps.
Fixtures also require you to label every record and make them unique throughout your dataset — you have to be careful not to create clashes. This gets difficult to manage quickly and requires diligence on a team that's trying to ship.
However, often the fact that a record is associated onto another is enough. So in Oaken, we let you skip naming every record. Notice how the menus.create
& menu_items.create
calls don't pass symbol labels. You can still get at them in tests though if you really need to with accounts.kaspers_donuts.menus.first.menu_items.first
.
See the fixtures version
# test/fixtures/accounts.yml
kaspers_donuts:
name: Kasper's Donuts
# test/fixtures/users.yml
kasper:
name: "Kasper"
email_address: "[email protected]"
accounts: kaspers_donuts
coworker:
name: "Coworker"
email_address: "[email protected]"
accounts: kaspers_donuts
# test/fixtures/menus.yml
basic:
account: kaspers_donuts
# test/fixtures/menu/items.yml
plain_donut:
menu: basic
name: Plain
price_cents: 10_00
sprinkled_donut:
menu: basic
name: Sprinkled
price_cents: 10_10
The reason you go through all the trouble of massaging your fixture files is to have a stable named dataset across test runs that's relatively quick to load — so the database call cost is amortized across tests.
Oaken mirrors this approach giving you stability in your dataset and the relative quickness to insert the data.
For instance, if you have 10 tests that each need the same 2 records, Oaken puts them in the database once before tests run, same as fixtures.
The tradeoff is that if you run just 1 test we'll still seed those 2 same records, but we'll also seed any other record you've added to the shared dataset that might not be needed in those tests.
We rely on Rails' tests being wrapped in transactions so any changes are rolled back after the test case run.
Note
It can be a good idea to structure your object graph so you won't need database records for your tests — reality can sometimes be far from that ideal state though. Oaken aims to make your present reality easier and something you can improve.
Factories can let you start an app easier. It's just this one factory for now, ok, easy enough.
Over time, however, many teams find their factory based test suite slows to a crawl. Suddenly one factory ends up pulling in the rest of the app.
Factories end up requiring a lot of diligence and passing just the right things in just-so to make managable.
Oaken does away with this. See the sections on the fixtures comparisons above for how.
Warning
Full Disclaimer: while I have worked on systems using factories, I overall don't get it and the fixtures approach makes more sense to me despite the UX issues. I'm trying to support a partial factories approach here in Oaken, see the below section for that, and I'm open to ideas here.
Tip
Oaken is compatible with FactoryBot and Fabrication, and they should be able to work together. I consider it a bug if there's compatibility issues, so please open an issue here if you find something.
See the sections on defaults & helpers below.
The aim for Oaken is to have most of the feature set of factories for a fraction of the implementation complexity.
Oaken upgrades seeds in db/seeds.rb
, so you can put together scenarios & reuse the development data in tests.
This way, the data you see in your browser, is the same data you work with in tests to make your object graph easier to get — especially for people new to your codebase.
So you get a cohesive & stable dataset with a story like fixtures & their fast loading. But you also get the dynamics of FactoryBot/Fabrication as well without making tons of one-off records to handle each case.
The end result is you end up writing less data back & forth to the database because you aren’t cobbling stuff together.
But seriously; Oaken is one of the single greatest tools I've added to my belt in the past year
It's made cross-environment shared data, data prepping for demos, edge-case tests, and overall development much more reliable & shareable across a team @tcannonfodder
We're using accounts.create
and such instead of Account.create!
to help enforce consistency & constrain your Ruby usage. This also allows for extra features like defaults
and helpers that take way less to implement.
We don't want to be a costly DSL that takes ages to learn and relearn when you come back to it.
We're aiming for a time-to-understand of less than an hour. Same goes for the internals, if you dive in, it should ideally take less than 1 hour to comprehend most of it.
We share similar sentiments to the Pkl configuration language. You may find the ideas helpful before using Oaken.
Oddly enough Oaken came out before Pkl, I just read the ideas here and went "yes, exactly!"
By default, Oaken.loader
returns an Oaken::Loader
instance to handle loading seed files.
You can load a seed directory via Oaken.loader.seed
. You can also load a file, it'll technically just be a match that happens to only hit one file.
So if you call Oaken.loader.seed :accounts
, we'll look within db/seeds/
and db/seeds/#{Rails.env}/
and match accounts{,**/*}.rb
. So these files would be found:
- accounts.rb
- accounts/kaspers_donuts.rb
- accounts/kaspers_donuts/deeply/nested/path.rb
- accounts/demo.rb
- and so on.
Tip
You can call Oaken.loader.glob
with a single identifier to see what files we'll match. > Some samples: Oaken.loader.glob :accounts
, Oaken.loader.glob "cases/pagination"
.
Tip
Putting a file in the top-level db/seeds
versus db/seeds/development
or db/seeds/test
means it's shared in both environments. See below for more tips.
Any directories and/or single-file matches are loaded in the order they're specified. So loader.seed :setup, :accounts
would first load setup and then accounts.
Important
Understanding and making effective use of Oaken's directory loading will pay dividends for your usage. You generally want to have 1 top-level directive seed
call to dictate how seeding happens in e.g. db/seeds.rb
and then let individual seed files load in no specified order within that.
When you call Oaken.loader.seed
we'll also call seed :setup
behind the scenes, though we'll only call this once. It's meant for common setup, like defaults
and helpers.
Important
We recommend you don't use create
/upsert
directly in setup. Add the defaults
and/or helpers that would be useful in the later seed files.
Here's some files you could add:
-
db/seeds/setup.rb — particularly useful as a starting point.
-
db/seeds/setup/defaults.rb — loader and type-specific defaults.
-
db/seeds/setup/defaults/*.rb — you could split out more specific files.
-
db/seeds/setup/users.rb — a type specific file for its defaults/helpers, doesn't have to just be users.
-
db/seeds/development/setup.rb — some defaults/helpers we only want in development.
-
db/seeds/test/setup.rb — some defaults/helpers we only want in test.
Tip
Remember, since we're using seed
internally you can nest as deeply as you want to structure however works best. There's tons of flexibility in the **/*
glob pattern seed
uses.
Oaken has some directory recommendations to help strengthen your understanding of your object graph:
db/seeds/data
for any data tables, like the plans a SaaS app has.- Group scenarios around your top-level root model, like
Account
,Team
, orOrganization
and have adb/seeds/accounts
directory. db/seeds/cases
for any specific cases, like pagination.
If you follow all these conventions you could do this:
Oaken.loader.seed :data, :accounts, :cases
And here's some potential file suggestions you could take advantage of:
-
db/seeds/data/plans.rb — put your SaaS plans in here.
-
db/seeds/test/data/plans.rb — some test specific plans, in case we need them.
-
db/seeds/cases/pagination.rb — group the seed code for generating pagination data here. NOTE: this could reference an account setup earlier.
-
db/seeds/test/cases/*.rb — any test specific cases.
Tip
We're letting Oaken's loading do all the hard work here, we're just staging the loading phases by specifying the top-level order.
For the cases part, you may want to tweak it a bit more.
You could add any definitely shared cases in db/seeds/cases
. Say you have a db/seeds/cases/pagination.rb
case that can be shared between development and test.
If not, you can add environment specific ones in db/seeds/development/cases/pagination.rb
and db/seeds/test/cases/pagination.rb
.
You could also avoid loading all the cases in the test environment like this:
Oaken.loader.seed :cases if Rails.env.development?
Now you can load specific seeds in tests, like this:
class PaginationTest < ActionDispatch::IntegrationTest
setup { seed "cases/pagination" }
end
And in RSpec:
RSpec.describe "Pagination", type: :feature do
before { seed "cases/pagination" }
end
Note
We're recommending having one-off seeds on an individual unit of work to help reinforce test isolation. Having some seed files be isolated also helps:
- Reduce amount of junk data generated for unrelated tests
- Make it easier to debug a particular test
- Reduce test flakiness
- Encourage writing seed files for specific edge-case scenarios
You can customize the loading and loader as well:
# config/initializers/oaken.rb
# Call `with` to build a new loader. Here we're just passing the default internal options:
loader = Oaken.loader.with(lookup_paths: "test/seeds") # Useful to pull from another directory, when migrating.
loader = Oaken.loader.with(locator: Oaken::Loader::Type, provider: Oaken::Stored::ActiveRecord, context: Oaken::Seeds)
Oaken.loader = loader # You can also replace Oaken's default loader.
Tip
Oaken
delegates Oaken::Loader
's public instance methods to loader
,
so Oaken.seed
works and is really Oaken.loader.seed
. Same goes for Oaken.lookup_paths
, Oaken.with
, Oaken.glob
and more.
Call loader.seed
and it'll follow the rules mentioned above:
# db/seeds.rb
Oaken.loader.seed :setup, :accounts, :data
Oaken.seed :setup, :accounts, :data # Or just this for short.
Both bin/rails db:seed
and bin/rails db:seed:replant
work as usual.
If you're in the bin/rails console
, you can invoke the same seed
method as in db/seeds.rb
.
Oaken.seed :setup, "cases/pagination"
This is useful if you're working on hammering out a single seed script.
Tip
Oaken wraps each file load in an ActiveRecord::Base.transaction
so any invalid data rolls back the whole file.
If you're using Rails' default minitest-based tests call this:
# test/test_helper.rb
class ActiveSupport::TestCase
include Oaken.loader.test_setup
end
We've got full support for Rails' test parallelization out of the box.
Note
For RSpec, you can put this in spec/rails_helper.rb
:
require "oaken/rspec_setup"
Oaken's data scripts are composed of table name looking methods corresponding to Active Record classes, which you can enhance with defaults
and helper methods, then eventually calling create
or upsert
on them.
Important
Ok, this bit is probably the most complex in Oaken. You can see the implementation in Oaken::Seeds#method_missing
and then Oaken::Loader::Type
.
When you reference e.g. accounts
we'll hit Oaken::Seeds#method_missing
hook and:
- locate a class using
loader.locate
, hittingOaken::Loader::Type.locate
. - If there's a match, call
loader.register Account, as: :accounts
.
We'll respect namespaces up to 3 levels deep, so we'll try to match:
menu_items
toMenu::Item
orMenuItem
.menu_item_details
toMenu::Item::Detail
,MenuItem::Detail
,Menu::ItemDetail
,MenuItemDetail
.- The third level which is going to be 2 separators ("::" or "") to the power of 3 levels, in other words 8 possible constants.
You can skip this by calling loader.register Menu::Item
, which we'll derive the method name via name.tableize.tr("/", "_")
or you can call register Menu::Item, as: :something_else
to have it however you want.
Internally, create
calls ActiveRecord::Base#create!
to fail early & prevent invalid records in your dataset. Runs create/save model callbacks.
users.create name: "Someone"
Some records have uniqueness constraints, like a User's email_address
, you can pass that via unique_by
:
users.create unique_by: :email_address, name: "First", email_address: "[email protected]"
users.create unique_by: :email_address, name: "Second", email_address: "[email protected]"
In the case of a uniqueness constraint clash, we'll update!
the record, so here name
is "Second"
. Runs save/update model callbacks.
Important
We're trying to make db:seed
rerunnable incrementally without needing to start from scratch. That's what the update!
part is for. I'm still not entirely sure about it and I'm trying to figure out a better way to highlight what's going on to users.
Mirrors ActiveRecord::Base#upsert
, allowing you to pass a unique_by:
which must correspond to a unique database index. Does not run model callbacks.
We'll instantiate and validate!
the record to help prevent bad data hitting the database.
Typically used for data tables, like so:
# db/seeds/data/plans.rb
plans.upsert :basic, unique_by: :title, title: "Basic", price_cents: 10_00
You can set defaults
that're applied on create
/upsert
, like this:
# Assign loader-level defaults that's applied to every type.
# Records only include defaults on attributes they have. So only records with a `public_key` attribute receive that and so on.
loader.defaults name: -> { Faker::Name.name }, public_key: -> { SecureRandom.hex }
# Assign specific defaults on one type, which overrides the loader `name` default from above.
accounts.defaults name: -> { Faker::Business.name }, status: :active
accounts.create # `name` comes from the `accounts.defaults` and `public_key` from `loader.defaults`.
accounts.upsert # Same.
users.create # `name` comes from `loader.defaults`.
Tip
It's best to be explicit in your dataset to tie things together with actual names, to make your object graph more cohesive. However, sometimes attributes can be filled in with Faker if they're not part of the "story".
Oaken uses Ruby's singleton_methods
for helpers because it costs us 0 lines of code to write and maintain.
In plain Ruby, they look like this:
obj = Object.new
def obj.hello = :yo
obj.hello # => :yo
obj.singleton_methods # => [:hello]
So you can do stuff like this on, say, a users
instance:
# Notice how we're using the `labeled_email` helper to compose `create_labeled` too:
def users.create_labeled(label, email_address: labeled_email(label), **) = create(label, email_address:, **)
def users.labeled_email(label) = "#{label}@example.com" # You don't have to use endless methods, they're fun though.
Now create_labeled
& labeled_email
are available everywhere the users
instance is, in development and test!
test "we definitely need this" do
assert_equal "[email protected]", users.labeled_email(:person)
end
Here's how you can provide a default unique_by:
on all users
:
# We override the built-in `create` to provide the default. Yes, `super` works on overriden methods!
def users.create(label = nil, unique_by: :email_address, **) = super
You could use this to provide FactoryBot
-like helpers. Maybe adding a factory
method?
Note
It's still early days for these kind of helpers, so I'm still finding out what's possible with them. I'd love to know how you're using them on the Discussions tab.
You can convert your Rails fixtures to Oaken's seeds by running:
bin/rails generate oaken:convert:fixtures
This will convert anything in test/fixtures to db/seeds. E.g. test/fixtures/users.yml
becomes db/seeds/users.rb
and so on.
IF you've fully converted to Oaken you may no longer want fixtures when running Rails' generators,
so you can disable generating them in config/application.rb
like this:
module YourApp
class Application < Rails::Application
# We prefer Oaken to fixtures, so we disable them here.
config.app_generators { _1.test_framework _1.test_framework, fixture: false }
end
end
The test_framework
repeating is to preserve :test_unit
or :rspec
respectively.
Note
If you're using FactoryBot
as well, you don't need to do this since it already replaces fixtures for you.
If you've got a mostly working FactoryBot or Fabrication setup you may not want to muck with that too much.
However, you can grab some of the most shared records and shave off some significant runtime on your test suite.
It's @erikaxel.bsky.social's team! They shaved 5.5 minutes off their test suite.
And that's just the first batch integrating Oaken!
— Kasper Timm Hansen (@kaspth.bsky.social) January 8, 2025 at 11:00 PM
[image or embed]
Set Oaken up for your tests like the setup section mentions, and then only add a setup directory and scenarios around the root-level model like an Account. Like this:
# db/seeds.rb
if Rails.env.test?
Oaken.loader.seed :setup, :accounts
return
end
Then define some very basic account setup like the very top of the README mentions.
Or maybe like this:
# db/seeds/test/accounts/basic.rb
accounts.create :basic, **FactoryBot.attributes_for(:account)
# Maybe some extra necessary records on the account here.
Now tests can pass account: accounts.basic
to other factories.
Do the very minimum and go slow. Pick records that you know are 100% safe to share.
Note
I'd love to improve these migration notes. Please file an issue if something is confusing. I'd also love to hear your experience in general.
Install the gem and add to the application's Gemfile by executing:
$ bundle add oaken
If bundler is not being used to manage dependencies, install the gem by executing:
$ gem install oaken
After checking out the repo, run bin/setup
to install dependencies. Then, run bin/rails test
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and the created tag, and push the .gem
file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/kaspth/oaken. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
The gem is available as open source under the terms of the MIT License.
Everyone interacting in the Oaken project's codebases, issue trackers, chat rooms, and mailing lists is expected to follow the code of conduct.
Initial development is supported in part by:

And by:
As a sponsor you're welcome to submit a pull request to add your own name here.