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.
The Foundation: Service Objects and RSpec
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
Testing Financial Transactions
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
Testing Money Transfers
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
Shared Examples for Common Behaviors
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
Testing Edge Cases
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
Testing Currency Conversions
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
Testing GraphQL Mutations
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
Best Practices and Recommendations
- 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
Conclusion
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!
Originally published at https://sulmanweb.com.
Top comments (0)