Testing Strategies
Learning Objectives
Structure test suites for XRPL integrations with appropriate coverage
Mock XRPL responses for fast, isolated unit testing
Test against testnet for realistic integration validation
Simulate failure scenarios to verify error handling
Load test to validate performance under stress
/\
/ \
/ E2E \ ← Real transactions on testnet
/________\ Slowest, most realistic
/ \
/ Integration \ ← API calls to testnet
/______________\ Moderate speed
/ \
/ Unit Tests \ ← Mocked responses
/____________________\ Fastest, most isolatedtests/
├── unit/
│ ├── validation.test.js # Input validation
│ ├── amount-parsing.test.js # Currency handling
│ ├── error-handling.test.js # Error classification
│ └── payment-logic.test.js # Business logic
├── integration/
│ ├── connection.test.js # WebSocket handling
│ ├── queries.test.js # API queries
│ └── transactions.test.js # Transaction submission
├── e2e/
│ ├── payment-flow.test.js # Complete payment scenarios
│ └── trading-flow.test.js # DEX operations
├── load/
│ └── throughput.test.js # Performance under load
└── mocks/
└── xrpl-responses.js # Shared mock dataclass MockXRPLClient {
constructor() {
this.responses = new Map()
this.calls = []
this.connected = false
}
// Setup expected responses
mockResponse(command, response) {
this.responses.set(command, response)
}
mockResponseForAccount(command, account, response) {
const key = ${command}:${account}
this.responses.set(key, response)
}
async connect() {
this.connected = true
}
async disconnect() {
this.connected = false
}
isConnected() {
return this.connected
}
async request(request) {
this.calls.push(request)
// Check for account-specific mock
const accountKey = ${request.command}:${request.account}
if (this.responses.has(accountKey)) {
return { result: this.responses.get(accountKey) }
}
// Check for command mock
if (this.responses.has(request.command)) {
const response = this.responses.get(request.command)
if (typeof response === 'function') {
return { result: response(request) }
}
return { result: response }
}
throw new Error(No mock for command: ${request.command})
}
getCallsFor(command) {
return this.calls.filter(c => c.command === command)
}
reset() {
this.calls = []
}
}
```
const { describe, it, expect, beforeEach } = require('jest')
const { PaymentValidator } = require('../src/payment-validator')
const { MockXRPLClient } = require('./mocks/xrpl-client')
describe('PaymentValidator', () => {
let client
let validator
beforeEach(() => {
client = new MockXRPLClient()
validator = new PaymentValidator(client)
})
describe('validateAddress', () => {
it('accepts valid addresses', () => {
const valid = 'rN7n3473SaZBCG4dFL83w7a1RXtXtbk2D9'
expect(() => validator.validateAddress(valid)).not.toThrow()
})
it('rejects invalid addresses', () => {
expect(() => validator.validateAddress('invalid')).toThrow('Invalid address')
expect(() => validator.validateAddress('')).toThrow('required')
expect(() => validator.validateAddress(null)).toThrow('required')
})
it('rejects addresses with wrong prefix', () => {
expect(() => validator.validateAddress('xN7n3473SaZBCG4dFL83w7a1RXtXtbk2D9'))
.toThrow('must start with')
})
})
describe('validateAmount', () => {
it('accepts valid XRP amounts', () => {
expect(validator.validateAmount('100')).toBe(100)
expect(validator.validateAmount('0.000001')).toBe(0.000001)
expect(validator.validateAmount(50)).toBe(50)
})
it('rejects invalid amounts', () => {
expect(() => validator.validateAmount(-1)).toThrow('positive')
expect(() => validator.validateAmount(0)).toThrow('positive')
expect(() => validator.validateAmount('abc')).toThrow('valid number')
})
it('rejects amounts with too many decimals', () => {
expect(() => validator.validateAmount('1.0000001'))
.toThrow('decimal places')
})
})
describe('checkSufficientBalance', async () => {
it('returns true when balance is sufficient', async () => {
client.mockResponse('account_info', {
account_data: {
Balance: '100000000', // 100 XRP
OwnerCount: 0
}
})
client.mockResponse('server_info', {
info: {
validated_ledger: {
reserve_base_xrp: 10,
reserve_inc_xrp: 2
}
}
})
const result = await validator.checkSufficientBalance('rAddr...', 50)
expect(result.sufficient).toBe(true)
expect(result.available).toBe(90) // 100 - 10 reserve
})
it('returns false when balance is insufficient', async () => {
client.mockResponse('account_info', {
account_data: {
Balance: '15000000', // 15 XRP
OwnerCount: 0
}
})
client.mockResponse('server_info', {
info: {
validated_ledger: {
reserve_base_xrp: 10,
reserve_inc_xrp: 2
}
}
})
const result = await validator.checkSufficientBalance('rAddr...', 10)
expect(result.sufficient).toBe(false)
expect(result.available).toBe(5)
})
})
})
```
describe('PartialPaymentDetector', () => {
const detector = require('../src/partial-payment-detector')
it('detects partial payment flag', () => {
const tx = { Flags: 0x00020000 }
expect(detector.isPartialPayment(tx)).toBe(true)
})
it('returns false when flag not set', () => {
const tx = { Flags: 0 }
expect(detector.isPartialPayment(tx)).toBe(false)
})
it('correctly extracts delivered_amount for XRP', () => {
const tx = {
TransactionType: 'Payment',
Amount: '100000000',
meta: {
TransactionResult: 'tesSUCCESS',
delivered_amount: '50000000' // Partial payment
}
}
const delivered = detector.getDeliveredAmount(tx)
expect(delivered.value).toBe(50) // 50 XRP, not 100
expect(delivered.currency).toBe('XRP')
})
it('rejects partial payment without delivered_amount', () => {
const tx = {
TransactionType: 'Payment',
Amount: '100000000',
Flags: 0x00020000,
meta: { TransactionResult: 'tesSUCCESS' }
// No delivered_amount
}
expect(() => detector.getDeliveredAmount(tx))
.toThrow('Cannot determine delivered amount')
})
})
```
const xrpl = require('xrpl')
class TestnetFixture {
constructor() {
this.client = new xrpl.Client('wss://s.altnet.rippletest.net:51233')
this.wallets = []
}
async setup() {
await this.client.connect()
// Create funded test wallets
this.sender = await this.createFundedWallet()
this.receiver = await this.createFundedWallet()
console.log(Sender: ${this.sender.address})
console.log(Receiver: ${this.receiver.address})
}
async createFundedWallet() {
const { wallet, balance } = await this.client.fundWallet()
this.wallets.push(wallet)
return wallet
}
async teardown() {
await this.client.disconnect()
}
async getBalance(address) {
const info = await this.client.request({
command: 'account_info',
account: address,
ledger_index: 'validated'
})
return parseInt(info.result.account_data.Balance) / 1_000_000
}
}
// Jest setup
let fixture
beforeAll(async () => {
fixture = new TestnetFixture()
await fixture.setup()
}, 30000)
afterAll(async () => {
await fixture.teardown()
})
```
describe('Payment Integration', () => {
it('successfully sends XRP payment', async () => {
const initialBalance = await fixture.getBalance(fixture.receiver.address)
const payment = {
TransactionType: 'Payment',
Account: fixture.sender.address,
Destination: fixture.receiver.address,
Amount: xrpl.xrpToDrops('10')
}
const result = await fixture.client.submitAndWait(payment, {
wallet: fixture.sender
})
expect(result.result.meta.TransactionResult).toBe('tesSUCCESS')
const finalBalance = await fixture.getBalance(fixture.receiver.address)
expect(finalBalance - initialBalance).toBe(10)
}, 30000)
it('fails payment to non-existent account with insufficient amount', async () => {
const nonExistent = xrpl.Wallet.generate().address
const payment = {
TransactionType: 'Payment',
Account: fixture.sender.address,
Destination: nonExistent,
Amount: xrpl.xrpToDrops('1') // Below reserve
}
const result = await fixture.client.submitAndWait(payment, {
wallet: fixture.sender
})
// Should fail because 1 XRP < 10 XRP reserve for new account
expect(result.result.meta.TransactionResult).toBe('tecNO_DST_INSUF_XRP')
}, 30000)
it('handles destination tag requirement', async () => {
// Set RequireDestTag on receiver
await fixture.client.submitAndWait({
TransactionType: 'AccountSet',
Account: fixture.receiver.address,
SetFlag: 1 // asfRequireDest
}, { wallet: fixture.receiver })
// Payment without tag should fail
const payment = {
TransactionType: 'Payment',
Account: fixture.sender.address,
Destination: fixture.receiver.address,
Amount: xrpl.xrpToDrops('5')
// No DestinationTag
}
const result = await fixture.client.submitAndWait(payment, {
wallet: fixture.sender
})
expect(result.result.meta.TransactionResult).toBe('tecDST_TAG_NEEDED')
}, 30000)
})
```
describe('Error Handling', () => {
it('handles connection timeout', async () => {
const slowClient = new xrpl.Client('wss://s1.ripple.com:51233')
slowClient.connection.config.timeout = 100 // Very short timeout
await expect(slowClient.connect())
.rejects
.toThrow()
})
it('handles invalid transaction', async () => {
const payment = {
TransactionType: 'Payment',
Account: fixture.sender.address,
Destination: 'invalidaddress',
Amount: xrpl.xrpToDrops('10')
}
await expect(fixture.client.submitAndWait(payment, {
wallet: fixture.sender
})).rejects.toThrow()
})
it('handles insufficient balance', async () => {
// Try to send more than available
const info = await fixture.client.request({
command: 'account_info',
account: fixture.sender.address
})
const balance = parseInt(info.result.account_data.Balance)
const tooMuch = balance + 1000000 // More than balance
const payment = {
TransactionType: 'Payment',
Account: fixture.sender.address,
Destination: fixture.receiver.address,
Amount: tooMuch.toString()
}
const result = await fixture.client.submitAndWait(payment, {
wallet: fixture.sender
})
expect(result.result.meta.TransactionResult).toBe('tecUNFUNDED_PAYMENT')
}, 30000)
})
```
describe('Network Resilience', () => {
it('reconnects after disconnect', async () => {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233')
await client.connect()
// Simulate disconnect
client.connection.ws.close()
// Wait for reconnection logic
await sleep(5000)
// Should be able to make requests again
const response = await client.request({ command: 'server_info' })
expect(response.result.info.server_state).toBeDefined()
await client.disconnect()
}, 30000)
})
```
const { performance } = require('perf_hooks')
describe('Load Testing', () => {
it('handles concurrent requests', async () => {
const CONCURRENT = 50
const requests = []
const start = performance.now()
for (let i = 0; i < CONCURRENT; i++) {
requests.push(
fixture.client.request({
command: 'account_info',
account: fixture.sender.address,
ledger_index: 'validated'
})
)
}
const results = await Promise.allSettled(requests)
const end = performance.now()
const duration = end - start
const succeeded = results.filter(r => r.status === 'fulfilled').length
const failed = results.filter(r => r.status === 'rejected').length
console.log(${CONCURRENT} requests in ${duration.toFixed(2)}ms)
console.log(Success: ${succeeded}, Failed: ${failed})
console.log(Throughput: ${(CONCURRENT / (duration / 1000)).toFixed(2)} req/sec)
expect(succeeded).toBeGreaterThan(CONCURRENT * 0.95) // 95% success rate
}, 60000)
it('maintains performance under sustained load', async () => {
const DURATION_MS = 10000
const TARGET_RPS = 20
const INTERVAL = 1000 / TARGET_RPS
const latencies = []
const startTime = Date.now()
while (Date.now() - startTime < DURATION_MS) {
const requestStart = performance.now()
await fixture.client.request({
command: 'server_info'
})
latencies.push(performance.now() - requestStart)
await sleep(INTERVAL)
}
// Calculate percentiles
latencies.sort((a, b) => a - b)
const p50 = latencies[Math.floor(latencies.length * 0.5)]
const p95 = latencies[Math.floor(latencies.length * 0.95)]
const p99 = latencies[Math.floor(latencies.length * 0.99)]
console.log(P50: ${p50.toFixed(2)}ms, P95: ${p95.toFixed(2)}ms, P99: ${p99.toFixed(2)}ms)
expect(p95).toBeLessThan(500) // P95 under 500ms
}, 30000)
})
```
- Unit tests with mocks for core business logic
- Integration tests on testnet for payment flows
- Error scenario tests for all failure modes
- Basic load test measuring throughput and latency
Time Investment: 3-4 hours
End of Lesson 15
Key Takeaways
Layer your tests:
Unit tests for logic, integration tests for API, E2E for flows.
Mock for speed:
Use mocks in unit tests; save testnet for integration.
Test error paths:
Verify error handling works as designed.
Load test before launch:
Discover performance limits before users do.
Keep testnet wallets funded:
Pre-funded wallets speed up test runs. ---