Spree Testing
| Layer | Tooling |
|---|
| Rails app () | RSpec + Factory Bot + — not Minitest, not fixtures |
| Dashboard () | Vitest (unit) + Playwright (e2e), preconfigured |
| Storefront () | whatever the Next.js app ships with |
Spree has no server-rendered admin or storefront, so there are no Capybara feature specs to write for Spree screens. Browser coverage belongs to Playwright against the dashboard/storefront.
Setup
Projects from
arrive with RSpec configured. For a classic app:
ruby
# Gemfile
group :development, :test do
gem 'spree_dev_tools'
end
bash
bin/rails g spree_dev_tools:install
The generator runs
if needed and writes
spec/support/{spree,factory_bot,database_cleaner,active_job}.rb
.
loads:
- Spree's factories (
spree/testing_support/factories
) + FactoryBot syntax
spree/testing_support/store
— a default USD store () created before each group and cleaned after each example
Spree::TestingSupport::Preferences
( before each example)
spree/api/testing_support/v3/base
— the API v3 shared contexts and JWT helper
- for and
- , DatabaseCleaner (transaction strategy)
Factory decorators in
spec/factories/spree/**/*_decorator.rb
are loaded automatically.
Running
bash
spree rspec # whole suite, in the web container, RAILS_ENV=test
spree rspec spec/models/spree/brand_spec.rb:15 # file / line; any rspec flags pass through
bundle exec rspec … # classic app, from the app root
works from a cold stack (it falls back to a one-off container). After adding migrations in a classic app:
bin/rails db:test:prepare
.
Factories you'll use
Read the source when in doubt:
$(bundle show spree_core)/lib/spree/testing_support/factories/
.
| Need | Factory |
|---|
| Customer / staff | (alias ), , (gets the admin role; trait) |
| Product | (default variant priced 19.99 in store currency), , :product_with_option_types
, ; transients , , |
| Variant | (same price transients), , |
| Cart () | , (), (addresses + delivery proposals), (payment covers total) |
| Order () | (draft), , :completed_order_with_totals
(placed), :completed_order_with_pending_payment
, , |
| Fulfillment | (alias ), (alias ), , , |
| Inventory | , |
| Payments | , , :credit_card_payment_method
, :store_credit_payment_method
|
| Catalog | , , , |
| Returns | , , |
| API / permissions | (, → scopes , ), (permissions: %w[read_orders]
) |
Checkout state comes from factories, not walkthroughs.
OrderWalkthrough.up_to(...)
is deprecated (removed in 6.1) — use
,
or
:completed_order_with_totals
.
Carts and orders are different models: a cart has no status; completion creates an
with
(
/
/
). To exercise real completion:
ruby
cart = create(:cart_ready_to_complete)
result = Spree.carts_complete_workflow.call(cart: cart)
expect(result).to be_success
order = result.value
expect(order).to be_placed
Prices are per currency — there is no
. Assert with
variant.price_in('EUR')&.amount
/
; set with
create(:variant, price: 10, currency: 'EUR')
or
variant.set_price('EUR', 10)
.
Your own resources:
spree generate api_resource Brand …
writes
spec/factories/spree/brand_factory.rb
plus Store/Admin controller specs.
Store context and required associations
- Set in specs that run code outside a request (workflows, jobs, services) when the store isn't the default one. falls back to , which is if no default store exists — store-scoped records then fail with "Store must exist". Groups tagged
without_global_store: true
skip the default store entirely.
- is required on Spree models. A factory for your model must build every non-optional association; failures read "" (not "can't be blank"). If blank is legitimate, declare on the association.
- is an
ActiveSupport::CurrentAttributes
; rspec-rails resets it between examples, so set it inside the example (or a ).
- Assigning arms for the rest of the example: any on a store-owned table that's neither store-scoped nor id-filtered is logged (default mode), or raises
Spree::StoreScopeGuard::UnscopedQueryError
with SPREE_STORE_SCOPE_GUARD=raise
(what Spree's own API suite uses — worth turning on in CI). API v3 request/controller specs are always guarded. Fix the query (), or wrap a deliberately global lookup in Spree::StoreScopeGuard.skip { … }
.
- The extension dummy app () configures Active Record encryption keys, so encrypted columns (webhook secrets, identity tokens) behave as in production. A host app's test env needs its own keys for the same behavior.
Model specs
ruby
require 'rails_helper'
RSpec.describe Spree::Brand, type: :model do
it 'rejects a duplicate slug in the same store' do
create(:brand, slug: 'nike')
expect(build(:brand, slug: 'nike')).not_to be_valid
end
end
Prefer
when persistence isn't needed. Test your custom logic — not Rails presence validations, strong params, or plain associations.
Workflow and hook specs
Workflows return a result:
,
,
.
ruby
RSpec.describe MyApp::RequirePoNumber do # a 'carts.complete.validate' handler
after { Spree.hooks.unregister('carts.complete.validate', described_class) }
it 'vetoes completion without a PO number' do
Spree.hooks.register('carts.complete.validate', described_class)
cart = create(:cart_ready_to_complete)
result = Spree.carts_complete_workflow.call(cart: cart)
expect(result).not_to be_success
expect(result.error.to_s).to include('PO number')
end
end
Unregister only what you registered —
also removes core's own registrations (e.g. the return-eligibility validator) for the rest of the process. Hook keys come from the workflow (
); an unknown key fails boot when eager loading.
To unit-test a handler in isolation, pass a double or the real workflow instance it reads from (
,
).
Subscriber specs
The subscriber generator writes a spec; the pattern:
ruby
RSpec.describe OrderPlacedSubscriber do
let(:order) { create(:completed_order_with_totals) }
let(:event) { Spree::Event.new(name: 'order.placed', payload: { 'id' => order.prefixed_id }) }
it 'submits the order' do
expect(WarehouseClient).to receive_message_chain(:new, :submit).with(order)
described_class.new.handle(event)
end
end
Call
directly — don't go through the bus to test your handler. To assert that something
publishes an event:
ruby
expect(Spree::Events).to receive(:publish).with('brand.created', anything, anything).and_call_original
brand.save!
For models with
publishes_lifecycle_events
, reuse Spree's shared example:
ruby
require 'spree/testing_support/lifecycle_events'
RSpec.describe Spree::Brand do
it_behaves_like 'lifecycle events' # factory: :brand, event_prefix: 'brand' inferred
end
If your suite globally disables events (
, as Spree's own suite does), re-enable per example with
Spree::Events.enable { … }
.
API v3 controller specs
Shared contexts (from
spree/api/testing_support/v3/base
, already loaded by
):
| Context | Provides |
|---|
| , publishable , , (customer), , ; stubs |
/ 'API v3 Store authenticated'
| above + (key only / key + customer JWT) |
| , (), , , |
'API v3 Admin authenticated'
| above + |
'API v3 Admin with custom permissions'
| set let(:custom_permissions) { %w[read_orders] }
— staff JWT with only those keys |
/ 'API v3 Seller authenticated'
| seller, seller user, JWT, header |
Shared examples:
,
,
,
'returns 401 Unauthorized'
,
,
,
'returns 422 Unprocessable Entity'
,
,
'requires authentication'
(all expect a
).
ruby
RSpec.describe Spree::Api::V3::Admin::BrandsController, type: :controller do
render_views
routes { Spree::Core::Engine.routes } # API controllers are mounted by the engine
include_context 'API v3 Admin authenticated'
before { request.headers.merge!(headers) }
let!(:brand) { create(:brand) }
describe 'GET #index' do
subject { get :index, params: { q: { name_cont: brand.name[0, 3] } }, as: :json }
it_behaves_like 'returns 200 OK'
it 'returns prefixed ids' do
subject
expect(json_response['data'].map { |b| b['id'] }).to include(brand.prefixed_id)
end
end
context 'with a role lacking write_brands' do
include_context 'API v3 Admin with custom permissions'
let(:custom_permissions) { %w[read_brands] }
subject { post :create, params: { name: 'X' }, as: :json }
it_behaves_like 'returns 403 Forbidden'
end
end
- Always assert on prefixed IDs (), never integer IDs.
- The generator's specs use exactly this shape — extend them rather than starting over.
- Permission keys (/) for your own resources exist only after
Spree.permissions.register_scope
— see .
Dashboard tests
bash
cd apps/dashboard
pnpm test # vitest run
pnpm test:e2e # playwright test (testDir ./e2e)
Unit-test the logic between UI and API — query keys (store-scoped via
), form → payload mappers, permission predicates, table state → Ransack params. Don't unit-test components or thin SDK wrappers. One Playwright spec per feature proves the layers connect: create data via the Admin API, log in, navigate from the sidebar (routes are store-scoped), assert visible text. Suffix test data with
— e2e runs share a database. Details:
.
Extension (gem) testing
Extensions test against a generated dummy app:
bash
bundle exec rake test_app # regenerate spec/dummy after schema changes
bundle exec rspec
/
exist only in engines and extensions, not in apps.
Common failures
| Symptom | Cause / fix |
|---|
/ Validation failed: Store must exist
| No default store in this context — tag without , or set / pass |
| from your factory | Required ; build the association or mark it |
| on variant | Prices are per currency — / |
| / | No state machines — assert (, ) |
| Order factory with | Still supported as a transient, but prefer :completed_order_with_totals
|
| Wrong currency | Order/cart factories default to ; pass |
| Subscriber "never runs" in a spec | Async subscribers only enqueue; call directly or |
| deprecation warning | Switch to the cart factories |
| Flaky time tests | / |
Where to read further
- Testing tutorial:
node_modules/@spree/docs/dist/developer/tutorial/testing.md
- Factories:
spree_core/lib/spree/testing_support/factories/
- Shared contexts:
spree_api/lib/spree/api/testing_support/v3/base.rb
- :
lib/spree_dev_tools/rspec/support/
and the install generator
- Related skills: , , , ,