Matthew

Interactive snapshot testing in Ruby with Thor

October 20, 2025

Context

Unit testing felt cumbersome while I was still figuring out the code structure of jsonapi-resources-anchor, so I ditched it.

I still wanted to test my code. But more integration-y.

For a gem that generates schema files, what could be more practical than snapshot testing?

Fine start

I initially went with something like:

it 'should match snapshot' do
  expect(generated_schema).to eq(File.read(path))
end

When I was strictly refactoring code, it was great. Make changes, run tests, confirm there were no regressions. Repeat.

When I was expecting changes in the schema, it was less useful. I would regenerate the schema files with rails jsonapi:generate and check the git diff to see if I was happy with the results.

It left me wondering if an experience like Jest's interactive snapshot mode was possible, where you can interactively view diffs and accept them.

I recalled rails app:update had a similar experience for interactive merge conflict resolution, maybe I could use whatever powered that?

Yes, I could. It was rails/thor and it was surprisingly easy to integrate.

Better snapshot testing with Thor

With Thor, we avoid the disjointed Rake task and git diff workflow.

This isn't to say the Rake task + git diff workflow no longer has utility. Sometimes, the diff is large and I want to view it outside of the merge conflict context, or even outside of the terminal. The Rake task + git diff workflow is more convenient in that case.

Demo and code using Thor below.

THOR_MERGE=nvim bundle exec rspec
require "thor"
 
class SnapshotUpdate < Thor
  include Thor::Actions
 
  def self.prompt(...) = new.prompt(...)
 
  desc "prompt", "Prompt user to update snapshot"
  def prompt(...) = create_file(...)
end
 
RSpec.describe Anchor::TypeScript::SchemaGenerator do
  it "generates correct schema" do
    schema = described_class.call(register: Schema.register)
    path = Rails.root.join("test/files", "schema.ts")
 
    # if schema.ts doesn't exist, just write to it
    File.open(path, "w") { |file| file.write(schema) } unless File.file?(path)
 
    if ENV["THOR_MERGE"] && File.read(path) != schema
      SnapshotUpdate.prompt(path, schema)
    end
 
    expect(File.read(path)).to eql(schema)
  end
end

Next step is to make this a custom RSpec matcher to have a more convenient API like expect(schema).to match_snapshot("schema.ts") in levinmr/rspec-snapshot.