docs/developer/tutorial/testing.mdx
Learn how to write automated tests for the Brands feature
Automated testing is a crucial part of the development process. It helps you ensure that your code works as expected and catches bugs early.
Spree uses RSpec, Factory Bot, and Capybara for testing.
We also provide the spree_dev_tools gem that helps you write Spree-specific tests.
bin/rails g rspec:install
bin/rails g spree_dev_tools:install
This adds Spree-specific test helpers to your spec/support/ directory, including:
stub_authorization!)When writing tests that involve file attachments (like images, PDFs, etc.), you need fixture files that your factories can use. Here's how to set them up.
mkdir -p spec/fixtures/files && printf '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82' > spec/fixtures/files/logo.png
bin/rails g rspec:model Spree::Brand
This creates spec/models/spree/brand_spec.rb.
Factories provide a convenient way to create test data. Create a factory for your Brand model:
FactoryBot.define do
factory :brand, class: Spree::Brand do
sequence(:name) { |n| "Brand #{n}" }
sequence(:slug) { |n| "brand-#{n}" }
trait :with_description do
description { '<div>A great brand for <strong>quality products</strong></div>' }
end
trait :with_logo do
after(:create) do |brand|
brand.logo.attach(
io: File.new(Rails.root.join('spec', 'fixtures', 'files', 'logo.png')),
filename: 'logo.png'
)
end
end
trait :with_products do
transient do
products_count { 3 }
store { nil }
end
after(:create) do |brand, evaluator|
store = evaluator.store || create(:store)
create_list(:product, evaluator.products_count, brand: brand, stores: [store])
end
end
end
end
# Basic factory
brand = create(:brand)
# With traits
brand = create(:brand, :with_description, :with_logo)
# With custom attributes
brand = create(:brand, name: 'Nike')
# Build without persisting (faster for unit tests)
brand = build(:brand)
# Create multiple records
brands = create_list(:brand, 5)
# With associated products
brand = create(:brand, :with_products)
brand = create(:brand, :with_products, products_count: 5)
Model tests verify your business logic, validations, associations, and scopes.
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 'validates uniqueness of slug' 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
describe 'FriendlyId' do
it 'generates slug from name' do
brand = create(:brand, name: 'Nike Sportswear', slug: nil)
expect(brand.slug).to eq('nike-sportswear')
end
it 'handles duplicate names by appending UUID' do
create(:brand, name: 'Nike', slug: 'nike')
brand = create(:brand, name: 'Nike', slug: nil)
expect(brand.slug).to match(/nike-[a-f0-9-]+/)
end
end
describe '#image' do
let(:brand) { create(:brand, :with_logo) }
it 'returns logo as image for Open Graph' do
expect(brand.image).to eq(brand.logo)
end
end
end
When you extend core Spree models with decorators (see Extending Core Models), test the added functionality:
require 'rails_helper'
RSpec.describe 'Spree::Product brand association' do
let(:store) { @default_store }
let(:brand) { create(:brand) }
let(:product) { create(:product, stores: [store]) }
describe 'brand association' do
it 'can be assigned a brand' do
product.brand = brand
product.save!
expect(product.reload.brand).to eq(brand)
end
it 'is optional' do
product.brand = nil
expect(product).to be_valid
end
end
describe 'brand.products' do
let!(:product1) { create(:product, brand: brand, stores: [store]) }
let!(:product2) { create(:product, brand: brand, stores: [store]) }
let!(:other_product) { create(:product, stores: [store]) }
it 'returns products for the brand' do
expect(brand.products).to contain_exactly(product1, product2)
end
it 'nullifies brand_id when brand is destroyed' do
brand.destroy
expect(product1.reload.brand_id).to be_nil
end
end
end
Controller tests verify that your endpoints respond correctly and perform the expected actions.
Test the API endpoints we created in the Store API tutorial. Store API tests use the 'API v3 Store' shared context which sets up a store, publishable API key, and JWT tokens.
require 'rails_helper'
RSpec.describe Spree::Api::V3::Store::BrandsController, type: :controller do
render_views
include_context 'API v3 Store'
let!(:brand1) { create(:brand, name: 'Nike') }
let!(:brand2) { create(:brand, name: 'Adidas') }
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(2)
end
it 'returns brand attributes' do
get :index
brand_data = json_response['data'].first
expect(brand_data).to include('id', 'name', 'slug')
end
it 'returns prefixed IDs' do
get :index
ids = json_response['data'].map { |b| b['id'] }
ids.each { |id| expect(id).to start_with('brand_') }
end
it 'returns pagination metadata' do
get :index, params: { page: 1, limit: 1 }
expect(json_response['data'].size).to eq(1)
expect(json_response['meta']).to include(
'page' => 1,
'limit' => 1,
'count' => 2,
'pages' => 2
)
end
it 'filters by name' do
get :index, params: { q: { name_cont: 'nik' } }
expect(json_response['data'].size).to eq(1)
expect(json_response['data'].first['name']).to eq('Nike')
end
it 'sorts by name' do
get :index, params: { sort: 'name' }
names = json_response['data'].map { |b| b['name'] }
expect(names).to eq(%w[Adidas Nike])
end
end
describe 'GET #show' do
it 'returns a brand by prefixed ID' do
get :show, params: { id: brand1.prefixed_id }
expect(response).to have_http_status(:ok)
expect(json_response['id']).to eq(brand1.prefixed_id)
expect(json_response['name']).to eq('Nike')
end
it 'returns a brand by slug' do
get :show, params: { id: brand1.slug }
expect(response).to have_http_status(:ok)
expect(json_response['name']).to eq('Nike')
end
it 'returns 404 for non-existent brand' do
get :show, params: { id: 'brand_nonexistent' }
expect(response).to have_http_status(:not_found)
end
end
describe 'GET #show with logo' do
let!(:brand_with_logo) { create(:brand, :with_logo) }
it 'includes logo_url when logo is attached' do
get :show, params: { id: brand_with_logo.prefixed_id }
expect(json_response['logo_url']).to be_present
end
end
end
Test that the custom Product serializer includes brand data:
require 'rails_helper'
RSpec.describe Spree::Api::V3::Store::ProductsController, type: :controller do
render_views
include_context 'API v3 Store'
let(:brand) { create(:brand, name: 'Nike') }
let!(:product) { create(:product, brand: brand, stores: [store], status: 'active') }
before do
request.headers['X-Spree-Api-Key'] = api_key.token
end
describe 'GET #show' do
it 'includes brand_id' do
get :show, params: { id: product.prefixed_id }
expect(json_response['brand_id']).to eq(brand.prefixed_id)
end
it 'does not include brand object without expand' do
get :show, params: { id: product.prefixed_id }
expect(json_response).not_to have_key('brand')
end
it 'includes brand object with expand=brand' do
get :show, params: { id: product.prefixed_id, expand: 'brand' }
expect(json_response['brand']).to be_present
expect(json_response['brand']['id']).to eq(brand.prefixed_id)
expect(json_response['brand']['name']).to eq('Nike')
end
end
describe 'GET #index' do
it 'filters products by brand_id' do
other_product = create(:product, stores: [store], status: 'active')
get :index, params: { q: { brand_id_eq: brand.id } }
ids = json_response['data'].map { |p| p['id'] }
expect(ids).to include(product.prefixed_id)
expect(ids).not_to include(other_product.prefixed_id)
end
end
end
require 'rails_helper'
RSpec.describe Spree::Admin::BrandsController, type: :controller do
stub_authorization!
render_views
describe 'GET #index' do
let!(:brand1) { create(:brand, name: 'Adidas') }
let!(:brand2) { create(:brand, name: 'Nike') }
it 'returns a successful response' do
get :index
expect(response).to be_successful
end
it 'displays all brands' do
get :index
expect(response.body).to include('Adidas')
expect(response.body).to include('Nike')
end
end
describe 'GET #new' do
it 'returns a successful response' do
get :new
expect(response).to be_successful
end
it 'displays the new brand form' do
get :new
expect(response.body).to include('brand[name]')
end
end
describe 'POST #create' do
context 'with valid params' do
let(:valid_params) do
{ brand: { name: 'New Brand' } }
end
it 'creates a new brand' do
expect {
post :create, params: valid_params
}.to change(Spree::Brand, :count).by(1)
end
it 'redirects to the edit page' do
post :create, params: valid_params
expect(response).to redirect_to(spree.edit_admin_brand_path(Spree::Brand.last))
end
end
context 'with invalid params' do
let(:invalid_params) do
{ brand: { name: '' } }
end
it 'does not create a new brand' do
expect {
post :create, params: invalid_params
}.not_to change(Spree::Brand, :count)
end
it 'returns unprocessable entity status' do
post :create, params: invalid_params
expect(response).to have_http_status(:unprocessable_content)
end
end
end
describe 'GET #edit' do
let(:brand) { create(:brand) }
it 'returns a successful response' do
get :edit, params: { id: brand.id }
expect(response).to be_successful
end
end
describe 'PUT #update' do
let(:brand) { create(:brand, name: 'Old Name') }
context 'with valid params' do
it 'updates the brand' do
put :update, params: { id: brand.id, brand: { name: 'New Name' } }
expect(brand.reload.name).to eq('New Name')
end
it 'redirects to the edit page' do
put :update, params: { id: brand.id, brand: { name: 'New Name' } }
expect(response).to redirect_to(spree.edit_admin_brand_path(brand))
end
end
end
describe 'DELETE #destroy' do
let!(:brand) { create(:brand) }
it 'removes the brand from the database' do
expect {
delete :destroy, params: { id: brand.id }, format: :html
}.to change(Spree::Brand, :count).by(-1)
end
end
end
Feature tests (also called system tests) simulate real user interactions using Capybara.
require 'rails_helper'
RSpec.feature 'Admin Brands', type: :feature do
stub_authorization!
describe 'listing brands' do
let!(:brand1) { create(:brand, name: 'Nike') }
let!(:brand2) { create(:brand, name: 'Adidas') }
it 'displays all brands' do
visit spree.admin_brands_path
expect(page).to have_content('Nike')
expect(page).to have_content('Adidas')
end
end
describe 'creating a brand' do
it 'creates a new brand 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
it 'shows validation errors' do
visit spree.new_admin_brand_path
click_on 'Create'
wait_for_turbo
expect(page).to have_content("can't be blank")
end
end
describe 'editing a brand' do
let!(:brand) { create(:brand, name: 'Nike') }
it 'updates the brand successfully' do
visit spree.admin_brands_path
click_on 'Edit'
fill_in 'Name', with: 'Nike Inc.'
within('#page-header') { click_button 'Update' }
wait_for_turbo
expect(page).to have_content('Brand "Nike Inc." has been successfully updated!')
expect(brand.reload.name).to eq('Nike Inc.')
end
end
describe 'deleting a brand' do
let!(:brand) { create(:brand, name: 'Nike') }
it 'removes the brand' do
expect {
page.driver.submit :delete, spree.admin_brand_path(brand), {}
}.to change(Spree::Brand, :count).by(-1)
end
end
end
Use stub_authorization! to bypass authorization checks in admin tests:
RSpec.describe Spree::Admin::BrandsController, type: :controller do
stub_authorization! # Grants full admin access
# ... your tests
end
When testing with Turbo/Hotwire, use wait_for_turbo to ensure the page has fully loaded:
click_on 'Create'
wait_for_turbo
expect(page).to have_content('Success!')
# Run all tests
bundle exec rspec
# Run specific test file
bundle exec rspec spec/models/spree/brand_spec.rb
# Run specific test
bundle exec rspec spec/models/spree/brand_spec.rb:15
# Run with documentation format
bundle exec rspec --format documentation
# Run only feature tests
bundle exec rspec spec/features/
it 'creates brand with all attributes', :aggregate_failures do
brand = create(:brand, name: 'Nike')
expect(brand.name).to eq('Nike')
expect(brand.slug).to eq('nike')
end
After completing this tutorial, your test structure should look like:
spec/
├── factories/
│ └── spree/
│ └── brand_factory.rb
├── models/
│ └── spree/
│ ├── brand_spec.rb
│ └── product_decorator_spec.rb
├── controllers/
│ └── spree/
│ ├── admin/
│ │ └── brands_controller_spec.rb
│ └── api/
│ └── v3/
│ └── store/
│ ├── brands_controller_spec.rb
│ └── products_brand_spec.rb
├── features/
│ └── spree/
│ └── admin/
│ └── brands_spec.rb
├── support/
│ └── (various support files)
├── rails_helper.rb
└── spec_helper.rb