DEV Community

Cover image for Rails Testing for Financial Operations
Sulman Baig
Sulman Baig

Posted on • Originally published at sulmanweb.com

Rails Testing for Financial Operations

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
Enter fullscreen mode Exit fullscreen mode

Testing Financial Transactions

When testing financial transactions, we need to verify several aspects:

  1. Balance updates
  2. Transaction records
  3. Audit logs
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Best Practices and Recommendations

  1. Always test in a transaction block to ensure database cleanliness
  2. Use factory_bot for test data setup
  3. Test both happy and unhappy paths
  4. Verify audit logs for sensitive operations
  5. Use decimal for money calculations to avoid floating-point errors
  6. Test currency conversions with known exchange rates
  7. Verify transaction atomicity in transfers
  8. 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)