DEV Community

Cover image for Building a Rails Engine #13 -- How to test a mountable Rails engine without a dummy app
Seryl Lns
Seryl Lns

Posted on

Building a Rails Engine #13 -- How to test a mountable Rails engine without a dummy app

Testing a Rails Engine with RSpec

How to test a mountable Rails engine without a dummy app -- just an in-memory SQLite database and 60 lines of spec_helper.

Context

This is part 13 of the series where we build DataPorter, a mountable Rails engine for data import workflows. In part 12, we extended the Source layer to support JSON files and API endpoints.

We have been writing specs throughout the series, but we never stepped back to explain how the test suite works. A Rails engine is not a Rails app. There is no config/database.yml. There is no spec/rails_helper.rb generated by rspec-rails. There is no schema to load, no test database to create, no ApplicationController to inherit from. Every piece of infrastructure that a typical Rails project gives you for free -- the database connection, the load paths, the base controller class -- has to be set up manually in a gem's test suite.

In this article, we look at the full testing strategy: the spec_helper that bootstraps a minimal Rails environment, the patterns we use for each layer (models, controllers, components, generators, JavaScript), and the TDD workflow that shaped the series.

The problem

A Rails engine gem ships as a library. It has no bin/rails, no config/application.rb, no running server. When you run rspec inside the gem, Ruby loads your spec_helper.rb and your spec files -- and that is it. If your code depends on ActiveRecord, you need a database. If it depends on ActionController, you need a controller base class. If it depends on load paths for app/models or app/controllers, you need to set those up yourself.

The conventional solution is a "dummy app" -- a minimal Rails application inside spec/dummy/ that boots the full Rails stack, mounts the engine, and provides the infrastructure specs need. This works, but it comes with overhead: you maintain a separate Gemfile.lock, a database config, an application class, route files, and all the boilerplate of a Rails app that exists only for tests. When the engine is small and focused, that overhead feels disproportionate.

DataPorter takes a different approach: no dummy app. The spec_helper.rb bootstraps just enough Rails to make the specs work, and nothing more. No application class. No route loading. No middleware stack. Just the frameworks we actually use -- ActiveRecord, ActiveJob, ActionController, ActionCable -- configured at the minimum viable level.

What we are building

Here is the spec_helper that powers the entire test suite:

# spec/spec_helper.rb
require "rails"
require "active_record"
require "active_job"
require "action_controller"
require "action_cable"
require "data_porter"

ActiveRecord::Base.establish_connection(
  adapter: "sqlite3",
  database: ":memory:"
)

ActiveRecord::Schema.define do
  create_table :data_porter_imports, force: true do |t|
    t.string  :target_key,  null: false, default: ""
    t.string  :source_type, null: false, default: "csv"
    t.integer :status,      null: false, default: 0
    t.text    :records
    t.text    :report
    t.text    :config

    t.string  :user_type
    t.integer :user_id

    t.timestamps
  end
end

%w[models jobs].each do |dir|
  $LOAD_PATH.unshift File.expand_path("../app/#{dir}", __dir__)
end
require "data_porter/data_import"
require "data_porter/parse_job"
require "data_porter/import_job"

# Stub for controller inheritance in test context
class ApplicationController < ActionController::Base; end unless defined?(ApplicationController)

$LOAD_PATH.unshift File.expand_path("../app/controllers", __dir__)
require "data_porter/imports_controller"

$LOAD_PATH.unshift File.expand_path("../app/channels", __dir__)
require "data_porter/import_channel"

$LOAD_PATH.unshift File.expand_path("../lib", __dir__)

RSpec.configure do |config|
  config.example_status_persistence_file_path = ".rspec_status"
  config.disable_monkey_patching!

  config.expect_with :rspec do |c|
    c.syntax = :expect
  end
end
Enter fullscreen mode Exit fullscreen mode

Sixty lines of setup that replace a 200-file dummy app. Every line exists because a spec somewhere needs it.

Implementation

Step 1 -- The in-memory database

The first problem is the database. DataPorter's DataImport model is an ActiveRecord class that needs a real table with real columns. In a Rails app, you would run rails db:create db:migrate. In a gem, there is no migration runner. We need to create the schema from scratch every time the specs run.

SQLite's :memory: database solves this perfectly. Look at the first block in the spec_helper above: establish_connection creates a database that lives entirely in memory -- no file on disk, no cleanup needed, no state leaking between test runs. The Schema.define block creates the table immediately. By the time the first spec runs, the database exists with the right schema.

