data_porter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.claude/commands/blog-status.md +10 -0
- data/.claude/commands/blog.md +109 -0
- data/.claude/commands/task-done.md +27 -0
- data/.claude/commands/tm/add-dependency.md +58 -0
- data/.claude/commands/tm/add-subtask.md +79 -0
- data/.claude/commands/tm/add-task.md +81 -0
- data/.claude/commands/tm/analyze-complexity.md +124 -0
- data/.claude/commands/tm/analyze-project.md +100 -0
- data/.claude/commands/tm/auto-implement-tasks.md +100 -0
- data/.claude/commands/tm/command-pipeline.md +80 -0
- data/.claude/commands/tm/complexity-report.md +120 -0
- data/.claude/commands/tm/convert-task-to-subtask.md +74 -0
- data/.claude/commands/tm/expand-all-tasks.md +52 -0
- data/.claude/commands/tm/expand-task.md +52 -0
- data/.claude/commands/tm/fix-dependencies.md +82 -0
- data/.claude/commands/tm/help.md +101 -0
- data/.claude/commands/tm/init-project-quick.md +49 -0
- data/.claude/commands/tm/init-project.md +53 -0
- data/.claude/commands/tm/install-taskmaster.md +118 -0
- data/.claude/commands/tm/learn.md +106 -0
- data/.claude/commands/tm/list-tasks-by-status.md +42 -0
- data/.claude/commands/tm/list-tasks-with-subtasks.md +30 -0
- data/.claude/commands/tm/list-tasks.md +46 -0
- data/.claude/commands/tm/next-task.md +69 -0
- data/.claude/commands/tm/parse-prd-with-research.md +51 -0
- data/.claude/commands/tm/parse-prd.md +52 -0
- data/.claude/commands/tm/project-status.md +67 -0
- data/.claude/commands/tm/quick-install-taskmaster.md +23 -0
- data/.claude/commands/tm/remove-all-subtasks.md +94 -0
- data/.claude/commands/tm/remove-dependency.md +65 -0
- data/.claude/commands/tm/remove-subtask.md +87 -0
- data/.claude/commands/tm/remove-subtasks.md +89 -0
- data/.claude/commands/tm/remove-task.md +110 -0
- data/.claude/commands/tm/setup-models.md +52 -0
- data/.claude/commands/tm/show-task.md +85 -0
- data/.claude/commands/tm/smart-workflow.md +58 -0
- data/.claude/commands/tm/sync-readme.md +120 -0
- data/.claude/commands/tm/tm-main.md +147 -0
- data/.claude/commands/tm/to-cancelled.md +58 -0
- data/.claude/commands/tm/to-deferred.md +50 -0
- data/.claude/commands/tm/to-done.md +47 -0
- data/.claude/commands/tm/to-in-progress.md +39 -0
- data/.claude/commands/tm/to-pending.md +35 -0
- data/.claude/commands/tm/to-review.md +43 -0
- data/.claude/commands/tm/update-single-task.md +122 -0
- data/.claude/commands/tm/update-task.md +75 -0
- data/.claude/commands/tm/update-tasks-from-id.md +111 -0
- data/.claude/commands/tm/validate-dependencies.md +72 -0
- data/.claude/commands/tm/view-models.md +52 -0
- data/.env.example +12 -0
- data/.mcp.json +24 -0
- data/.taskmaster/CLAUDE.md +435 -0
- data/.taskmaster/config.json +44 -0
- data/.taskmaster/docs/prd.txt +2044 -0
- data/.taskmaster/state.json +6 -0
- data/.taskmaster/tasks/task_001.md +19 -0
- data/.taskmaster/tasks/task_002.md +19 -0
- data/.taskmaster/tasks/task_003.md +19 -0
- data/.taskmaster/tasks/task_004.md +19 -0
- data/.taskmaster/tasks/task_005.md +19 -0
- data/.taskmaster/tasks/task_006.md +19 -0
- data/.taskmaster/tasks/task_007.md +19 -0
- data/.taskmaster/tasks/task_008.md +19 -0
- data/.taskmaster/tasks/task_009.md +19 -0
- data/.taskmaster/tasks/task_010.md +19 -0
- data/.taskmaster/tasks/task_011.md +19 -0
- data/.taskmaster/tasks/task_012.md +19 -0
- data/.taskmaster/tasks/task_013.md +19 -0
- data/.taskmaster/tasks/task_014.md +19 -0
- data/.taskmaster/tasks/task_015.md +19 -0
- data/.taskmaster/tasks/task_016.md +19 -0
- data/.taskmaster/tasks/task_017.md +19 -0
- data/.taskmaster/tasks/task_018.md +19 -0
- data/.taskmaster/tasks/task_019.md +19 -0
- data/.taskmaster/tasks/task_020.md +19 -0
- data/.taskmaster/tasks/tasks.json +299 -0
- data/.taskmaster/templates/example_prd.txt +47 -0
- data/.taskmaster/templates/example_prd_rpg.txt +511 -0
- data/CHANGELOG.md +29 -0
- data/CLAUDE.md +65 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/CONTRIBUTING.md +49 -0
- data/LICENSE +21 -0
- data/README.md +463 -0
- data/Rakefile +12 -0
- data/app/assets/stylesheets/data_porter/application.css +646 -0
- data/app/channels/data_porter/import_channel.rb +10 -0
- data/app/controllers/data_porter/imports_controller.rb +68 -0
- data/app/javascript/data_porter/progress_controller.js +33 -0
- data/app/jobs/data_porter/dry_run_job.rb +12 -0
- data/app/jobs/data_porter/import_job.rb +12 -0
- data/app/jobs/data_porter/parse_job.rb +12 -0
- data/app/models/data_porter/data_import.rb +49 -0
- data/app/views/data_porter/imports/index.html.erb +142 -0
- data/app/views/data_porter/imports/new.html.erb +88 -0
- data/app/views/data_porter/imports/show.html.erb +49 -0
- data/config/database.yml +3 -0
- data/config/routes.rb +12 -0
- data/docs/SPEC.md +2012 -0
- data/docs/UI.md +32 -0
- data/docs/blog/001-why-build-a-data-import-engine.md +166 -0
- data/docs/blog/002-scaffolding-a-rails-engine.md +188 -0
- data/docs/blog/003-configuration-dsl.md +222 -0
- data/docs/blog/004-store-model-jsonb.md +237 -0
- data/docs/blog/005-target-dsl.md +284 -0
- data/docs/blog/006-parsing-csv-sources.md +300 -0
- data/docs/blog/007-orchestrator.md +247 -0
- data/docs/blog/008-actioncable-stimulus.md +376 -0
- data/docs/blog/009-phlex-ui-components.md +446 -0
- data/docs/blog/010-controllers-routing.md +374 -0
- data/docs/blog/011-generators.md +364 -0
- data/docs/blog/012-json-api-sources.md +323 -0
- data/docs/blog/013-testing-rails-engine.md +618 -0
- data/docs/blog/014-dry-run.md +307 -0
- data/docs/blog/015-publishing-retro.md +264 -0
- data/docs/blog/016-erb-view-templates.md +431 -0
- data/docs/blog/017-showcase-final-retro.md +220 -0
- data/docs/blog/BACKLOG.md +8 -0
- data/docs/blog/SERIES.md +154 -0
- data/docs/screenshots/index-with-previewing.jpg +0 -0
- data/docs/screenshots/index.jpg +0 -0
- data/docs/screenshots/modal-new-import.jpg +0 -0
- data/docs/screenshots/preview.jpg +0 -0
- data/lib/data_porter/broadcaster.rb +29 -0
- data/lib/data_porter/components/base.rb +10 -0
- data/lib/data_porter/components/failure_alert.rb +20 -0
- data/lib/data_porter/components/preview_table.rb +54 -0
- data/lib/data_porter/components/progress_bar.rb +33 -0
- data/lib/data_porter/components/results_summary.rb +19 -0
- data/lib/data_porter/components/status_badge.rb +16 -0
- data/lib/data_porter/components/summary_cards.rb +30 -0
- data/lib/data_porter/components.rb +14 -0
- data/lib/data_porter/configuration.rb +25 -0
- data/lib/data_porter/dsl/api_config.rb +25 -0
- data/lib/data_porter/dsl/column.rb +17 -0
- data/lib/data_porter/engine.rb +15 -0
- data/lib/data_porter/orchestrator.rb +141 -0
- data/lib/data_porter/record_validator.rb +32 -0
- data/lib/data_porter/registry.rb +33 -0
- data/lib/data_porter/sources/api.rb +49 -0
- data/lib/data_porter/sources/base.rb +35 -0
- data/lib/data_porter/sources/csv.rb +43 -0
- data/lib/data_porter/sources/json.rb +45 -0
- data/lib/data_porter/sources.rb +20 -0
- data/lib/data_porter/store_models/error.rb +13 -0
- data/lib/data_porter/store_models/import_record.rb +52 -0
- data/lib/data_porter/store_models/report.rb +21 -0
- data/lib/data_porter/target.rb +89 -0
- data/lib/data_porter/type_validator.rb +46 -0
- data/lib/data_porter/version.rb +5 -0
- data/lib/data_porter.rb +32 -0
- data/lib/generators/data_porter/install/install_generator.rb +33 -0
- data/lib/generators/data_porter/install/templates/create_data_porter_imports.rb.erb +21 -0
- data/lib/generators/data_porter/install/templates/initializer.rb +30 -0
- data/lib/generators/data_porter/target/target_generator.rb +44 -0
- data/lib/generators/data_porter/target/templates/target.rb.tt +20 -0
- data/sig/data_porter.rbs +4 -0
- metadata +274 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Building DataPorter #13 -- Testing a Rails Engine with RSpec"
|
|
3
|
+
series: "Building DataPorter - A Data Import Engine for Rails"
|
|
4
|
+
part: 13
|
|
5
|
+
tags: [ruby, rails, rails-engine, gem-development, rspec, testing, tdd]
|
|
6
|
+
published: false
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Testing a Rails Engine with RSpec
|
|
10
|
+
|
|
11
|
+
> How to test a mountable Rails engine without a full Rails app -- using an in-memory SQLite database, stub controllers, anonymous target classes, and a spec_helper that bootstraps just enough Rails to exercise ActiveRecord, StoreModel, Phlex, generators, and even JavaScript files.
|
|
12
|
+
|
|
13
|
+
## Context
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
## The problem
|
|
22
|
+
|
|
23
|
+
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.
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
|
|
27
|
+
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.
|
|
28
|
+
|
|
29
|
+
## What we're building
|
|
30
|
+
|
|
31
|
+
Here is the spec_helper that powers the entire test suite:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
# spec/spec_helper.rb
|
|
35
|
+
require "rails"
|
|
36
|
+
require "active_record"
|
|
37
|
+
require "active_job"
|
|
38
|
+
require "action_controller"
|
|
39
|
+
require "action_cable"
|
|
40
|
+
require "data_porter"
|
|
41
|
+
|
|
42
|
+
ActiveRecord::Base.establish_connection(
|
|
43
|
+
adapter: "sqlite3",
|
|
44
|
+
database: ":memory:"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
ActiveRecord::Schema.define do
|
|
48
|
+
create_table :data_porter_imports, force: true do |t|
|
|
49
|
+
t.string :target_key, null: false, default: ""
|
|
50
|
+
t.string :source_type, null: false, default: "csv"
|
|
51
|
+
t.integer :status, null: false, default: 0
|
|
52
|
+
t.text :records
|
|
53
|
+
t.text :report
|
|
54
|
+
t.text :config
|
|
55
|
+
|
|
56
|
+
t.string :user_type
|
|
57
|
+
t.integer :user_id
|
|
58
|
+
|
|
59
|
+
t.timestamps
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
%w[models jobs].each do |dir|
|
|
64
|
+
$LOAD_PATH.unshift File.expand_path("../app/#{dir}", __dir__)
|
|
65
|
+
end
|
|
66
|
+
require "data_porter/data_import"
|
|
67
|
+
require "data_porter/parse_job"
|
|
68
|
+
require "data_porter/import_job"
|
|
69
|
+
|
|
70
|
+
# Stub for controller inheritance in test context
|
|
71
|
+
class ApplicationController < ActionController::Base; end unless defined?(ApplicationController)
|
|
72
|
+
|
|
73
|
+
$LOAD_PATH.unshift File.expand_path("../app/controllers", __dir__)
|
|
74
|
+
require "data_porter/imports_controller"
|
|
75
|
+
|
|
76
|
+
$LOAD_PATH.unshift File.expand_path("../app/channels", __dir__)
|
|
77
|
+
require "data_porter/import_channel"
|
|
78
|
+
|
|
79
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
80
|
+
|
|
81
|
+
RSpec.configure do |config|
|
|
82
|
+
config.example_status_persistence_file_path = ".rspec_status"
|
|
83
|
+
config.disable_monkey_patching!
|
|
84
|
+
|
|
85
|
+
config.expect_with :rspec do |c|
|
|
86
|
+
c.syntax = :expect
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Sixty lines of setup that replace a 200-file dummy app. Every line exists because a spec somewhere needs it.
|
|
92
|
+
|
|
93
|
+
## Implementation
|
|
94
|
+
|
|
95
|
+
### Step 1 -- The in-memory database
|
|
96
|
+
|
|
97
|
+
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.
|
|
98
|
+
|
|
99
|
+
SQLite's `:memory:` database solves this perfectly:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
ActiveRecord::Base.establish_connection(
|
|
103
|
+
adapter: "sqlite3",
|
|
104
|
+
database: ":memory:"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
ActiveRecord::Schema.define do
|
|
108
|
+
create_table :data_porter_imports, force: true do |t|
|
|
109
|
+
t.string :target_key, null: false, default: ""
|
|
110
|
+
t.string :source_type, null: false, default: "csv"
|
|
111
|
+
t.integer :status, null: false, default: 0
|
|
112
|
+
t.text :records
|
|
113
|
+
t.text :report
|
|
114
|
+
t.text :config
|
|
115
|
+
t.string :user_type
|
|
116
|
+
t.integer :user_id
|
|
117
|
+
t.timestamps
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The `establish_connection` call creates a SQLite 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.
|
|
123
|
+
|
|
124
|
+
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.
|
|
125
|
+
|
|
126
|
+
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.
|
|
127
|
+
|
|
128
|
+
### Step 2 -- Load paths and the ApplicationController stub
|
|
129
|
+
|
|
130
|
+
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:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
%w[models jobs].each do |dir|
|
|
134
|
+
$LOAD_PATH.unshift File.expand_path("../app/#{dir}", __dir__)
|
|
135
|
+
end
|
|
136
|
+
require "data_porter/data_import"
|
|
137
|
+
require "data_porter/parse_job"
|
|
138
|
+
require "data_porter/import_job"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
This is straightforward but easy to forget: without the `$LOAD_PATH` manipulation, `require "data_porter/data_import"` would fail because Ruby does not know to look in `app/models`.
|
|
142
|
+
|
|
143
|
+
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:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
class ApplicationController < ActionController::Base; end unless defined?(ApplicationController)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
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, which we will cover shortly.
|
|
150
|
+
|
|
151
|
+
### Step 3 -- Testing ActiveRecord models with StoreModel
|
|
152
|
+
|
|
153
|
+
The `DataImport` model is a standard ActiveRecord class that uses StoreModel for its `records`, `report`, and `config` attributes. Testing it requires a working database (which we set up in step 1) and the StoreModel gem loaded (which happens through the `require "data_porter"` in the spec_helper).
|
|
154
|
+
|
|
155
|
+
The model specs test what any model spec would: validations, enum behavior, default values, and persistence. Here is the pattern:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
# spec/data_porter/data_import_spec.rb
|
|
159
|
+
RSpec.describe DataPorter::DataImport, type: :model do
|
|
160
|
+
let(:target_class) do
|
|
161
|
+
Class.new(DataPorter::Target) do
|
|
162
|
+
label "Guests"
|
|
163
|
+
model_name "Guest"
|
|
164
|
+
icon "fas fa-users"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
before do
|
|
169
|
+
DataPorter::Registry.clear
|
|
170
|
+
DataPorter::Registry.register(:guests, target_class)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
describe "validations" do
|
|
174
|
+
it "requires target_key" do
|
|
175
|
+
import = described_class.new(source_type: "csv")
|
|
176
|
+
|
|
177
|
+
expect(import).not_to be_valid
|
|
178
|
+
expect(import.errors[:target_key]).to include("can't be blank")
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
describe "persistence" do
|
|
183
|
+
it "saves and reloads with records" do
|
|
184
|
+
import = described_class.create!(
|
|
185
|
+
target_key: "guests",
|
|
186
|
+
source_type: "csv",
|
|
187
|
+
user_type: "User",
|
|
188
|
+
user_id: 1
|
|
189
|
+
)
|
|
190
|
+
record = DataPorter::StoreModels::ImportRecord.new(
|
|
191
|
+
line_number: 1,
|
|
192
|
+
data: { name: "Alice" }
|
|
193
|
+
)
|
|
194
|
+
import.update!(records: [record])
|
|
195
|
+
|
|
196
|
+
reloaded = described_class.find(import.id)
|
|
197
|
+
|
|
198
|
+
expect(reloaded.records.first.line_number).to eq(1)
|
|
199
|
+
expect(reloaded.records.first.data).to eq({ "name" => "Alice" })
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
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.
|
|
206
|
+
|
|
207
|
+
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.
|
|
208
|
+
|
|
209
|
+
### Step 4 -- Testing StoreModel objects in isolation
|
|
210
|
+
|
|
211
|
+
The `ImportRecord` is a StoreModel type, not an ActiveRecord model. It has no table. It exists as a JSONB-backed object inside a `DataImport`'s `records` array. Testing it does not require a database at all:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
# spec/data_porter/store_models/import_record_spec.rb
|
|
215
|
+
RSpec.describe DataPorter::StoreModels::ImportRecord do
|
|
216
|
+
subject(:record) { described_class.new(line_number: 1, data: { name: "Alice" }) }
|
|
217
|
+
|
|
218
|
+
describe "#add_error" do
|
|
219
|
+
it "appends an error to errors_list" do
|
|
220
|
+
record.add_error("name is required")
|
|
221
|
+
|
|
222
|
+
expect(record.errors_list.size).to eq(1)
|
|
223
|
+
expect(record.errors_list.first.message).to eq("name is required")
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
describe "#determine_status!" do
|
|
228
|
+
it "sets complete when no errors" do
|
|
229
|
+
record.determine_status!
|
|
230
|
+
|
|
231
|
+
expect(record.status).to eq("complete")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it "sets missing when required field error exists" do
|
|
235
|
+
record.add_error("Name is required")
|
|
236
|
+
record.determine_status!
|
|
237
|
+
|
|
238
|
+
expect(record.status).to eq("missing")
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
describe "#attributes" do
|
|
243
|
+
it "returns symbolized compact data" do
|
|
244
|
+
record = described_class.new(data: { "name" => "Alice", "email" => nil })
|
|
245
|
+
|
|
246
|
+
expect(record.attributes).to eq({ name: "Alice" })
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
StoreModel objects behave like plain Ruby objects with attribute accessors -- you instantiate them with a hash, call methods on them, and assert on their state. No database queries, no fixtures, no setup. The `#attributes` spec is particularly important because it verifies the `compact` and `symbolize_keys` behavior that downstream code (the target's `persist` method) depends on. If `attributes` returned `nil` values or string keys, persisting to the host app's models would fail in subtle ways.
|
|
253
|
+
|
|
254
|
+
### Step 5 -- Testing controllers structurally
|
|
255
|
+
|
|
256
|
+
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.
|
|
257
|
+
|
|
258
|
+
Instead, we test controllers *structurally* -- verifying the class hierarchy, the callback registrations, and the method signatures:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
# spec/data_porter/imports_controller_spec.rb
|
|
262
|
+
RSpec.describe DataPorter::ImportsController do
|
|
263
|
+
describe "inheritance" do
|
|
264
|
+
it "inherits from the configured parent controller" do
|
|
265
|
+
expect(described_class.superclass.name).to eq(
|
|
266
|
+
DataPorter.configuration.parent_controller
|
|
267
|
+
)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
describe "before_actions" do
|
|
272
|
+
it "registers set_import callback" do
|
|
273
|
+
callbacks = described_class._process_action_callbacks.select do |c|
|
|
274
|
+
c.filter == :set_import
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
expect(callbacks).not_to be_empty
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
describe "action methods" do
|
|
282
|
+
it "defines index, new, create, show, parse, confirm, and cancel" do
|
|
283
|
+
actions = %i[index new create show parse confirm cancel]
|
|
284
|
+
actions.each do |action|
|
|
285
|
+
expect(described_class.instance_method(action)).to be_a(UnboundMethod)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
describe "private methods" do
|
|
291
|
+
it "defines set_import" do
|
|
292
|
+
expect(described_class.private_instance_methods).to include(:set_import)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
it "defines import_params" do
|
|
296
|
+
expect(described_class.private_instance_methods).to include(:import_params)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
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.
|
|
303
|
+
|
|
304
|
+
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*.
|
|
305
|
+
|
|
306
|
+
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.
|
|
307
|
+
|
|
308
|
+
### Step 6 -- Testing Phlex components
|
|
309
|
+
|
|
310
|
+
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:
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
# spec/data_porter/components/preview_table_spec.rb
|
|
314
|
+
RSpec.describe DataPorter::Components::PreviewTable do
|
|
315
|
+
def render(component)
|
|
316
|
+
component.call
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
let(:target_class) do
|
|
320
|
+
Class.new(DataPorter::Target) do
|
|
321
|
+
label "Guests"
|
|
322
|
+
model_name "Guest"
|
|
323
|
+
|
|
324
|
+
columns do
|
|
325
|
+
column :first_name, type: :string, required: true
|
|
326
|
+
column :last_name, type: :string
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
let(:record) do
|
|
332
|
+
DataPorter::StoreModels::ImportRecord.new(
|
|
333
|
+
line_number: 1,
|
|
334
|
+
status: "complete",
|
|
335
|
+
data: { "first_name" => "Alice", "last_name" => "Smith" }
|
|
336
|
+
)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
let(:error_record) do
|
|
340
|
+
r = DataPorter::StoreModels::ImportRecord.new(
|
|
341
|
+
line_number: 2,
|
|
342
|
+
status: "missing",
|
|
343
|
+
data: { "first_name" => "", "last_name" => "Jones" }
|
|
344
|
+
)
|
|
345
|
+
r.add_error("First name is required")
|
|
346
|
+
r
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
it "renders column headers from target" do
|
|
350
|
+
html = render(described_class.new(columns: target_class._columns, records: [record]))
|
|
351
|
+
|
|
352
|
+
expect(html).to include("First name")
|
|
353
|
+
expect(html).to include("Last name")
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
it "renders error messages" do
|
|
357
|
+
html = render(described_class.new(columns: target_class._columns, records: [error_record]))
|
|
358
|
+
|
|
359
|
+
expect(html).to include("First name is required")
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
it "includes status-specific row class" do
|
|
363
|
+
html = render(described_class.new(columns: target_class._columns, records: [record]))
|
|
364
|
+
expect(html).to include("dp-row--complete")
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
The local `render` helper is just `component.call` -- but giving it a name makes the specs read like a sentence: "render the component, expect the html to include this string." Every component spec follows this exact pattern.
|
|
370
|
+
|
|
371
|
+
The PreviewTable spec is the most interesting because it tests the full chain: the anonymous Target class declares columns via the DSL, those columns are passed to the component, and the rendered HTML includes the column labels and record data. This exercises the integration between the Target DSL and the view layer without touching a database.
|
|
372
|
+
|
|
373
|
+
The assertions are string-based (`include("Alice")`) rather than DOM-based (`have_css("td", text: "Alice")`). We do not use Capybara or Nokogiri for component specs. String matching is sufficient for verifying that the right content appears in the output, and it keeps the test dependencies minimal. For a gem that ships UI components, the host app's integration tests are the place for DOM-level assertions.
|
|
374
|
+
|
|
375
|
+
### Step 7 -- Testing generators
|
|
376
|
+
|
|
377
|
+
Generator testing in a gem without a dummy app requires a different approach from the usual `rails generate` flow. We cannot run the generator and check the filesystem output because the generator expects a Rails app directory structure. Instead, we test the generator class itself -- its inheritance, its source root, and its method definitions:
|
|
378
|
+
|
|
379
|
+
```ruby
|
|
380
|
+
# spec/data_porter/generators/install_generator_spec.rb
|
|
381
|
+
require "generators/data_porter/install/install_generator"
|
|
382
|
+
|
|
383
|
+
RSpec.describe DataPorter::Generators::InstallGenerator do
|
|
384
|
+
it "inherits from Rails::Generators::Base" do
|
|
385
|
+
expect(described_class.superclass).to eq(Rails::Generators::Base)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
it "has a source_root pointing to templates" do
|
|
389
|
+
expect(described_class.source_root).to end_with(
|
|
390
|
+
"lib/generators/data_porter/install/templates"
|
|
391
|
+
)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
describe "generator methods" do
|
|
395
|
+
it "defines copy_migration" do
|
|
396
|
+
expect(described_class.instance_method(:copy_migration)).to be_a(UnboundMethod)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
it "defines copy_initializer" do
|
|
400
|
+
expect(described_class.instance_method(:copy_initializer)).to be_a(UnboundMethod)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
it "defines create_importers_directory" do
|
|
404
|
+
expect(described_class.instance_method(:create_importers_directory)).to be_a(UnboundMethod)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
it "defines mount_engine" do
|
|
408
|
+
expect(described_class.instance_method(:mount_engine)).to be_a(UnboundMethod)
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
The `require` at the top loads the generator file directly from the `lib/generators` path -- it does not use the Rails generator lookup mechanism. The specs then verify three things: the generator inherits from the right base class (which determines what helpers are available), the `source_root` points to the right template directory (which determines where template files are found during generation), and every expected generator method exists (which determines what the generator actually does when invoked).
|
|
415
|
+
|
|
416
|
+
This is the same structural testing pattern we use for controllers. The specs prove the generator *exists and is wired correctly*. They do not prove that running `rails generate data_porter:install` produces the right files -- that would require a dummy app with a writable filesystem. But they catch the most common generator bugs: a misspelled method name, a wrong source root, or a missing base class.
|
|
417
|
+
|
|
418
|
+
### Step 8 -- Testing JavaScript from Ruby specs
|
|
419
|
+
|
|
420
|
+
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.
|
|
421
|
+
|
|
422
|
+
Instead, we test the JavaScript file as a *text artifact* from Ruby:
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
# spec/data_porter/progress_controller_js_spec.rb
|
|
426
|
+
RSpec.describe "progress_controller.js" do
|
|
427
|
+
let(:js_path) do
|
|
428
|
+
File.expand_path("../../app/javascript/data_porter/progress_controller.js", __dir__)
|
|
429
|
+
end
|
|
430
|
+
let(:content) { File.read(js_path) }
|
|
431
|
+
|
|
432
|
+
it "exists" do
|
|
433
|
+
expect(File.exist?(js_path)).to be true
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
it "imports Stimulus Controller" do
|
|
437
|
+
expect(content).to include('import { Controller } from "@hotwired/stimulus"')
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
it "imports ActionCable consumer" do
|
|
441
|
+
expect(content).to include('import { createConsumer } from "@rails/actioncable"')
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
it "defines bar and text targets" do
|
|
445
|
+
expect(content).to include('static targets = ["bar", "text"]')
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
it "defines id value" do
|
|
449
|
+
expect(content).to include("static values = { id: Number }")
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
it "subscribes to ImportChannel on connect" do
|
|
453
|
+
expect(content).to include("DataPorter::ImportChannel")
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
it "unsubscribes on disconnect" do
|
|
457
|
+
expect(content).to include("this.subscription?.unsubscribe()")
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
it "updates progress bar width and text" do
|
|
461
|
+
expect(content).to include("this.barTarget.style.width")
|
|
462
|
+
expect(content).to include("this.textTarget.textContent")
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
This reads the JavaScript file as a string and asserts that it contains the expected patterns: the right imports, the right Stimulus static declarations, the right channel subscription, the right DOM updates. It does not execute the JavaScript. It does not test that the progress bar actually animates. It tests the *contract* between the JavaScript file and the rest of the system.
|
|
468
|
+
|
|
469
|
+
This approach catches a specific class of bugs: someone renames the ActionCable channel in Ruby but forgets to update the JavaScript subscription string. Someone removes a Stimulus target from the HTML but leaves the target declaration in the controller. Someone refactors the progress update and forgets to update `barTarget.style.width`. These are integration seams where Ruby and JavaScript must agree, and string matching catches disagreements.
|
|
470
|
+
|
|
471
|
+
Is this a substitute for real JavaScript testing? No. But for a Ruby gem with a single Stimulus controller, it provides meaningful coverage without adding a JavaScript toolchain to the project. If the Stimulus controller grows more complex, the calculus changes and a proper JS test runner becomes worth the overhead.
|
|
472
|
+
|
|
473
|
+
### Step 9 -- The anonymous target class pattern
|
|
474
|
+
|
|
475
|
+
A pattern that runs through nearly every spec file is the anonymous target class:
|
|
476
|
+
|
|
477
|
+
```ruby
|
|
478
|
+
let(:target_class) do
|
|
479
|
+
Class.new(DataPorter::Target) do
|
|
480
|
+
label "Guests"
|
|
481
|
+
model_name "Guest"
|
|
482
|
+
icon "fas fa-users"
|
|
483
|
+
sources :csv
|
|
484
|
+
|
|
485
|
+
columns do
|
|
486
|
+
column :first_name, type: :string, required: true
|
|
487
|
+
column :last_name, type: :string
|
|
488
|
+
column :email, type: :email
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
csv_mapping do
|
|
492
|
+
map "First Name" => :first_name
|
|
493
|
+
map "Last Name" => :last_name
|
|
494
|
+
map "Email" => :email
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
define_method(:persist) do |record, **|
|
|
498
|
+
records << record.data
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
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.
|
|
505
|
+
|
|
506
|
+
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.
|
|
507
|
+
|
|
508
|
+
The companion pattern is registry cleanup:
|
|
509
|
+
|
|
510
|
+
```ruby
|
|
511
|
+
before do
|
|
512
|
+
DataPorter::Registry.clear
|
|
513
|
+
DataPorter::Registry.register(:guests, target_class)
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
after { DataPorter::Registry.clear }
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
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.
|
|
520
|
+
|
|
521
|
+
## Decisions & tradeoffs
|
|
522
|
+
|
|
523
|
+
| Decision | We chose | Over | Because |
|
|
524
|
+
|----------|----------|------|---------|
|
|
525
|
+
| 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 |
|
|
526
|
+
| 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 |
|
|
527
|
+
| Component testing | `component.call` with string assertions | Capybara or Nokogiri DOM assertions | Minimal dependencies; string matching is sufficient for verifying content in component output |
|
|
528
|
+
| Generator testing | Class-level structural tests | Running the generator against a temp directory | Catches wiring bugs without requiring a writable Rails app filesystem |
|
|
529
|
+
| 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 |
|
|
530
|
+
| Target fixtures | Anonymous classes per spec with `Class.new` | Shared named target classes | Each spec controls its own target definition; no coupling between specs |
|
|
531
|
+
| Registry isolation | `clear` before and after each spec that registers targets | Global shared state | Prevents registration leakage and ordering-dependent test failures |
|
|
532
|
+
| 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 |
|
|
533
|
+
|
|
534
|
+
## Testing it
|
|
535
|
+
|
|
536
|
+
The test suite itself is best verified by running it:
|
|
537
|
+
|
|
538
|
+
```bash
|
|
539
|
+
$ bundle exec rspec
|
|
540
|
+
|
|
541
|
+
DataPorter::Configuration
|
|
542
|
+
has default parent_controller
|
|
543
|
+
has default queue_name
|
|
544
|
+
has default storage_service
|
|
545
|
+
...
|
|
546
|
+
|
|
547
|
+
DataPorter::DataImport
|
|
548
|
+
validations
|
|
549
|
+
requires target_key
|
|
550
|
+
requires source_type
|
|
551
|
+
enum status
|
|
552
|
+
defaults to pending
|
|
553
|
+
persistence
|
|
554
|
+
saves and reloads with records
|
|
555
|
+
|
|
556
|
+
DataPorter::StoreModels::ImportRecord
|
|
557
|
+
#add_error
|
|
558
|
+
appends an error to errors_list
|
|
559
|
+
#determine_status!
|
|
560
|
+
sets complete when no errors
|
|
561
|
+
sets missing when required field error exists
|
|
562
|
+
|
|
563
|
+
DataPorter::Orchestrator
|
|
564
|
+
#parse!
|
|
565
|
+
transitions to previewing
|
|
566
|
+
creates import records from source data
|
|
567
|
+
validates required fields
|
|
568
|
+
#import!
|
|
569
|
+
transitions to completed
|
|
570
|
+
handles per-record errors
|
|
571
|
+
|
|
572
|
+
DataPorter::Components::PreviewTable
|
|
573
|
+
renders column headers from target
|
|
574
|
+
renders record data in rows
|
|
575
|
+
includes status-specific row class
|
|
576
|
+
|
|
577
|
+
DataPorter::Generators::InstallGenerator
|
|
578
|
+
inherits from Rails::Generators::Base
|
|
579
|
+
has a source_root pointing to templates
|
|
580
|
+
|
|
581
|
+
progress_controller.js
|
|
582
|
+
imports Stimulus Controller
|
|
583
|
+
defines bar and text targets
|
|
584
|
+
subscribes to ImportChannel on connect
|
|
585
|
+
|
|
586
|
+
Finished in 0.XXs
|
|
587
|
+
XX examples, 0 failures
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
Every layer of the engine has coverage. The in-memory database starts and tears down in milliseconds. The full suite runs in under a second.
|
|
591
|
+
|
|
592
|
+
## The TDD workflow
|
|
593
|
+
|
|
594
|
+
Throughout this series, every feature followed the same cycle: write a spec that describes the behavior we want, watch it fail, implement the minimum code to make it pass, then refactor. This is not a theoretical preference -- it is a practical necessity when building a gem.
|
|
595
|
+
|
|
596
|
+
In a Rails app, you can spike code in a controller action, hit refresh in the browser, and see if it works. In a gem, there is no browser. The spec suite *is* the feedback loop. When we built the Orchestrator in part 7, the first thing we wrote was a spec that called `orchestrator.parse!` and expected `data_import.status` to be `"previewing"`. When we built the PreviewTable in part 9, the first thing we wrote was a spec that rendered the component with two columns and expected the HTML to include both column headers.
|
|
597
|
+
|
|
598
|
+
The anonymous target class pattern emerged from this workflow. We needed a way to define targets inline in specs without creating named classes that would pollute the test namespace. `Class.new(DataPorter::Target)` was the answer, and it became the standard pattern for every spec that touches the target system.
|
|
599
|
+
|
|
600
|
+
The structural controller testing pattern also emerged from necessity. We needed to verify that `ImportsController` inherits from the configured parent controller, but we had no router to send requests through. Instead of building a dummy app just for controller tests, we tested the class structure directly. This turned out to be more valuable than request specs anyway, because the structural tests verify the *engine's contribution* -- the inheritance, the callbacks, the method definitions -- while leaving the *integration behavior* to the host app.
|
|
601
|
+
|
|
602
|
+
## Recap
|
|
603
|
+
|
|
604
|
+
- **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.
|
|
605
|
+
- **StoreModel objects** are tested as plain Ruby objects, with database round-trip tests on the parent ActiveRecord model.
|
|
606
|
+
- **Controllers** are tested structurally -- inheritance, callbacks, and method existence -- rather than with request specs, because the gem has no router.
|
|
607
|
+
- **Phlex components** are tested by calling `.call` and asserting against the HTML string, with no view context or browser.
|
|
608
|
+
- **Generators** are tested by verifying class hierarchy, source root, and method definitions -- the wiring, not the filesystem output.
|
|
609
|
+
- **JavaScript files** are tested as text artifacts from Ruby, verifying the contract between the JS and the rest of the engine.
|
|
610
|
+
- **Anonymous target classes** with `Class.new` keep each spec self-contained, and `Registry.clear` prevents state leakage.
|
|
611
|
+
|
|
612
|
+
## Next up
|
|
613
|
+
|
|
614
|
+
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*.
|
|
615
|
+
|
|
616
|
+
---
|
|
617
|
+
|
|
618
|
+
*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](#)*
|