advanced 60 min

Testing RPC handlers

How to test RPC handlers with the jtx framework and debug them.

Prerequisites

Complete these before starting this module:

What you'll learn

  • Write unit tests for a handler with the jtx `Env`.
  • Debug handlers with logging and gdb.
  • Cover success and error paths.
Complete this module by mentor review and a quiz. Jump to assessment

Introduction

≈60 min · Advanced · builds on RPC handler best practices

Code you can't test is code you can't trust. In this module you'll learn to test a handler with the jtx framework and its Env (covering happy paths, error conditions, input validation and role checks) and to debug it with logging and gdb when something's off. This is how your custom command becomes production-ready.


The Test Setup

In brief: beast::unit_test + the jtx Env, the exact shape of every suite in src/test/rpc.

Rippled uses its own beast::unit_test framework (not Google Test or Catch2), with the jtx helpers on top. A suite is a class, each scenario is a testcase, and Env gives every test a funded, throwaway ledger:

Key idea. The jtx Env gives you a throwaway ledger to submit transactions against, so you test a handler's real behaviour rather than a mock of it.

There is no mocking framework in rippled's tests: you stage the situation you need through Env itself (fund or don't fund accounts, close or don't close ledgers, toggle amendments with Env env(*this, testable_amendments() - featureX)). Real state beats mocks here.


Anatomy of a Handler Test

Every scenario follows Arrange / Act / Assert. You call the handler the way a client would, through env.rpc, and assert on the JSON with BEAST_EXPECT:

Name testcases after the behaviour they pin down ("Errors", "Signer lists", "Malformed account"); never "Test1" or "Works".


The Four Axes of Coverage

In brief: happy path, error paths, input validation, roles; every handler needs all four.

Axis 1: happy path

The ReturnsBalance test above is the model: real accounts, real transactions, assert on the exact JSON. Add one variant per meaningful option (ledger_index numeric vs "validated", optional fields present or absent).

Axis 2: error paths

One test per error code the handler can return. The shape never changes; only the arrangement and the expected code do:

{
    // straight from src/test/rpc/AccountInfo_test.cpp
    auto const info = env.rpc("json", "account_info",
        R"({"account": "not-a-valid-address"})");

    BEAST_EXPECT(info[jss::result][jss::error_code] == RpcActMalformed);
    BEAST_EXPECT(info[jss::result][jss::error_message] == "Account malformed.");
}
Arrangement Expected error
required parameter missing RpcInvalidParams
account string malformed RpcActMalformed
account absent from the ledger RpcActNotFound
ledger_index = 999999999 RpcLgrNotFound
no current ledger (mocked) RpcNoCurrent

Axis 3: input validation

Same pattern, focused on each parameter's type and bounds. All of these expect RpcInvalidParams:

Parameter Invalid input to test
limit "not-a-number", 0, 2000 (above the cap)
currency over-long or non-standard code
amount malformed JSON amount
optional fields present but invalid (they are not skipped)

Axis 4: roles

Build the same request with different roles and assert what each may see or do:

{
    // admin-only command through a non-admin connection
    auto const res = env.rpc("json", "my_admin_command", "{ }");

    BEAST_EXPECT(res[jss::result][jss::error_code] == RpcNoPermission);
}

Env runs with admin rights by default; to exercise the non-admin path, construct it with a config whose port grants no admin access (envconfig(no_admin)), then run the same commands.

Role Expectation
GUEST public queries succeed; privileged commands return RpcNoPermission
USER standard operations succeed
ADMIN admin-only response fields are present

Edge cases worth one test each

Empty strings, zero and huge balances, limit at exactly its minimum and maximum, special ledger indices ("validated", "current"), and a burst of concurrent requests against the same handler (thread safety).


Integration Tests

Unit tests exercise one handler; an integration test walks a real flow end to end:

You can also drive a live standalone node from the outside: start ./xrpld --standalone, curl the JSON-RPC port, and watch the logs; that is the closest thing to production behaviour.


Running Tests and Measuring Coverage

# Run a specific suite, fast and local (name from BEAST_DEFINE_TESTSUITE)
./xrpld --unittest=MyHandler

# Several suites, or parallel execution
./xrpld --unittest=beast,MyHandler --unittest-jobs=4

# Coverage build + report
cmake -DCMAKE_BUILD_TYPE=Debug -DCOVERAGE=ON ..
lcov --capture --directory . --output-file coverage.info
genhtml coverage.info --output-directory coverage_html

Aim for at least 80% coverage, and make sure the covered lines include every error branch and every role branch, not just the happy path.

Do / Don't

Do Don't
test every error code and every role test only the happy path
use descriptive test names test implementation internals
use jtx for realistic ledger state hardcode magic test data everywhere
add a regression test for every bug found rely on manual testing
test boundaries (min and max) ignore resource limits and DoS paths

