Why is Oaken: for your database seeds & test data
Today, I'd like to talk about this gem I've been working on for a while — and I think I've got something that can vastly help clarify your codebase over the existing solutions: fixtures and factories.
I've worked on systems that have used fixtures and factories, so here's my experience with both.
Then I'll share my alternate take with Oaken.
Fixtures: storytelling & speed
Let's start with the speed component.
Upfront cost to insert fixtures, but then they're speedy
Fixtures are fast, since they're seeded once before tests run. You can recoup that initial cost because individual tests are wrapped in a transaction, so you can create/update/delete within and those changes are rolled back afterwards for a clean slate.
Fixtures' main job is storytelling: here's your app's object graph
Rails' built-in fixtures for your test data are, to me, primarily for storytelling. You want a pre-setup slice of the world to ease wrapping your head around working in the codebase. You want to show how your app's object graph is connected.
Ah! We've got the Kasper's Donuts account, which has these users on it, they've got this menu with these two donut menu items on them.
Sorta like this:
# test/fixtures/accounts.yml
kaspers_donuts:
name: Kasper's Donuts
# test/fixtures/users.yml
kasper:
name: Kasper
accounts: kaspers_donuts
coworker:
name: Coworker
accounts: kaspers_donuts
# test/fixtures/menus.yml
donuts:
account: kaspers_donuts
# test/fixtures/menu/items.yml
plain_donut:
menu: donuts
name: Plain
price_cents: 10_00
sprinkled_donut:
menu: donuts
name: Sprinkled
price_cents: 10_10
The problem comes when every association is split into its own Yaml file. So if you've got 10 associations that's 10 files to track down. That's not even counting if you have separate accounts with separate data mixed in with each other.
Quick Aside: people try to capture every main and edge case in fixtures, but that becomes unruly fast. Cover the main case in fixtures and then update/create records in your test if you need to.
Even with the above suggestion, fixtures still have the discoverability problem: great, where do I put this new thing? Do I update an existing fixture or add a new one? Over time, you can end up with lots of fixtures that you may not need, further increasing the discoverability problem.
With these issues, it's ironic to say but fixtures are plain bad at their main job.
Factories: isolated, repeatable, what-the-hell-is-going-ons
Factories, in contrast to fixtures, are optimized for isolation. You can use these ready-made structures whenever you need a new something, and it'll always know how to set itself up correctly.
Sorta. In practice, I find that factories get unruly faster than fixtures.
Whenever I had to write a test with factories, sure, I could wire up the 5 different ones I needed and the test passed, but did I do it correctly? It never felt right.
They'll also get you going slow fast.
If you're starting a new Rails application and would like to make one simple technical decision that will guarantee that you have a slow and diabolically expensive-in-CI test suite, I recommend choosing factories instead of fixtures 👍
— Nate Berkopec (@nateberkopec) July 22, 2024
Like fixtures, you also run into the discoverability problem. I'm trying to add something new, is this a new factory or is there an existing one to add it to?
Finally, traits try to re-inject some storytelling and hint at the cohesiveness of your app, but everything is still anonymous so it's hard to know what's going on.
What I want is clarity and simpler data to reason about
I'm working on apps that are built of records and associations and objects, I want to see that same graph reflected in my tests.
I've worked on setting up appropriate seeds for me to develop with, why can't what I see in my browser also be used for testing?
This would be particularly helpful for juniors or anybody else joining your team.
This is exactly what I'm trying to do with Oaken. To reshape and level-up database seeds. To share them with your tests (if you want). To give you factory-like helpers for setting up extra data. To give your Rails app more conventions that make it easier to work within.
What does Oaken do?
First the name: Oaken means to have an oak-like quality, same as what ashen is to ash. It refers to seeds and sprouted from there.
With Oaken, let's convert the above fixtures sample.
It'll already start out differently than fixtures and factories because we group by a root model. A root model is the one model that everything else connects back to. Usually it's an Account, Team or Organization.
Grouping by this root model helps clarify your object graph so you can see what connects to what. Like this:
donuts = accounts.create :kaspers_donuts, name: "Kasper's Donuts"
kasper = users.create :kasper, name: "Kasper", email_address: "kasper@example.com", accounts: [donuts]
coworker = users.create :coworker, name: "Coworker", email_address: "coworker@example.com", accounts: [donuts]
menu = menus.create account: donuts
plain_donut = menu_items.create menu:, name: "Plain", price_cents: 10_00
sprinkled_donut = menu_items.create menu:, name: "Sprinkled", price_cents: 10_10
menu_item_details.create :plain, menu_item: plain_donut, description: "Plain, but mighty."
supporter = users.create name: "Super Supporter"
orders.insert_all [user_id: supporter.id, item_id: plain_donut.id] * 10
orders.insert_all \
10.times.map { { user_id: users.create.id, item_id: menu.items.sample.id } }
I cheated a bit and added some extra models in there, but notice how we can pack those in there and it's more digestible than fixtures!
Oaken seeds are meant to run longer and take up vertical space, similar to
config/routes.rb
.
Great, but where do we place this file?
Well, since Oaken's entry point is in db/seeds.rb
we'll head there and add:
Oaken.prepare do
seed :accounts
end
Now any seed file that Oaken finds in db/seeds/accounts/**/*.rb
and db/seeds/<Rails.env>/accounts/**/*.rb
is loaded and executed.
And since we've grouped by account, we can now add db/seeds/accounts/kaspers_donuts.rb
. That's it to start!
Here's more that we got from these conventions:
- This adds the same data to both development and tests.
- We could've used
db/seeds/development
ordb/seeds/test
if we didn't want to share the data. accounts.create :kasper
etc. lets you callaccounts.kasper
in tests to return the account.- We can use the standard tasks
bin/rails db:seed
andbin/rails db:seed:replant
to seed the development database.
There's tons more:
- Per-model default attributes, e.g.
users.defaults password_digest: -> { BCrypt::Password.create("password") }
- Universal default attributes, just remove
users.
above and every record that has thepassword_digest
column will use the default. - Per-model factory-like helpers,
def users.labeled_email(label) = "#{label}@example.com"
which are automatically callable in tests too.
Have you used @kaspth ‘s Oaken?https://t.co/M7hPkKDDvd
— Julián Pinzón Eslava (@pinzonjulian) July 22, 2024
I tried it on a small project and it felt dead easy to use and maintain. I’ve yet to use it on something large though.
Please try out Oaken. I've had it working well in small-to-medium apps, but we need more users to see how it holds up in the longer term.
We're only scratching the surface with Oaken, and I've got a bunch more things I want to explore to help keep your small Rails app's better competitive edge — in the way that only Ruby can.
For instance, we're looking at adding an out of the box demo mode, that you can then execute via system tests and take marketing screenshots. You could then funnel those screenshots into a publishing pipeline and have things be automagically updated.
Thanks for reading! Go check out Oaken — the codebase is a fairly short read too.
Note: if you're on fixtures we've got a built-in converter to group fixtures by the root model, e.g. Account, Team or Organization. Run bin/rails generate oaken:convert:fixtures --root-model=Account
to get started.