Full Stack Hanami, Part 3
Not dogma, just what I'm learning and thinking about right now.
Comments and feedback are welcome on Mastodon.
"If you're thinking without writing, then you just think you're thinking."
—Leslie Lamport
Welcome to Part 3 of Full Stack Hanami, where we’ll look at how to use our database in testing.
- Part 1 - Adding ROM. Covers the gems we need and how to get them working with Hanami 2.
- Part 2 - Working with ROM. Covers relations, repos, and database chores (creation, migrations, etc.).
- Part 3 - Testing. Set up tooling to use your database in RSpec.
These guides are derived from the examples contained in the Hanami docs, Tim Riley’s Decaf Sucks project, and Brooke Kuhlmann’s Hemo demo application. I am grateful to those authors for providing such excellent examples to follow!
In This Article
This article is based entirely on Tim Riley’s DecafSucks example project. Here are the topics we’ll cover in this article.
- Recap database setup and ENV.
- Migrating the test database.
- Create
spec/support/feature_loader.rb
. - Create
spec/support/db.rb
. - Create
spec/support/db/helpers.rb
. - Create
spec/support/db/database_cleaner.rb
. - Create
spec/support/db/factory.rb
. - Writing tests using the database.
If you haven’t read Part 1 or Part 2 of this series, please check them out now to get up to speed on the dependencies required to follow along and the steps we’ve taken so far.
1. Recap Database Setup and ENV.
In Part 1 of this series we set up a .env.test
file to provide a database connection string to be used by our app when running tests. Later, we ran the command HANAMI_ENV=test bin/hanami db setup
from the command line. This command created our test database that we will use when testing our app. Unlike our development database, the test database is not typically seeded with data. Instead, each test (or set of tests) will populate the database with the needed data, and then wipe the data when the tests are complete.
2. Migrating the test database.
Just as we run migrations to update our development database, we need to do the same to keep our test database up to date and structurally in sync with the development database. We can use the following command to run any pending migrations against the test database:
HANAMI_ENV=test bin/hanami db migrate
We need to run this command whenever we add new migrations before running our tests.
3. Create spec/support/feature_loader.rb
.
If you are familiar with Rails and testing with RSpec, then you are probably aware of the spec_helper.rb
and rails_helper.rb
files used to configure dependencies used in testing. Hanami also has a spec_helper.rb
file that performs a similar function. However, Tim takes a different approach in DecafSucks that we will be exploring here. Tim’s strategy is to combine the “MagicCommentParser” feature of dry-system (one of the gems upon which Hanami is built) with a RSpec callback mechanism to selectively load support dependencies as needed in each test file. It’s really kind of neat. Let’s get it implemented and then discuss how it works.
First, we will create a new file, spec/support/feature_loader.rb
, with the following contents:
01 # frozen_string_literal: true
02
03 require "dry/system"
04
05 RSpec.configure do |config|
06 Dir[File.join(__dir__, "*.rb")].sort.each do |file|
07 options = Dry::System::MagicCommentsParser.call(file)
08 tag_name = options[:require_with_metadata]
09
10 next unless tag_name
11
12 tag_name = File.basename(file, File.extname(file)) if tag_name.eql?(true)
13
14 config.when_first_matching_example_defined(tag_name.to_sym) do
15 require file
16 end
17 end
18 end
This file will:
- Loop through all of the Ruby files in
spec/support
(lines 06-17); - Find any that begin with the magic comment
# require_with_metadata: true
(tag name specified on 08, checked for presence on 10, and verified as true on 12); - Notes the file’s name (line 12);
- Creates a RSpec callback that will target the symbolized version of the file name (i.e.,
:db
fordb.rb
, on line 14); and - Assigns a block to the RSpec callback that will execute
require 'db'
the first time:db
is encountered in the metadata of a RSpecdescribe
block (lines 14-16).
That’s a lot to take in. If any of it doesn’t make sense to you, we’ll see a concrete example later in the article.
Next, in order to make the feature loader available in all of our test files, we will require it in our spec/spec_helper.rb
file (that was generated with hanami new
). Add the following line at the end of the file, immediately following the current contents.
# spec/spec_helper.rb
# ... original contents above this line
require_relative "support/feature_loader"
4. Create spec/support/db.rb
.
With our feature loader in place, we can now create arbitrary support files to load expensive dependencies–and include them only as needed in our test files–by specifying one or more symbols in the test setup. For example, in DecafSucks, Tim set up a :web
metadata tag to load Capybara for doing end-to-end tests, and a :db
tag for tests that need to access ROM. Let’s follow his example to get our database testing support file in place. Here is his db
support file. It should be saved to spec/support/db.rb
.
01 # require_with_metadata: true
02 # frozen_string_literal: true
03
04 require_relative "db/helpers"
05 require_relative "db/database_cleaner"
06 require_relative "db/factory"
07
08 RSpec.configure do |config|
09 config.before :suite do
10 Hanami.app.start :persistence
11 end
12
13 config.include Test::DB::Helpers, :db
14
15 # Configure per-slice factory helpers
16 Dir[SPEC_ROOT.join("slices", "*")].each do |slice_dir|
17 slice_name = File.basename(slice_dir).to_sym
18
19 config.define_derived_metadata(db: true, file_path: Regexp.escape(slice_dir)) do |metadata|
20 metadata[:factory] = slice_name
21 end
22
23 config.include(Test::DB::FactoryHelper.new(slice_name), factory: slice_name)
24 end
25 config.include(Test::DB::FactoryHelper.new, factory: nil)
26 end
As you can see, the file begins with our magic comment: require_with_metadata: true
(line 01). This is detected by the feature loader and causes this file to be executed whenever the symbol :db
is included in the metadata of a RSpec test. Lines 04-06 load the remaining files that we’ll be creating next in this tutorial. On line 10 we tell RSpec to fire up our app’s persistence provider. Line 13 tells RSpec to automatically include the Test::DB::Helpers
module contained in spec/support/db/helpers.rb
. The remainder of the file configures the factory helper (created below) to work with Hanami’s slices architecture.
5. Create spec/support/db/helpers.rb
.
The purpose of helpers.rb
is just to provide easy accessors to the basic ROM objects we may need in our tests. Create the following file and save it to spec/support/db/helpers.rb
.
module Test
module DB
module Helpers
module_function
def relations
rom.relations
end
def rom
Hanami.app["persistence.rom"]
end
def db
Hanami.app["persistence.db"]
end
end
end
end
This module should be self-explanatory. It includes the methods #relations
, #rom
, and #db
to provide easy access to the indicated objects from within tests.
6. Create spec/support/db/database_cleaner.rb
.
The purpose of the database cleaner file is to return the test database to an empty state between tests. We don’t want any data from previous tests hanging around to muck up later tests. This file should be saved to spec/support/db/database_cleaner.rb
.
01 require "database_cleaner/sequel"
02 require_relative "helpers"
03
04 DatabaseCleaner[:sequel].strategy = :transaction
05
06 RSpec.configure do |config|
07 config.before :suite do
08 DatabaseCleaner[:sequel].clean_with :truncation
09 end
10
11 config.prepend_before :each, :db do |example|
12 strategy = example.metadata[:js] ? :truncation : :transaction
13 DatabaseCleaner[:sequel].strategy = strategy
14
15 DatabaseCleaner[:sequel].start
16 end
17
18 config.append_after :each, :db do
19 DatabaseCleaner[:sequel].clean
20 end
21 end
This is pretty standard stuff for the DatabaseCleaner gem (meaning, it would look about the same in Rails or Sinatra or whatever). Let’s take a quick look to make sure we understand. Line 04 sets the default cleaning strategy to “transaction.” This is usually done in the config.before :suite
block, beginning on Line 7. I’m not sure why Tim chose to move this line to the top of the file. Perhaps it has something to do with the conditional on Line 12.
Line 08 tells DatabaseCleaner to “truncate” the contents of the test database before running any tests. This essentially means to throw away all rows in all tables.
The block on Lines 11-16 tells DatabaseCleaner what to do before each test that includes the :db
metadata symbol. Line 12 configures DatabaseCleaner to use the “truncation” strategy to rollback changes between tests if the :js
metadata symbol is also present, or the “transaction” strategy if it is not. Clearly Tim is looking ahead to testing JavaScript in DecafSucks. I left these lines in this example for learning purposes. You could delete lines 12-13 and the script would work just fine (using the transaction strategy specified on line 04).
Line 15 then sets the starting point for DatabaseCleaner before each test is run, and Line 19 triggers the actual cleaning after each test is run.
7. Create spec/support/db/factory.rb
.
Before we begin, I want to make it clear that this file is not required. The example file I show here is taken directly from DecafSucks and incorporates some assumptions that are specific to that project (for instance, the assumption that we will use factories at all to generate data for testing). Nevertheless, I present it here as an example of how you might implement factories in your tests. Note that the factory library used here is from ROM (rom-factory).
The primary assumption baked into this script is that each slice in the application (including the app
directory itself, which is really just a special slice) will contain an entities
directory. ROM will look to these directories for the entity objects it will instantiate for testing. Factory definitions for each entity to be generated in your tests are placed in the spec/factories/
directory. Factory definitions for entities that live in your app
directory will be placed in the root of this directory, while slice-based entities will be namespaced in directories that mirror your slices
directory structure. You can learn the basics of rom-factory from the ROM documentation.
01 require "rom-factory"
02 require_relative "helpers"
03
04 module Test
05 Factory = ROM::Factory.configure { |config|
06 config.rom = Test::DB::Helpers.rom
07 }
08
09 module DB
10 class FactoryHelper < Module
11 ENTITIES_MODULE_NAME = :Entities
12
13 attr_reader :slice_name
14
15 def initialize(slice_name = nil)
16 @slice_name = slice_name
17
18 factory = entity_namespace ? Test::Factory.struct_namespace(entity_namespace) : Factory
19
20 define_method(:factory) do
21 factory
22 end
23 end
24
25 private
26
27 def entity_namespace
28 return @entity_namespace if instance_variable_defined?(:@entity_namespace)
29
30 slice = slice_name ? Hanami.app.slices[slice_name] : Hanami.app
31 slice_namespace = slice.namespace
32
33 @entity_namespace =
34 if slice_namespace.const_defined?(ENTITIES_MODULE_NAME)
35 slice_namespace.const_get(ENTITIES_MODULE_NAME)
36 end
37 end
38 end
39 end
40 end
41
42 # Patch rom-factory to use our app inflector
43 #
44 # TODO(rom-factory): Support a configurable inflector
45 class ROM::Factory::Factories
46 module Fixes
47 def infer_factory_name(name)
48 Hanami.app["inflector"].singularize(name).to_sym
49 end
50
51 def infer_relation(name)
52 Hanami.app["inflector"].pluralize(name).to_sym
53 end
54 end
55
56 prepend Fixes
57 end
58
59 Dir[SPEC_ROOT.join("factories/**/*.rb")].each { require(_1) }
Lines 09-40 do all of the magic in deducing the proper namespace for each factory based on a passed-in slice name. This bit of wizardry tells the factory where to find both the factory definition and the entity class definition. It’s worth noting that Tim included the code on lines 45-57 to patch rom-factory to use the custom inflector he created within DecafSucks (to prevent the default inflector from pluralizing ‘cafe’ to ‘caves’). :) This portion is probably not required in your app. Should you omit it, take care to preserve line 59, which is still needed to locate the factory definition files.
Let’s look briefly at the contents of a factory definition file. These files are used to configure the values generated for each attribute of a factory-generated entity. For example, to make a factory for our Book
class, we should create a factory definition file like the following and save it as spec/factories/book.rb
.
# frozen_string_literal: true
Test::Factory.define(:book) do |f|
f.title { fake(:book, :title) }
f.association(:authors, count: 1)
end
Under the hood, rom-factory uses the popular Faker gem to generate data. Faker is invoked with the fake
method, as illustrated above, with the names of the desired Faker generator and attribute passed as symbols. Since we also expect our books to have an author, the Book factory specifies an association with :authors
. Authors is plural because this is a has-many relationship, although we only specify one author in this factory. We could easily create another factory to create Book entities with multiple authors.
To make this association work properly, we also need a factory for Author
. This file should be saved to spec/factories/author.rb
.
# frozen_string_literal: true
Test::Factory.define(:author) do |f|
f.name { "#{fake(:name, :last_name)}, #{fake(:name, :first_name)}" }
end
Following is a more comprehensive example of a factory definition file from DecafSucks showing many more generators in use in combination with manually generated data.
# frozen_string_literal: true
Test::Factory.define(:cafe) do |f|
f.name { "#{fake(:coffee, :blend_name)} Cafe" }
f.name_dmetaphone { |name| "cafe" } # TODO: add real double metaphone support
f.address { fake(:address, :full_address) }
f.lat { fake(:address, :latitude) }
f.lng { fake(:address, :longitude) }
f.rating { 1.upto(10).to_a.sample }
f.created_at { Time.now }
end
8. Writing tests using the database.
With all of the pieces in place, we can now run tests that have full access to our database layer. As an example, lets write a test for our Book
repository. We will test two aspects of the #all
method: that it returns an empty array when the test database is empty, and it returns an array of book records when the test database holds data. Save the following file to spec/repositories/book_spec.rb
. You will have to create the spec/repositories/
directory.
01 # frozen_string_literal: true
02
03 RSpec.describe Fullstack::Repositories::Book, :db do
04 subject(:repository) { described_class.new }
05
06 let(:book) { Test::Factory[:book] }
07
08 describe "#all" do
09 it "returns empty array when records don't exist" do
10 expect(repository.all).to eq([])
11 end
12
13 it "returns all records" do
14 book
15 expect(repository.all).to contain_exactly(book)
16 end
17 end
18 end
Let’s see how this test file brings everything together. Line 03 describes the class under test. More importantly, we pass the :db
symbol as “metadata” to RSpec. This triggers the callback we created in spec/support/feature_loader.rb
(on lines 14-16). When RSpec sees the :db
symbol, it looks for the file spec/support/db.rb
and loads it, providing our test with access to our database layer.
Line 04 provides a new instance of the Book
repository as the subject of each subsequent test. Line 06 provides a book
method that, when called in the tests, will use the book
factory to generate and persist a new book entity.
Lines 09-11 test that #all
returns an empty array when the test database is empty. Remember that DatabaseCleaner is invoked before the test suite to empty the test database, and is also invoked before and after each test case to return the test database to an empty state before each test is run. Since we do not invoke the book
factory method, the test database remains empty for this test.
Lines 13-16 calls book
to populate the test database with a single factory-generated book, and then verifies that #all
returns an array containing only that book.
And that’s it! Run your tests!
> bundle exec rake
If all of your tests are green, you’ve fully implemented persistence in Hanami 2 with ROM-rb! Congratulations!
Wrap up
That was a long trip. I hope you enjoyed it as much as I did! Please send comments and corrections to me on Mastodon using the link at the top of the page.
Thank you for reading!