Testing XRPL Applications | XRPL Development 101 | XRP Academy - XRP Academy
3 free lessons remaining this month

Free preview access resets monthly

Upgrade for Unlimited
Skip to main content
intermediate•50 min

Testing XRPL Applications

Learning Objectives

Design testable XRPL code with proper separation and dependency injection

Create effective mocks for XRPL client responses

Write integration tests against testnet with proper setup and teardown

Test error handling for all XRPL failure modes

Build a comprehensive test suite for production confidence

  • Database transactions can be rolled back
  • Errors are recoverable
  • Test data can be reset easily
  • Transactions are permanent (even on testnet)
  • Network latency affects test timing
  • Testnet has limited faucet funds
  • Some behaviors only occur under specific conditions

Strategy: Layer your tests. Mock for speed and isolation; use testnet for realistic verification.


// ❌ HARD TO TEST - Direct dependency
class PaymentService {
    async sendPayment(destination, amount) {
        const client = new xrpl.Client('wss://...');  // Hardcoded
        await client.connect();
        // ...
    }
}

// âś… TESTABLE - Injected dependency
class PaymentService {
constructor(xrplClient) {
this.client = xrplClient; // Injected
}

async sendPayment(destination, amount) {
// Use this.client - can be real or mock
}
}

// Production
const realClient = new xrpl.Client('wss://...');
const service = new PaymentService(realClient);

// Test
const mockClient = createMockClient();
const service = new PaymentService(mockClient);
```

// src/services/payment-logic.js

// Pure functions - easy to test, no network
class PaymentLogic {
// Pure validation - no I/O
static validateAmount(amount, limits) {
if (amount < limits.min) {
return { valid: false, error: Below minimum: ${limits.min} };
}
if (amount > limits.max) {
return { valid: false, error: Above maximum: ${limits.max} };
}
return { valid: true };
}

// Pure calculation
static calculateFee(baseFee, loadFactor) {
return Math.ceil(baseFee * loadFactor * 1.2);
}

// Pure transaction building
static buildPaymentTransaction(sender, destination, amountDrops, options = {}) {
const tx = {
TransactionType: 'Payment',
Account: sender,
Destination: destination,
Amount: amountDrops.toString()
};

if (options.destinationTag !== undefined) {
tx.DestinationTag = options.destinationTag;
}

return tx;
}

// Pure result parsing
static parseTransactionResult(result) {
const code = result.meta?.TransactionResult;
return {
success: code === 'tesSUCCESS',
code,
delivered: result.meta?.delivered_amount
};
}
}

module.exports = PaymentLogic;
```

tests/
├── unit/
│   ├── payment-logic.test.js    # Pure function tests
│   ├── validation.test.js       # Input validation
│   └── parsing.test.js          # Response parsing
├── integration/
│   ├── payment.test.js          # Full payment flow
│   ├── balance.test.js          # Balance queries
│   └── connection.test.js       # Connection handling
├── e2e/
│   └── api.test.js              # Full API tests
├── fixtures/
│   ├── transactions.js          # Sample transaction data
│   └── responses.js             # Sample XRPL responses
├── helpers/
│   ├── mock-client.js           # Mock XRPL client
│   └── testnet-setup.js         # Testnet account setup
└── setup.js                     # Test configuration

// tests/helpers/mock-client.js

class MockXRPLClient {
constructor(responses = {}) {
this.responses = responses;
this.requestHistory = [];
this.isConnected = false;
}

async connect() {
this.isConnected = true;
return true;
}

async disconnect() {
this.isConnected = false;
}

async request(params) {
this.requestHistory.push(params);

// Check for predefined responses
const responseKey = this.getResponseKey(params);
if (this.responses[responseKey]) {
const response = this.responses[responseKey];
if (response.error) {
throw response.error;
}
return response;
}

// Default responses
return this.getDefaultResponse(params);
}

getResponseKey(params) {
if (params.account) {
return ${params.command}:${params.account};
}
return params.command;
}

getDefaultResponse(params) {
switch (params.command) {
case 'account_info':
return {
result: {
account_data: {
Account: params.account,
Balance: '100000000000', // 100,000 XRP
Sequence: 1,
OwnerCount: 0
}
}
};

case 'server_info':
return {
result: {
info: {
validated_ledger: {
seq: 12345678,
base_fee_xrp: 0.00001
},
load_factor: 1
}
}
};

case 'submit':
return {
result: {
engine_result: 'tesSUCCESS',
tx_json: {
hash: 'MOCK_HASH_' + Date.now()
}
}
};

default:
throw new Error(No mock for command: ${params.command});
}
}

// Helpers for test assertions
getRequestCount() {
return this.requestHistory.length;
}

getLastRequest() {
return this.requestHistory[this.requestHistory.length - 1];
}

wasCommandCalled(command) {
return this.requestHistory.some(r => r.command === command);
}

// Configure specific responses
setResponse(key, response) {
this.responses[key] = response;
}

setError(key, error) {
this.responses[key] = { error };
}
}