The schema here mirrors the migration that the install generator creates for host apps, but it is defined inline rather than loaded from a migration file. This is a deliberate duplication: the spec_helper schema is the test contract, not the migration itself. If the migration changes and we forget to update the spec_helper, a spec will fail -- which is exactly the signal we want.

The text columns for records, report, and config are where StoreModel does its work. In production with PostgreSQL, these would be jsonb columns. In the test suite with SQLite, they are text columns -- and StoreModel handles the serialization transparently in both cases. This is one of the advantages of StoreModel over raw JSONB: the serialization adapter abstracts the column type difference.

Step 2 -- Load paths and the ApplicationController stub

A Rails engine's code lives in app/models, app/controllers, app/jobs, and app/channels. In a running Rails app, the autoloader resolves these paths. In a gem's test suite, there is no autoloader. We add the directories to $LOAD_PATH manually and require each file explicitly (see the middle section of the spec_helper above). Without this, require "data_porter/data_import" would fail because Ruby does not know to look in app/models.

The controller layer is trickier. ImportsController inherits from the host app's ApplicationController (or whatever DataPorter.configuration.parent_controller is set to). In a host Rails app, that class already exists. In the gem's test suite, it does not. We stub it:

class ApplicationController < ActionController::Base; end unless defined?(ApplicationController)
Enter fullscreen mode Exit fullscreen mode

The unless defined? guard is important. If something else in the test environment has already defined ApplicationController (which happens in some CI setups), we do not want to redefine it. The stub gives us just enough to verify that ImportsController is loadable, inherits from the right parent, and defines the expected methods. It does not give us routing or request processing -- but we do not need those for the kind of controller testing we do.

Step 3 -- Testing models and StoreModel objects

The DataImport model uses StoreModel for its records, report, and config attributes. The model specs test validations, enum behavior, and persistence:

# spec/data_porter/data_import_spec.rb
RSpec.describe DataPorter::DataImport, type: :model do
  let(:target_class) do
    Class.new(DataPorter::Target) do
      label "Guests"
      model_name "Guest"
      icon "fas fa-users"
    end
  end

  before do
    DataPorter::Registry.clear
    DataPorter::Registry.register(:guests, target_class)
  end

  describe "validations" do
    it "requires target_key" do
      import = described_class.new(source_type: "csv")

      expect(import).not_to be_valid
      expect(import.errors[:target_key]).to include("can't be blank")
    end
  end

  describe "persistence" do
    it "saves and reloads with records" do
      import = described_class.create!(
        target_key: "guests",
        source_type: "csv",
        user_type: "User",
        user_id: 1
      )
      record = DataPorter::StoreModels::ImportRecord.new(
        line_number: 1,
        data: { name: "Alice" }
      )
      import.update!(records: [record])

      reloaded = described_class.find(import.id)

      expect(reloaded.records.first.line_number).to eq(1)
      expect(reloaded.records.first.data).to eq({ "name" => "Alice" })
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Two things stand out. First, every spec that depends on a target class creates an anonymous class with Class.new(DataPorter::Target) and registers it in the before block. This avoids polluting the global namespace with named test classes and ensures each spec controls its own target definition. The Registry.clear in before prevents target registrations from one spec leaking into another.

Second, the persistence spec exercises the full StoreModel round-trip: create a DataImport, add ImportRecord objects to the records attribute, save, reload from the database, and verify the data survived serialization. This is critical because StoreModel serializes to JSON under the hood, and the round-trip through SQLite's text column can surface subtle issues -- symbol keys becoming string keys, for instance, which is exactly what { "name" => "Alice" } (not { name: "Alice" }) is testing.

The ImportRecord StoreModel type has no table -- it lives inside a DataImport's records array. Testing it requires no database at all: instantiate with a hash, call methods, assert on state. The key spec verifies that #attributes returns symbolized, compacted data ({ name: "Alice" } not { "name" => "Alice", "email" => nil }), because the target's persist method depends on this contract.

Step 4 -- Testing controllers structurally

In a full Rails app, you would test controllers with request specs: send a POST, check the response status, verify the redirect. In a gem without a dummy app, there is no router, no middleware stack, and no request processing. We cannot send HTTP requests because there is nowhere to send them.

Instead, we test controllers structurally -- verifying the class hierarchy, the callback registrations, and the method signatures:

