Rails Testing for Financial Operations

Senior Software Engineer with 12 years of expertise in Ruby on Rails and Vue.js, specializing in health, e-commerce, staffing, and transport. Experienced in software development and version analysis.
Search for a command to run...

Senior Software Engineer with 12 years of expertise in Ruby on Rails and Vue.js, specializing in health, e-commerce, staffing, and transport. Experienced in software development and version analysis.
No comments yet. Be the first to comment.
Motivation After building several Shopify embedded apps, I've learned valuable lessons about what works (and what definitely doesn't) in the embedded app environment. Today, I'm sharing these insights to help you avoid common pitfalls and build bette...

As a developer working with Shopify's ecosystem, I recently built a multi-tenant SaaS application that synchronizes customer data between Shopify stores and external services. In this article, I'll share my experience and technical insights into crea...

As developers, we're always looking for ways to streamline our deployment process while maintaining security and reliability. Today, I'm excited to share my experience setting up an automated deployment pipeline for a Rails application using Dokku an...

The Challenge: Modernizing Rails Deployment When I recently needed to deploy my Rails 8 application with multiple databases, I faced a common dilemma: choosing between expensive managed solutions and complex self-managed servers. My requirements were...

The Cost-Benefit Realization As a Rails developer who recently migrated from Heroku to Dokku, I want to share my journey and the surprising benefits I discovered. This transition wasn't just about cost savings—it opened up new possibilities for my de...

Testing financial applications requires exceptional attention to detail and robust test coverage. In this article, we'll explore advanced testing patterns for financial operations using a real-world Rails application. We'll cover transaction testing, balance validations, and audit logging verification.
Our financial application uses a service-object pattern to encapsulate business logic. Here's how we structure our tests:
RSpec.describe Transactions::CreateService do
subject(:service) { described_class.new(params) }
let(:user) { create(:user) }
let(:account) { create(:account, balance: 100, user: user) }
let(:params) do
{
user_id: user.id,
account_id: account.id,
amount: 50,
transaction_type: 'expense'
}
end
end
When testing financial transactions, we need to verify several aspects:
Balance updates
Transaction records
Audit logs
Error handling
Here's a comprehensive test example:
describe '#call' do
context 'when creating an expense transaction' do
it 'updates the account balance correctly' do
result = service.call
expect(result).to be_success
expect(account.reload.balance).to eq(50) # 100 - 50
end
it 'creates an audit log' do
expect { service.call }
.to change(AuditLog, :count).by(1)
end
end
context 'when creating an income transaction' do
let(:params) do
{
user_id: user.id,
account_id: account.id,
amount: 50,
transaction_type: 'income'
}
end
it 'increases the account balance' do
result = service.call
expect(result).to be_success
expect(account.reload.balance).to eq(150) # 100 + 50
end
end
end
Transfer operations are particularly critical as they involve multiple accounts. Here's how we test them:
RSpec.describe Transactions::TransferService do
let(:user) { create(:user) }
let(:account_from) { create(:account, user: user, balance: 1000) }
let(:account_to) { create(:account, user: user, balance: 0) }
let(:params) do
{
user_id: user.id,
account_from_id: account_from.id,
account_to_id: account_to.id,
amount: 100
}
end
describe '#call' do
it 'transfers money between accounts' do
service = described_class.new(params)
result = service.call
expect(result).to be_success
expect(account_from.reload.balance).to eq(900)
expect(account_to.reload.balance).to eq(100)
end
it 'creates two transactions' do
expect { service.call }
.to change(Transaction, :count).by(2)
end
end
end
To maintain DRY tests, we use shared examples for common behaviors:
RSpec.shared_examples 'an audit log' do |service_call, should_create|
if should_create
it 'creates an audit log' do
expect { instance_exec(&service_call) }
.to change(AuditLog, :count).by(1)
end
else
it 'does not create an audit log' do
expect { instance_exec(&service_call) }
.not_to change(AuditLog, :count)
end
end
end
# Usage in specs
describe 'successful transaction' do
include_examples 'an audit log',
-> { service.call },
true
end
Financial applications must handle edge cases gracefully:
describe 'edge cases' do
context 'with insufficient funds' do
let(:account) { create(:account, balance: 10) }
let(:params) do
{
user_id: user.id,
account_id: account.id,
amount: 100,
transaction_type: 'expense'
}
end
it 'fails the transaction' do
result = service.call
expect(result).not_to be_success
expect(account.reload.balance).to eq(10)
end
end
context 'with invalid amounts' do
let(:params) do
{
user_id: user.id,
account_id: account.id,
amount: -50,
transaction_type: 'expense'
}
end
it 'rejects negative amounts' do
result = service.call
expect(result).not_to be_success
end
end
end
When dealing with multiple currencies, we need to test conversion accuracy:
RSpec.describe Currency do
let(:usd) { create(:currency, code: 'USD', amount: 1.0) }
let(:eur) { create(:currency, code: 'EUR', amount: 0.85) }
describe 'currency conversion' do
it 'converts amounts correctly' do
account = create(:account, currency: eur, balance: 100)
# Verify USD equivalent
expect(account.balance_in_usd).to eq(117.65) # 100 / 0.85
end
end
end
Our financial operations are exposed via GraphQL. Here's how we test them:
RSpec.describe Mutations::TransactionCreate do
let(:user) { create(:user) }
let(:account) { create(:account, user: user) }
let(:query) do
<<~GQL
mutation($input: TransactionCreateInput!) {
transactionCreate(input: $input) {
success
transaction { id amount }
}
}
GQL
end
it 'creates a transaction through GraphQL' do
variables = {
input: {
accountId: account.id,
amount: 100,
transactionType: 'EXPENSE'
}
}
post '/graphql', params: {
query: query,
variables: variables.to_json
}, headers: auth_headers(user)
expect(response.parsed_body['data']['transactionCreate'])
.to include('success' => true)
end
end
Always test in a transaction block to ensure database cleanliness
Use factory_bot for test data setup
Test both happy and unhappy paths
Verify audit logs for sensitive operations
Use decimal for money calculations to avoid floating-point errors
Test currency conversions with known exchange rates
Verify transaction atomicity in transfers
Test authorization and authentication
Testing financial operations requires a comprehensive approach that covers not just the happy path but also edge cases, error conditions, and audit requirements. By following these patterns and practices, you can build reliable financial applications with confidence in their correctness.
Remember that financial data is critical, and tests are your first line of defense against bugs and inconsistencies. Take the time to write thorough tests, and your future self (and your users) will thank you.
This testing approach has been battle-tested in production environments and provides a solid foundation for building robust financial applications. The key is to think about all possible scenarios and edge cases while maintaining clean, readable, and maintainable tests.
Happy Coding!