function createMockClient(responses = {}) {
return new MockXRPLClient(responses);
}

module.exports = { MockXRPLClient, createMockClient };
```

// tests/unit/payment-logic.test.js
const { describe, it, expect } = require('@jest/globals');
const PaymentLogic = require('../../src/services/payment-logic');

describe('PaymentLogic', () => {
describe('validateAmount', () => {
const limits = { min: 0.001, max: 1000 };

it('accepts valid amounts', () => {
expect(PaymentLogic.validateAmount(1, limits).valid).toBe(true);
expect(PaymentLogic.validateAmount(0.001, limits).valid).toBe(true);
expect(PaymentLogic.validateAmount(1000, limits).valid).toBe(true);
});

it('rejects amounts below minimum', () => {
const result = PaymentLogic.validateAmount(0.0001, limits);
expect(result.valid).toBe(false);
expect(result.error).toContain('minimum');
});

it('rejects amounts above maximum', () => {
const result = PaymentLogic.validateAmount(1001, limits);
expect(result.valid).toBe(false);
expect(result.error).toContain('maximum');
});
});

describe('calculateFee', () => {
it('calculates fee with load factor', () => {
const baseFee = 10;
const loadFactor = 1;
const fee = PaymentLogic.calculateFee(baseFee, loadFactor);
expect(fee).toBe(12); // 10 * 1 * 1.2 = 12
});

it('scales with load factor', () => {
const baseFee = 10;
const fee = PaymentLogic.calculateFee(baseFee, 2);
expect(fee).toBe(24); // 10 * 2 * 1.2 = 24
});

it('rounds up to integer', () => {
const fee = PaymentLogic.calculateFee(10, 1.1);
expect(Number.isInteger(fee)).toBe(true);
});
});

describe('buildPaymentTransaction', () => {
it('builds basic payment', () => {
const tx = PaymentLogic.buildPaymentTransaction(
'rSender...',
'rDestination...',
1000000
);

expect(tx.TransactionType).toBe('Payment');
expect(tx.Account).toBe('rSender...');
expect(tx.Destination).toBe('rDestination...');
expect(tx.Amount).toBe('1000000');
});

it('includes destination tag when provided', () => {
const tx = PaymentLogic.buildPaymentTransaction(
'rSender...',
'rDestination...',
1000000,
{ destinationTag: 12345 }
);

expect(tx.DestinationTag).toBe(12345);
});

it('excludes destination tag when not provided', () => {
const tx = PaymentLogic.buildPaymentTransaction(
'rSender...',
'rDestination...',
1000000
);

expect(tx.DestinationTag).toBeUndefined();
});
});

describe('parseTransactionResult', () => {
it('identifies successful transactions', () => {
const result = PaymentLogic.parseTransactionResult({
meta: { TransactionResult: 'tesSUCCESS' }
});
expect(result.success).toBe(true);
});

it('identifies failed transactions', () => {
const result = PaymentLogic.parseTransactionResult({
meta: { TransactionResult: 'tecUNFUNDED_PAYMENT' }
});
expect(result.success).toBe(false);
expect(result.code).toBe('tecUNFUNDED_PAYMENT');
});
});
});
```

// tests/unit/payment-service.test.js
const { describe, it, expect, beforeEach } = require('@jest/globals');
const { createMockClient } = require('../helpers/mock-client');
const PaymentService = require('../../src/services/payment');

