Agent Skill · Spree Commerce

spree-testing

Use when the user is writing or running automated tests for a Spree app — model specs, controller specs, API integration tests, admin feature specs, factories, fixtures. Covers RSpec + Factory Bot + Capybara (Spree's stack — NOT Minitest + fixtures), the spree_dev_tools gem, pulling in Spree's own factories, the shared `API v3 Store` context, stub_authorization!, wait_for_turbo, and common Spree testing gotchas. Common phrasings include "test my Spree model", "Spree spec", "Factory Bot factories from Spree", "spree_dev_tools", "include_context API v3 Store", "stub_authorization", "wait_for_turbo", "Spree test setup".

Provider: Spree Commerce Path in repo: skills/spree-testing/SKILL.md

Skill body

Spree Testing

Commands below use the Spree CLI form (spree …, Docker). On a classic Rails app without the CLI (typical pre-5.4), use the native mapping in the spree-project skill — bin/rails / bundle exec rake from the app root, paths without the backend/ prefix.

Spree’s testing stack:

Tool Role
RSpec Test framework (not Minitest)
Factory Bot Test data (not fixtures)
Capybara Browser-driving feature tests
spree_dev_tools Spree-specific helpers (authorization stub, shared contexts, factory access)

If you’ve worked with vanilla Rails: drop test/, drop fixtures/, write under spec/ with RSpec instead. Spree gems use this stack consistently — your app should too.

Setup (one-time)

bin/rails g rspec:install            # creates spec/spec_helper.rb, spec/rails_helper.rb
bin/rails g spree_dev_tools:install  # adds Spree-specific helpers + shared contexts

spree_dev_tools is the key piece. It wires up:

For tests involving images/uploads, create a fixtures directory:

mkdir -p spec/fixtures/files
# add real file bytes — a 1x1 PNG is enough for most cases
printf '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR...' > spec/fixtures/files/logo.png

Pulling in Spree’s factories

spree_dev_tools requires spree/testing_support/factories — the same factories Spree itself uses in its own specs (spree/core/lib/spree/testing_support/factories/). You get factories for every Spree model:

create(:store)           # Spree::Store
create(:product)         # Spree::Product (with default_variant, prices)
create(:variant)         # Spree::Variant
create(:order)           # Spree::Order
create(:order_with_line_items)
create(:completed_order_with_totals)
create(:user)            # Spree::User
create(:admin_user)
create(:shipping_method)
create(:tax_rate)
create(:promotion)
create(:payment_method)

Spree mostly ships preset variations as nested child factories rather than traits — check bundle show spree_core/lib/spree/testing_support/factories/ for each factory’s list:

create(:product_in_stock)                            # product with stock on hand
create(:product_with_option_types)                   # product with an option type + values
create(:variant, product: product)                   # add a variant to an existing product
create(:order_with_line_items, line_items_count: 3)
create(:order_ready_to_ship)                         # complete order, payment_state 'paid', shipments ready
create(:shipped_order)
create(:shipment, state: 'ready')                    # shipment factory has no traits — override state
create(:payment, state: 'completed')                 # payment factory has no traits — override state

Always use factories — never call Model.create directly in tests. Factories handle dependencies (stores, currencies, shipping categories) you don’t want to think about per-test.

Writing model specs

# spec/models/spree/brand_spec.rb
require 'rails_helper'

RSpec.describe Spree::Brand, type: :model do
  describe 'associations' do
    it 'has many products' do
      association = described_class.reflect_on_association(:products)
      expect(association.macro).to eq(:has_many)
      expect(association.class_name).to eq('Spree::Product')
    end
  end

  describe 'validations' do
    it 'validates presence of name' do
      brand = build(:brand, name: nil)
      expect(brand).not_to be_valid
      expect(brand.errors[:name]).to include("can't be blank")
    end

    describe 'slug uniqueness' do
      let!(:existing_brand) { create(:brand, slug: 'nike') }

      it 'is enforced' do
        brand = build(:brand, slug: 'nike')
        expect(brand).not_to be_valid
        expect(brand.errors[:slug]).to include('has already been taken')
      end
    end
  end
end

Prefer build over create when persistence isn’t needed — it skips the database round-trip and runs ~10x faster.

Testing decorators

When you decorate a Spree model (e.g. add brand to Product), write a separate spec file:

# spec/models/spree/product_decorator_spec.rb
require 'rails_helper'

RSpec.describe 'Spree::Product brand association' do
  let(:brand) { create(:brand) }
  let(:product) { create(:product) }

  it 'can be assigned a brand' do
    product.update!(brand: brand)
    expect(product.reload.brand).to eq(brand)
  end
end

Writing controller specs

Always include render_views so view rendering bugs surface in tests too.

Admin controller spec

# spec/controllers/spree/admin/brands_controller_spec.rb
require 'rails_helper'

RSpec.describe Spree::Admin::BrandsController, type: :controller do
  stub_authorization!   # grants full admin access for tests
  render_views

  describe 'GET #index' do
    let!(:brand) { create(:brand, name: 'Nike') }

    it 'returns a successful response' do
      get :index
      expect(response).to be_successful
      expect(response.body).to include('Nike')
    end
  end

  describe 'POST #create' do
    it 'creates a brand' do
      expect {
        post :create, params: { brand: { name: 'Adidas', slug: 'adidas' } }
      }.to change(Spree::Brand, :count).by(1)
    end
  end
end

stub_authorization! is the single most important admin-test helper. Without it, every test would have to log in as an admin user (slow + brittle).

API v3 Store controller spec

Scaffolding a new API resource? bin/rails g spree:api_resource Name attr:type (or spree generate api_resource ... via the Spree CLI) scaffolds these files for you: v3 Store + Admin controller specs under spec/controllers/spree/api/v3/{store,admin}/ and a Factory Bot factory at spec/factories/spree/<name>_factory.rb (FactoryBot’s default scan path). Pass --skip-specs to skip the controller specs (the factory is always generated).

Use the shared context — it provisions a default store + a publishable API key. The 'API v3 Store' / 'API v3 Admin' shared contexts live in the spree_api gem — add require 'spree/api/testing_support/v3/base' at the top of the spec (after require 'rails_helper') before include_context 'API v3 Store'.

# spec/controllers/spree/api/v3/store/brands_controller_spec.rb
require 'rails_helper'
require 'spree/api/testing_support/v3/base'

RSpec.describe Spree::Api::V3::Store::BrandsController, type: :controller do
  render_views
  include_context 'API v3 Store'

  let!(:brand) { create(:brand, name: 'Nike') }

  before do
    request.headers['X-Spree-Api-Key'] = api_key.token
  end

  describe 'GET #index' do
    it 'returns a list of brands' do
      get :index
      expect(response).to have_http_status(:ok)
      expect(json_response['data'].size).to eq(1)
    end

    it 'returns prefixed IDs' do
      get :index
      expect(json_response['data'].first['id']).to start_with('brand_')
    end

    it 'filters by name' do
      create(:brand, name: 'Adidas')
      get :index, params: { q: { name_cont: 'nik' } }
      expect(json_response['data'].size).to eq(1)
    end
  end

  describe 'GET #show' do
    it 'returns the brand by prefixed ID' do
      get :show, params: { id: brand.prefixed_id }
      expect(json_response['name']).to eq('Nike')
    end
  end
end

json_response comes from Spree::TestingSupport::ApiHelpers (defined in spree_dev_tools’ generated spec/support/spree.rb), which is only included for type: :request specs by default. For API controller specs, extend the include in spec/support/spree.rb: config.include Spree::TestingSupport::ApiHelpers, type: :controller.

Equivalent shared context for admin: include_context 'API v3 Admin' (provisions admin JWT + a secret key). Use it for Admin API controller specs.

When to write controller specs vs API integration specs

Default to controller specs. Use them for:

Use API integration specs (spec/integration/) sparingly. They drive request → middleware → controller → response end-to-end, and they generate OpenAPI examples via Rswag. Reserve them for:

Integration specs are slow and brittle to maintain. Don’t try to cover every combination there — controller specs do that better.

Writing feature specs (Capybara)

Feature specs use rack_test by default (no browser, no JavaScript); tag examples with js: true to drive a real headless Chrome browser through the Rails admin (or storefront). Turbo-driven admin pages need js: true — without it, wait_for_turbo and any Turbo Stream/Frame behavior is a no-op.

# spec/features/spree/admin/brands_spec.rb
require 'rails_helper'

RSpec.feature 'Admin Brands', type: :feature do
  stub_authorization!

  describe 'creating a brand' do
    it 'creates successfully' do
      visit spree.admin_brands_path
      click_on 'New Brand'
      fill_in 'Name', with: 'Puma'
      fill_in 'Slug', with: 'puma'
      click_on 'Create'
      wait_for_turbo

      expect(page).to have_content('Brand "Puma" has been successfully created!')
      expect(Spree::Brand.find_by(name: 'Puma')).to be_present
    end
  end
end

wait_for_turbo

The Rails admin uses Turbo (Hotwire). After clicking a button that triggers a Turbo Stream / Frame update, the response is async — Capybara needs to wait for the DOM update. wait_for_turbo waits for Turbo’s in-flight requests to settle.

wait_for_turbo comes from Spree::Admin::TestingSupport::CapybaraUtils in the spree_admin gem — it is NOT wired up by spree_dev_tools:install (the generator skips the gem’s spree_admin.rb support file). If you get NoMethodError: undefined method 'wait_for_turbo', add a spec/support/spree_admin.rb:

require 'spree/admin/testing_support/capybara_utils'

RSpec.configure do |config|
  config.include Spree::Admin::TestingSupport::CapybaraUtils, type: :feature
end
click_on 'Create'
wait_for_turbo            # <- without this, the next expect runs before the update
expect(page).to have_content('Success!')

Many Capybara matchers (have_content, have_css) auto-poll, so they often work without wait_for_turbo. Use it explicitly when:

Running tests

bundle exec rspec                            # all
spree exec bundle exec rspec                 # Spree CLI (Docker) projects: runs inside the web container (invoke as `npx spree` / `pnpm exec spree` if not on PATH)
bundle exec rspec spec/models/spree/brand_spec.rb    # one file
bundle exec rspec spec/models/spree/brand_spec.rb:15 # one test (line number)
bundle exec rspec spec/features/             # one directory
bundle exec rspec --format documentation     # readable output
bundle exec rspec --tag focus                # filter by tag

# Parallel (after `bundle exec rake parallel_setup`)
bundle exec parallel_rspec spec
bundle exec parallel_rspec -n 4 spec         # 4 workers

When developing the Spree gems themselves or a Spree extension — not a Spree app — specs run against a generated dummy app; after schema changes regenerate it:

bundle exec rake test_app                    # default SQLite
DB=postgres DB_USERNAME=postgres DB_PASSWORD=password DB_HOST=localhost bundle exec rake test_app

(Spree’s engine Rakefiles define test_app via spree/testing_support/common_rake; extension Rakefiles delegate to extension:test_app from spree/testing_support/extension_rake. bundle exec rake parallel_setup is likewise engine/extension-only.) Then re-run parallel_setup for parallel workers.

In a Spree app there is no dummy app to regenerate — after running migrations, just run bin/rails db:test:prepare.

Common Spree testing gotchas

“spree_dummy_models table missing”

(Spree gem development only — this hits Spree’s own core/admin test suites, not apps or extensions; apps don’t even have a test_app task.) The gem’s dummy app is stale. Regenerate from the gem directory: bundle exec rake test_app.

“ActiveRecord::ConnectionPool…” in parallel

You skipped parallel_setup. Each worker needs its own DB:

bundle exec rake parallel_setup

“Wrong currency in test”

The order factory hardcodes currency { 'USD' }, so orders are USD no matter what the store’s default_currency is. To test another currency, be explicit:

create(:order, currency: 'EUR')

“Variant has no price”

create(:variant) doesn’t always create a Price in your test currency. Force it:

variant = create(:variant)
variant.prices.create!(currency: 'EUR', amount: 10.00)

Or use the factory’s transients: create(:variant, price: 10.0, currency: 'EUR') — the factory’s after(:create) hook calls set_price with these, creating the Price in that currency.

“Image attachments fail”

You used build(:image) instead of create(:image). Image fixtures need ActiveStorage to actually attach the file — that happens in the before(:create) hook. Always create.

“Time-dependent test flakes around midnight”

Use Timecop:

Timecop.freeze(Time.zone.local(2025, 1, 1, 12, 0)) do
  # the entire block thinks it's noon on 2025-01-01
end

Don’t write Time.now and hope.

Tests should clean up between runs (DatabaseCleaner). If you’re seeing stock_items from other tests, check spec/support/database_cleaner.rb (loaded via the support-file glob in rails_helper.rb) — rails g spree_dev_tools:install generates it, but custom config can break it.

“TestApp regeneration is slow”

The Spree test app boots the full stack. Once generated, don’t regenerate unless schema changed. Use RAILS_ENV=test bin/rails db:rollback for migration tweaks.

What NOT to test

Spree’s CLAUDE.md is clear: don’t test framework guarantees.

DO test:

Best practices

Where to read further