API (Ruby) Tests
Ruby Tests at CaterCow
At CaterCow, we employ a multi-layered testing strategy to ensure our codebase is robust, reliable, and easy to maintain. Our approach focuses on testing the right things at the right level, from high-level API requests down to specific business logic units. This ensures comprehensive coverage without unnecessary duplication.
Our primary API testing layers are:
- Commander Tests: For complex business actions.
- Request Tests: For the API lifecycle.
- Model/Unit Tests: For critical helper methods and service objects.
Commanders
Nearly all actions in our application that go beyond simple Create, Read, Update, Delete (CRUD) operations are encapsulated in special service objects that follow the Command pattern. These "Commanders" are responsible for executing a single, specific business process and handling its associated logic, validations, and side effects.
Testing Approach
Because Commanders house critical and often complex business logic, they receive our most comprehensive test coverage. We write extensive tests to cover:
- The successful "happy path" execution.
- All expected failure scenarios and error conditions.
- Various edge cases that might not be immediately obvious. This is particularly helpful in refactors to prevent regressions.
- The transactional nature of the command, ensuring it can be rolled back (
down) if something fails.
Example: Generating an Order Payout
One simple example is the GenerateOrderPayout commander, which creates a payout record for a caterer after an order is complete.
The Commander itself defines the up (execution) and down (rollback) methods, along with critical validations.
class GenerateOrderPayout < Commander
attr_reader :payout
## Error Messages
ERRORS = {
order_not_accepted: 'The order must be accepted to generate payouts',
balance_not_positive: 'Nothing to pay out!'
}.freeze
## Argument Types
enforce_arguments!(
order: 'Order'
)
def prepare(order:, amount: order.caterer_payout_balance, **_opts)
@order = order
@amount = amount
end
def up(order:, payout_fields: {}, **_opts)
# ... (payout creation logic)
@payout = order.payouts.create!(payout_attributes)
end
def down
@payout.destroy!
end
protected
## Validations
def validate_conditions!(order:, **_opts)
condition!(:order_not_accepted) { order.accepted_at? }
condition!(:balance_not_positive) { @amount&.positive? }
end
endThe corresponding test file ensures that this Commander behaves exactly as expected under different conditions.
- It tests the successful creation of a
Payout. - It verifies that the
order.caterer_payout_balanceis correctly updated. - It confirms that the Commander fails appropriately if the order is not in an
acceptedstate.- Ensure no payouts have been created
- Ensure the correct error code (
generate_order_payout--order_not_accepted) is returned
require 'rails_helper'
describe GenerateOrderPayout, type: :commander do
let!(:order) { create(:order, :accepted, ...) }
let!(:caterer_payout_balance) { order.caterer_payout_balance }
subject { described_class.run(order: order) }
it do
is_expected.to be_success
expect(subject.payout).to be_a(Payout)
expect(subject.payout.amount).to eq(caterer_payout_balance)
end
it do
expect { subject && order.reload }
.to change(order.payouts, :count).by(1)
.and change(order, :caterer_payout_balance).to(0)
end
context 'for a pending order' do
let(:order_status) { :pending }
it { is_expected.to fail_condition(:order_not_accepted) }
it do
expect { subject && order.reload }
.to not_change(order.payouts, :count)
end
end
endRequest Tests
Request specs test the entire API request lifecycle from end to end. They are our primary tool for verifying that our controllers, routing, and views (serializers) all work together correctly.
Testing Approach
Given a URL and a set of parameters, request specs validate that the application:
- Responds with the correct HTTP status code.
- Returns the expected JSON payload.
- Makes the appropriate changes to the database.
These tests cover the main "happy path" use cases for each endpoint (typically 1-3 tests for 2xx responses) and key failure scenarios (1-2 tests for 4xx responses). They are less exhaustive than Commander tests because the underlying business logic is already tested there.
A unique aspect of our request specs is that they also generate our API documentation. We use metaprogramming and controller introspection to define parameters, filters, and ordering fields, which are then used to build a Swagger/OpenAPI specification.
Example: Draft Order Management
The draft_order_management_spec.rb file tests the API endpoints for managing draft orders. Notice the path and post blocks, which are part of the rswag gem and used for documentation generation.
require 'rails_helper'
describe 'Draft Order Management', type: :request, swagger_doc: 'cpi/v2/orderer.json' do
let(:current_user) { user }
let(:user) { create(:user, :orderer) }
before { authenticate_as(current_user) }
path '/cpi/v2/orderer/draft_orders' do
describe 'POST #create' do
let(:create_params) { to_ids(attributes_for(:draft_order, headcount: 30, ...)) }
let(:params) { {draft_order: create_params} }
subject { send_request :post, "/cpi/v2/orderer/draft_orders", params }
it do
is_expected.to be_successful
is_expected.to match_json('id', 'headcount' => 30)
end
post 'Create new draft order' do
define_endpoint(...)
response '200', 'Returns newly created draft order' do
include_context 'with integration test'
end
end
end
end
path '/cpi/v2/orderer/draft_orders/{id}' do
let!(:draft_order) { create(:draft_order, user: user, headcount: 20) }
describe 'DELETE #destroy' do
subject { send_request :delete, "/cpi/v2/orderer/draft_orders/#{draft_order.id}" }
it { is_expected.to return_no_content }
it { expect { subject }.to change(DraftOrder, :count).by(-1) }
delete 'Delete a draft order' do
define_endpoint(...)
response '204', 'Successful deletion' do
include_context 'with integration test'
end
end
end
end
endThese specs are powered by declarations in the controller, such as register_params_set and association_filters, which keep our documentation in sync with the actual implementation.
class Cpi::V2::Orderer::DraftOrdersController < Cpi::V2::OrdererController
# ...
# Params
DRAFT_ORDER_PARAMS = {
start_date: :date,
start_time: :time,
headcount: :integer,
# ...
}
register_params_set :draft_order_params, DRAFT_ORDER_PARAMS, required_within: :draft_order
# Filters
association_filters :user, :team
date_time_filters :start_date
# ...
endModel/Unit Tests
While the bulk of our business logic lives in Commanders, some complex calculations or helper functions are better placed on models or in separate service objects. These are prime candidates for traditional unit tests.
Testing Approach
We write model or unit tests for functions that are:
- Heavily used throughout the application.
- Responsible for complex calculations (e.g., pricing).
- Pure functions with no external dependencies, making them easy to test in isolation.
This layer of testing is more focused and granular, allowing us to test a wide variety of inputs and outputs for a specific piece of logic without the overhead of the full request stack.
Example: Order Price Calculator
The OrderPriceCalculator is a service object dedicated to calculating all price components of an order. Given its complexity and importance, it has a detailed unit test file that verifies each component of the final price.
The spec file contains numerous contexts to test specific scenarios like the inclusion of tips, delivery fees, taxes, and utensils.
require 'rails_helper'
describe OrderPriceCalculator, type: :service do
let(:headcount) { 20 }
let!(:order) { create(:order, headcount: headcount, ...) }
let(:instance) { OrderPriceCalculator.new(order) }
# Base expectations, for better testing against modifications
let(:expected_feeable_price) { 258 }
let(:expected_order_items_price) { 258 }
describe 'base tests' do
it { expect(instance.feeable_price).to eq(expected_feeable_price) }
it { expect(instance.order_items_price).to eq(expected_order_items_price) }
end
describe 'tips' do
context 'without any tips' do
it { expect(instance.gross_tip_amount).to eq(0) }
end
context 'with a tip' do
let!(:tip) { create(:tip, order: order, amount: 50) }
it { expect(instance.gross_tip_amount).to eq(50) }
it { expect(instance.caterer_tip_fee).to eq(15) }
it { expect(instance.net_tip_amount).to eq(35) }
end
end
describe 'utensils' do
let(:place_settings_price) { 0.25 }
let(:expected_place_settings_cost) { 5.0 }
context 'with place settings only' do
let(:include_place_settings) { true }
it { expect(instance.place_settings_cost).to eq(expected_place_settings_cost) }
it { expect(instance.utensils_price).to eq(expected_place_settings_cost) }
it { expect(instance.feeable_price).to eq(expected_feeable_price + expected_place_settings_cost) }
end
end
end