describe('PaymentService', () => {
let mockClient;
let service;

beforeEach(() => {
mockClient = createMockClient();
service = new PaymentService(mockClient);
});

describe('getBalance', () => {
it('returns balance information', async () => {
const balance = await service.getBalance('rAddress...');

expect(balance.balance).toBe(100000); // 100,000 XRP
expect(balance.reserve).toBe(10); // Base reserve
expect(balance.available).toBe(99990);
});

it('accounts for owner reserve', async () => {
mockClient.setResponse('account_info:rAddress...', {
result: {
account_data: {
Balance: '50000000000',
OwnerCount: 5
}
}
});

const balance = await service.getBalance('rAddress...');

expect(balance.reserve).toBe(20); // 10 + (5 * 2)
});

it('throws for non-existent accounts', async () => {
mockClient.setError('account_info:rBadAddress...', {
data: { error: 'actNotFound' }
});

await expect(service.getBalance('rBadAddress...'))
.rejects.toThrow();
});
});

describe('validatePayment', () => {
it('passes for valid payments', async () => {
await expect(service.validatePayment('rValid...', 100))
.resolves.not.toThrow();
});

it('fails for insufficient balance', async () => {
mockClient.setResponse('account_info:rPoor...', {
result: {
account_data: {
Balance: '15000000', // 15 XRP
OwnerCount: 0
}
}
});

await expect(service.validatePayment('rPoor...', 10))
.rejects.toThrow('Insufficient balance');
});
});
});
```


// tests/helpers/testnet-setup.js
const xrpl = require('xrpl');

const TESTNET_URL = 'wss://s.altnet.rippletest.net:51233';

class TestnetSetup {
constructor() {
this.client = null;
this.accounts = [];
}

async initialize() {
this.client = new xrpl.Client(TESTNET_URL);
await this.client.connect();
}

async createFundedAccount() {
const wallet = xrpl.Wallet.generate();

// Fund from faucet
await this.client.fundWallet(wallet);

// Verify funding
const info = await this.client.request({
command: 'account_info',
account: wallet.address
});

const balance = Number(info.result.account_data.Balance) / 1_000_000;
console.log(Created testnet account ${wallet.address} with ${balance} XRP);

this.accounts.push(wallet);
return wallet;
}

async cleanup() {
// Note: Can't delete testnet accounts, but they'll expire eventually
this.accounts = [];

if (this.client) {
await this.client.disconnect();
}
}

getClient() {
return this.client;
}
}

module.exports = { TestnetSetup, TESTNET_URL };
```

// tests/integration/payment.test.js
const xrpl = require('xrpl');
const { TestnetSetup } = require('../helpers/testnet-setup');

describe('Payment Integration Tests', () => {
let setup;
let sender;
let recipient;

beforeAll(async () => {
setup = new TestnetSetup();
await setup.initialize();

// Create test accounts (this takes time - faucet calls)
sender = await setup.createFundedAccount();
recipient = await setup.createFundedAccount();
}, 60000); // 60 second timeout for setup

afterAll(async () => {
await setup.cleanup();
});

it('sends XRP payment successfully', async () => {
const client = setup.getClient();

// Get initial balances
const initialSender = await getBalance(client, sender.address);
const initialRecipient = await getBalance(client, recipient.address);

// Send payment
const payment = {
TransactionType: 'Payment',
Account: sender.address,
Destination: recipient.address,
Amount: xrpl.xrpToDrops(10)
};

const prepared = await client.autofill(payment);
const signed = sender.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);

expect(result.result.meta.TransactionResult).toBe('tesSUCCESS');

// Verify balances changed
const finalSender = await getBalance(client, sender.address);
const finalRecipient = await getBalance(client, recipient.address);

expect(finalSender).toBeLessThan(initialSender - 10); // Sent 10 + fee
expect(finalRecipient).toBe(initialRecipient + 10);
}, 30000);

it('fails payment with insufficient funds', async () => {
const client = setup.getClient();

// Try to send more than balance
const payment = {
TransactionType: 'Payment',
Account: sender.address,
Destination: recipient.address,
Amount: xrpl.xrpToDrops(1000000) // 1M XRP - way more than balance
};

const prepared = await client.autofill(payment);
const signed = sender.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);

expect(result.result.meta.TransactionResult).toBe('tecUNFUNDED_PAYMENT');
}, 30000);

it('handles destination tag correctly', async () => {
const client = setup.getClient();
const destTag = 12345;

const payment = {
TransactionType: 'Payment',
Account: sender.address,
Destination: recipient.address,
Amount: xrpl.xrpToDrops(1),
DestinationTag: destTag
};

const prepared = await client.autofill(payment);
const signed = sender.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);

expect(result.result.meta.TransactionResult).toBe('tesSUCCESS');

// Verify destination tag in transaction
const tx = await client.request({
command: 'tx',
transaction: signed.hash
});

expect(tx.result.DestinationTag).toBe(destTag);
}, 30000);
});