# spec/data_porter/imports_controller_spec.rb
RSpec.describe DataPorter::ImportsController do
  describe "inheritance" do
    it "inherits from the configured parent controller" do
      expect(described_class.superclass.name).to eq(
        DataPorter.configuration.parent_controller
      )
    end
  end

  describe "before_actions" do
    it "registers set_import callback" do
      callbacks = described_class._process_action_callbacks.select do |c|
        c.filter == :set_import
      end

      expect(callbacks).not_to be_empty
    end
  end

  describe "action methods" do
    it "defines index, new, create, show, parse, confirm, and cancel" do
      actions = %i[index new create show parse confirm cancel]
      actions.each do |action|
        expect(described_class.instance_method(action)).to be_a(UnboundMethod)
      end
    end
  end

  describe "private methods" do
    it "defines set_import" do
      expect(described_class.private_instance_methods).to include(:set_import)
    end

    it "defines import_params" do
      expect(described_class.private_instance_methods).to include(:import_params)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

This approach tests four things that matter at the gem level: (1) the controller inherits from the right parent class, which proves the dynamic inheritance mechanism works; (2) the before_action callbacks are registered, which proves the controller's lifecycle is set up correctly; (3) every expected action method exists as an instance method; (4) the private helper methods exist and are private.

What this does not test is the actual behavior of those actions -- what happens when you call create with certain params, or whether show renders the right component. Those are integration concerns that belong in the host app's test suite, or in a separate integration test suite with a dummy app. The gem-level specs verify the contract -- the controller exists, has the right shape, and plugs into the right place. The host app tests verify the behavior.

The _process_action_callbacks API is an internal Rails method that returns the registered callbacks. It is not a public API, but it is stable across Rails versions and it is the only way to verify callback registration without actually processing a request. Using it here is a pragmatic choice -- the alternative is a full request spec, which requires a dummy app.

Step 5 -- Testing Phlex components

Phlex components are the easiest layer to test in a gem context because they have zero Rails dependencies. A Phlex component is a Ruby object with a call method that returns an HTML string. No view context, no request, no controller:

# spec/data_porter/components/preview_table_spec.rb
RSpec.describe DataPorter::Components::PreviewTable do
  def render(component)
    component.call
  end

  let(:target_class) do
    Class.new(DataPorter::Target) do
      label "Guests"
      model_name "Guest"
      columns do
        column :first_name, type: :string, required: true
        column :last_name, type: :string
      end
    end
  end

  let(:record) do
    DataPorter::StoreModels::ImportRecord.new(
      line_number: 1, status: "complete",
      data: { "first_name" => "Alice", "last_name" => "Smith" }
    )
  end

  it "renders column headers from target" do
    html = render(described_class.new(columns: target_class._columns, records: [record]))

    expect(html).to include("First name")
    expect(html).to include("Last name")
  end
end
Enter fullscreen mode Exit fullscreen mode

The render helper is just component.call. The assertions are string-based (include("Alice")) rather than DOM-based (have_css("td", text: "Alice")) -- no Capybara, no Nokogiri. String matching is sufficient for verifying content in component output, and it keeps the test dependencies minimal.

Step 6 -- Testing generators

Generator specs follow the same structural pattern as controllers: verify inheritance, source root, and method definitions. We covered the full walkthrough in part 11 -- the key insight is that we test the generator class, not its filesystem output, because running rails generate would require a dummy app. This catches the most common bugs: misspelled method names, wrong source roots, or missing base classes.

Step 7 -- Testing JavaScript from Ruby specs

DataPorter ships a Stimulus controller in app/javascript/data_porter/progress_controller.js. In a typical frontend project, you would test this with Jest or Vitest, running the JavaScript in a Node.js environment. But DataPorter is a Ruby gem. Adding a JavaScript test runner means adding package.json, node_modules, and a separate CI step. For a single 30-line Stimulus controller, that is a lot of overhead.

Instead, we test the JavaScript file as a text artifact from Ruby:

# spec/data_porter/progress_controller_js_spec.rb
RSpec.describe "progress_controller.js" do
  let(:content) { File.read(js_path) }

  it "imports Stimulus Controller" do
    expect(content).to include('import { Controller } from "@hotwired/stimulus"')
  end

  it "subscribes to ImportChannel on connect" do
    expect(content).to include("DataPorter::ImportChannel")
  end

  it "updates progress bar width and text" do
    expect(content).to include("this.barTarget.style.width")
  end
end
Enter fullscreen mode Exit fullscreen mode

Read the file as a string, assert it contains the expected patterns. No JavaScript execution, no Node.js toolchain. This catches a specific class of bugs: someone renames the ActionCable channel in Ruby but forgets to update the JS subscription string, or removes a Stimulus target from the HTML but leaves the declaration in the controller. These are integration seams where Ruby and JavaScript must agree, and string matching catches disagreements.

Is this a substitute for real JS testing? No. But for a single 30-line Stimulus controller, it provides meaningful coverage without adding package.json and node_modules to the project.

Step 8 -- The anonymous target class pattern