There is no "complete example" to copy here on purpose: assemble the fixture, the four axes, and the integration flow above, and compare with the real suites in src/test/rpc of the rippled repository; they follow exactly this shape.


Troubleshooting and Diagnostic Techniques

Even with careful coding, issues arise. The key is isolating the problem quickly: compile-time, crash, wrong result, or slow.

Common compilation errors

Error message Cause Fix
'jss::my_field' was not declared string constant missing use constants from xrpl/protocol/jss.h; add yours there if new
no matching function for call to 'rpcError' wrong parameter types return rpcError(RpcInvalidParams, "msg"); and return it directly
invalid conversion from 'const AccountID*' const dropped keep const& in signatures; never const_cast
'parseBase58' was not declared missing include #include <xrpld/rpc/detail/RPCHelpers.h> (plus Context.h, ErrorCodes.h, jss.h)

Strategic logging

Json::Value doMyHandler(RPC::JsonContext& context)
{
    JLOG(context.app.journal("RPC").debug())
        << "doMyHandler params: " << context.params.toStyledString();

    if (!account) {
        JLOG(context.app.journal("RPC").warning())
            << "Failed to parse account";
        return rpcError(RpcActMalformed);
    }
    // ...
}

Journal levels, from loudest to quietest: fatal (node stopping), error (handler failed), warning (recoverable surprise), info, debug (hidden in release builds), trace.

Debugging with gdb

cmake -DCMAKE_BUILD_TYPE=Debug ..   # build with symbols
gdb --args ./xrpld --standalone
Command Purpose
break doMyHandler stop at handler entry
break MyHandler.cpp:45 if seq == 3 conditional breakpoint
print context.params / print *account inspect values
next / step / continue step over / into / resume
backtrace, frame 0 locate a crash
x/32bx account raw memory in hex

On macOS, lldb offers the same workflow (br set -f MyHandler.cpp -l 45, p context.params, bt).

Analyzing crashes

Almost every handler segfault is one of these two:

// 1. Unchecked ledger read
auto const sle = ledger->read(keylet);
auto value = sle->getFieldU32(sfBalance);      // CRASH if sle is null

// 2. Dereferencing an empty optional
auto const acct = parseBase58<AccountID>(str);
auto const k = keylet::account(*acct);          // CRASH if parse failed

The fix is the same shape both times: check, and return the matching rpcError (RpcActNotFound, RpcActMalformed) before dereferencing. For a post-mortem: ulimit -c unlimited, then open the core dump with lldb ./xrpld -c core and read bt.

Log level configuration

There is no [logging] config section; run log_level commands at startup via [rpc_startup], one JSON object per line:

[rpc_startup]
{ "command": "log_level", "severity": "info" }
{ "command": "log_level", "partition": "RPCHandler", "severity": "debug" }

At runtime: xrpld log_level RPCHandler debug. Useful partitions: rpc, ledger, transaction, peer.

Performance issues

Bracket the suspect sections with timing logs and read which step dominates:

auto start = std::chrono::high_resolution_clock::now();
// ... handler section ...
JLOG(journal.debug()) << "section took "
    << std::chrono::duration_cast<std::chrono::milliseconds>(
           std::chrono::high_resolution_clock::now() - start).count() << "ms";

Then apply the usual levers: fewer ledger reads, indexed lookups, cache what repeats, and stream large results instead of materialising a giant vector.

Checklist: debug workflow

When your handler isn't working:

The debug workflow: compile errors (check includes, jss constants, type mismatches), immediate crashes (stack trace, null pointers, optionals), wrong results (log parameters, verify the ledger lookup, check the JSON, print intermediates), slow execution (timing logs, slowest section, fewer ledger operations, caching), and inconsistent behavior (error paths, state assumptions, varied inputs, race conditions)


Summary

This module covered how to test a handler with the jtx framework and its Env, which gives you a throwaway ledger to submit real transactions against. You learned to cover the happy path, error conditions, input validation, and role-based permissions, and to debug a handler with logging and gdb when something is off. This is how a custom command becomes production-ready.

To remember:

  • The jtx framework (src/test/jtx) gives you Env: a throwaway ledger to test against
  • Basics: env.fund(...), env(pay(alice, bob, XRP(100))), env.close()
  • Call your handler through the test client and assert on the JSON
  • Cover four axes: happy path, each error path, input validation, role denial
  • Run fast and local: ./xrpld --unittest="MySuite"
  • Good tests double as executable documentation of your handler's contract
  • Debug failing tests with scoped log partitions and gdb (the Development & debugging techniques module's tools)
  • Watch out: happy-path-only suites pass right up until production; the error paths are where handlers actually break

Next up. Request and response is mastered. Next, the features behind live explorers and wallets: subscriptions, streaming, and gRPC.

Assignments

0 of 2 complete

Unlocks

Finishing this module opens up:

XRPL Academy © 2026