Seeking Opportunities
Contact Me

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.

  1. Recap database setup and ENV.
  2. Migrating the test database.
  3. Create spec/support/feature_loader.rb.
  4. Create spec/support/db.rb.
  5. Create spec/support/db/helpers.rb.
  6. Create spec/support/db/database_cleaner.rb.
  7. Create spec/support/db/factory.rb.
  8. 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 for db.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 RSpec describe 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!