A pattern that runs through nearly every spec file is the anonymous target class:

let(:target_class) do
  Class.new(DataPorter::Target) do
    label "Guests"
    model_name "Guest"
    icon "fas fa-users"
    sources :csv

    columns do
      column :first_name, type: :string, required: true
      column :last_name, type: :string
      column :email, type: :email
    end

    csv_mapping do
      map "First Name" => :first_name
      map "Last Name" => :last_name
      map "Email" => :email
    end

    define_method(:persist) do |record, **|
      records << record.data
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

This creates a new class that inherits from DataPorter::Target without giving it a constant name. Each spec defines exactly the target it needs -- the Orchestrator spec defines one with persist, the CSV spec defines one with csv_mapping, the PreviewTable spec defines one with just columns. No spec depends on a shared target fixture that might change.

The define_method(:persist) pattern (instead of def persist) is required because anonymous class blocks use Class.new with a block, and def inside that block defines a method on the class -- but if the method needs to capture a local variable (like records for the collection array), define_method with a closure is the only way. This is a Ruby metaprogramming detail that comes up often when testing DSL-heavy code.

The companion pattern is registry cleanup:

before do
  DataPorter::Registry.clear
  DataPorter::Registry.register(:guests, target_class)
end

after { DataPorter::Registry.clear }
Enter fullscreen mode Exit fullscreen mode

Every spec that registers a target clears the registry before and after. Without this, target registrations accumulate across specs, leading to flaky tests where the order of execution matters. The clear method on the Registry exists primarily for this testing use case.

Decisions & tradeoffs

Decision We chose Over Because
Test infrastructure Inline spec_helper with in-memory SQLite Dummy Rails app in spec/dummy/ Less maintenance overhead, faster boot time, no separate Gemfile or config to maintain
Controller testing Structural tests (inheritance, callbacks, method existence) Request specs with a dummy app Verifies the contract without requiring routing, middleware, or a full request cycle
Component testing component.call with string assertions Capybara or Nokogiri DOM assertions Minimal dependencies; string matching is sufficient for verifying content in component output
Generator testing Class-level structural tests Running the generator against a temp directory Catches wiring bugs without requiring a writable Rails app filesystem
JavaScript testing Ruby string matching on file content Jest/Vitest with a Node.js test runner Avoids adding a JS toolchain for a single 30-line file; tests the Ruby/JS integration contract
Target fixtures Anonymous classes per spec with Class.new Shared named target classes Each spec controls its own target definition; no coupling between specs
Registry isolation clear before and after each spec that registers targets Global shared state Prevents registration leakage and ordering-dependent test failures
StoreModel testing Direct instantiation without database Round-trip through ActiveRecord StoreModel objects are plain Ruby objects; database round-trips are tested separately on the DataImport model

The TDD workflow

In a Rails app, you can spike code, hit refresh, and see if it works. In a gem, there is no browser -- the spec suite is the feedback loop. Every feature in this series followed the same cycle: write a spec that describes the behavior, watch it fail, implement the minimum code to make it pass. The anonymous target classes, the structural controller tests, the JS string matching -- all of these patterns emerged from necessity, not theory. When you have no dummy app and no browser, you find the simplest way to verify each layer's contract, and that simplicity turns out to be an advantage.

Recap

  • No dummy app: the spec_helper bootstraps an in-memory SQLite database, sets up load paths, and stubs ApplicationController -- just enough Rails to test a full engine.
  • StoreModel objects are tested as plain Ruby objects, with database round-trip tests on the parent ActiveRecord model.
  • Controllers are tested structurally -- inheritance, callbacks, and method existence -- rather than with request specs, because the gem has no router.
  • Phlex components are tested by calling .call and asserting against the HTML string, with no view context or browser.
  • Generators are tested by verifying class hierarchy, source root, and method definitions -- the wiring, not the filesystem output.
  • JavaScript files are tested as text artifacts from Ruby, verifying the contract between the JS and the rest of the engine.
  • Anonymous target classes with Class.new keep each spec self-contained, and Registry.clear prevents state leakage.

Next up

Now that we have a tested, fully-featured engine, there is one more safety net to add before imports touch the database. In part 14, we build Dry Run -- a validation mode that wraps the import in a transaction, rolls it back, and enriches records with the database-level errors that the preview phase cannot catch. Preview validates the data; dry run validates the persistence.


This is part 13 of the series "Building DataPorter - A Data Import Engine for Rails". Previous: Adding JSON & API Sources | Next: Dry Run: Validate Before You Persist


GitHub: SerylLns/data_porter | RubyGems: data_porter

Top comments (0)