async function getBalance(client, address) {
const info = await client.request({
command: 'account_info',
account: address
});
return Number(info.result.account_data.Balance) / 1_000_000;
}
```

// tests/integration/error-handling.test.js
const xrpl = require('xrpl');
const { TestnetSetup } = require('../helpers/testnet-setup');

describe('Error Handling Tests', () => {
let setup;
let wallet;

beforeAll(async () => {
setup = new TestnetSetup();
await setup.initialize();
wallet = await setup.createFundedAccount();
}, 60000);

afterAll(async () => {
await setup.cleanup();
});

it('handles non-existent destination', async () => {
const client = setup.getClient();
const fakeDestination = 'rNonExistent12345678901234567';

const payment = {
TransactionType: 'Payment',
Account: wallet.address,
Destination: fakeDestination,
Amount: xrpl.xrpToDrops(1) // Less than reserve
};

const prepared = await client.autofill(payment);
const signed = wallet.sign(prepared);
const result = await client.submitAndWait(signed.tx_blob);

// Should fail - can't create account with <10 XRP
expect(result.result.meta.TransactionResult).toBe('tecNO_DST_INSUF_XRP');
}, 30000);

it('handles sequence number conflicts', async () => {
const client = setup.getClient();

// Get current sequence
const info = await client.request({
command: 'account_info',
account: wallet.address
});
const currentSeq = info.result.account_data.Sequence;

// Create transaction with old sequence
const payment = {
TransactionType: 'Payment',
Account: wallet.address,
Destination: wallet.address, // Self-payment
Amount: '1',
Sequence: currentSeq - 1, // Old sequence
Fee: '12',
LastLedgerSequence: info.result.ledger_index + 20
};

const signed = wallet.sign(payment);

try {
await client.submit(signed.tx_blob);
} catch (error) {
expect(error.data.engine_result).toBe('tefPAST_SEQ');
}
});

it('handles connection timeout gracefully', async () => {
// Create client with very short timeout
const timeoutClient = new xrpl.Client('wss://s.altnet.rippletest.net:51233', {
timeout: 1 // 1ms - will timeout
});

await expect(timeoutClient.connect())
.rejects.toThrow();
});
});
```


// Use descriptive test names that document behavior
describe('PaymentService', () => {
    describe('when sending a payment', () => {
        describe('with valid parameters', () => {
            it('submits transaction to network', async () => {});
            it('returns transaction hash', async () => {});
            it('deducts from sender balance', async () => {});
        });

describe('with insufficient balance', () => {
it('throws InsufficientBalanceError', async () => {});
it('does not submit transaction', async () => {});
});

describe('with invalid destination', () => {
it('throws InvalidAddressError', async () => {});
});
});
});
```

// tests/fixtures/transactions.js

const fixtures = {
successfulPayment: {
result: {
meta: {
TransactionResult: 'tesSUCCESS',
delivered_amount: '10000000'
},
validated: true,
ledger_index: 12345678
}
},

failedPayment: {
result: {
meta: {
TransactionResult: 'tecUNFUNDED_PAYMENT'
},
validated: true
}
},

pendingPayment: {
result: {
engine_result: 'tesSUCCESS',
tx_json: {
hash: 'ABCD1234...'
}
}
}
};

// Factory for creating test transactions
function createPaymentFixture(overrides = {}) {
return {
TransactionType: 'Payment',
Account: 'rSender...',
Destination: 'rRecipient...',
Amount: '10000000',
...overrides
};
}

module.exports = { fixtures, createPaymentFixture };
```

// Aim for coverage of:

const coverageTargets = {
    // Happy paths
    happyPaths: [
        'Successful payment submission',
        'Balance query',
        'Transaction confirmation'
    ],

// Error conditions
    errors: [
        'Insufficient balance',
        'Invalid destination',
        'Network timeout',
        'Rate limiting',
        'Sequence conflicts'
    ],

// Edge cases
    edgeCases: [
        'Exactly minimum amount',
        'Exactly maximum amount',
        'Zero-value transactions (rejected)',
        'Self-payments',
        'Concurrent payments'
    ],

// Recovery scenarios
    recovery: [
        'Connection lost mid-transaction',
        'Timeout during confirmation',
        'Retry after failure'
    ]
};

Tests provide confidence, not certainty. A comprehensive test suite catches most issues before production, but some bugs only appear under real-world conditions. Test thoroughly, but also build robust monitoring and error handling for production.


Assignment: Build a complete test suite for an XRPL payment application.

Requirements:

  • Test pure logic functions

  • Mock XRPL client for service tests

  • Cover validation logic

  • Test error parsing

  • Set up testnet accounts

  • Test real payment flow

  • Test error conditions

  • Clean up after tests

  • Mock client helper

  • Fixtures for common data

  • Testnet setup/teardown

  • All happy paths

  • Key error conditions

  • At least one edge case per function

  • Unit tests comprehensive (30%)

  • Integration tests working (30%)

  • Test infrastructure reusable (20%)

  • Coverage adequate (20%)

Time investment: 4 hours
Value: Confidence in code quality before production


Knowledge Check

Question 1 of 1

What makes XRPL code more testable?

  • Jest documentation
  • Integration testing patterns
  • Test-driven development (TDD)

For Next Lesson:
You now know how to test your code. Lesson 19 covers deployment—taking your tested application to production on mainnet.


End of Lesson 18

Total words: ~4,500
Estimated completion time: 50 minutes reading + 4 hours for deliverable

Key Takeaways

1

Layer your tests

: Unit tests with mocks for speed, integration tests on testnet for realism.

2

Design for testability

: Dependency injection and pure functions make testing easier.

3

Test error paths

: Happy paths are obvious; bugs hide in error handling.

4

Use testnet liberally

: It's free, realistic, and the only way to test actual network behavior.

5

Tests are documentation

: Well-written tests explain how code should behave. ---