When your gem supports multiple backends, testing gets interesting. You can't just run your test suite once and call it done. You need to verify that things work with backend A but not B, with B but not A, with both, and with neither. Multiply that by every optional dependency and you've got a combinatorial problem.

The Appraisal gem from thoughtbot solves this nicely. Here's how I've been using it.

The problem

Say you're building a gem that converts documents to PDF. It supports multiple adapters: Prawn for text, ImageMagick for images, LibreOffice for Word docs. Users install only the adapter gems they need, and your gem auto-detects what's available at runtime.

That means you need to test every realistic combination:

Setting up Appraisal

Add it to your gemspec's development dependencies and create an Appraisals file in your project root. Each block defines a separate Gemfile with a different set of optional dependencies:

# Appraisals
appraise "all" do
  gem "prawn", "~> 2.4"
  gem "rmagick", "~> 6.0"
  gem "mini_magick", "~> 5.0"
end

appraise "prawn-only" do
  gem "prawn", "~> 2.4"
end

appraise "rmagick-only" do
  gem "rmagick", "~> 6.0"
end

appraise "mini-magick-only" do
  gem "mini_magick", "~> 5.0"
end

appraise "no-adapters" do
end

Run bundle exec appraisal install and Appraisal generates a separate Gemfile for each configuration under gemfiles/.

Writing adapter-aware tests

The key challenge is that some tests should only exist when their adapter is available. You could use skip, but that just clutters your output with noise. Instead, detect adapter availability once at load time and use the constants to conditionally define tests:

# test_helper.rb
def self.gem_available?(name)
  require name
  true
rescue LoadError
  false
end

PRAWN_AVAILABLE = gem_available?("prawn")
RMAGICK_AVAILABLE = gem_available?("rmagick")
MINI_MAGICK_AVAILABLE = gem_available?("mini_magick")
IMAGE_CONVERTER_AVAILABLE = RMAGICK_AVAILABLE || MINI_MAGICK_AVAILABLE

Then wrap tests in conditionals so they simply don't exist when the adapter isn't installed:

if PRAWN_AVAILABLE
  def test_text_conversion
    result = DocPDF.convert("notes.txt")
    assert result.data.start_with?("%PDF")
  end
end

unless PRAWN_AVAILABLE
  def test_missing_text_adapter_raises_clear_error
    error = assert_raises(DocPDF::AdapterNotFoundError) do
      DocPDF.convert("notes.txt")
    end
    assert_match(/prawn/i, error.message)
  end
end

The result is a clean test run with zero skips. In the prawn-only appraisal, the text conversion tests run and the error path tests don't exist. In no-adapters, it's the reverse. Your test count changes between appraisals, but every test that runs is meaningful.

Running the matrix

Run all configurations:

bundle exec appraisal rake test

Or target a specific one:

bundle exec appraisal prawn-only rake test

Each appraisal runs your full test suite with a different set of gems available. Tests that need a missing gem aren't defined, and tests that verify error behavior only run in the right context.

CI integration

In GitHub Actions, run each appraisal as a separate matrix entry:

strategy:
  matrix:
    ruby: ['3.3', '3.4', '4.0']
    appraisal: [all, prawn-only, rmagick-only, mini-magick-only, no-adapters]

steps:
  - run: bundle exec appraisal ${{ matrix.appraisal }} rake test

This gives you a clear view in the CI dashboard of which adapter combinations pass and which don't. A failure in the rmagick-only appraisal tells you exactly where to look.

Tips

If you're building a gem with optional dependencies, Appraisal is worth the small setup cost. It catches a class of bugs that a single test run simply can't.