Testing eBPF code where it actually runs
This framework is heavily inspired by Cilium’s eBPF testing approach. The overall testing architecture comes from Cilium; this post focuses on implementing those ideas in Rust using Aya. If you’re interested in the original design, I recommend taking a look at Cilium’s testing framework. The implementation described in this post is available in my GitHub repository.
- Cilium: https://github.com/cilium/cilium
- Implementation: https://github.com/erwin-kok/sarena
The problem
When exploring/implementing an eBPF/XDP/TCX dataplane, a question arose:
“How do we actually test this?” For example, when we have an eBPF program
called process_ipv4(), how can we test its functionality? Granted, these
programs are written in Rust (when using Aya), so it looks straightforward
to test them like any other Rust function. However, there are a few
caveats:
- In production,
process_ipv4()runs in kernel-space, not in user-space, therefore it must be verifier-friendly. We cannot just write any code: it must be accepted by the verifier. - Testing process_ipv4() in userspace doesn’t necessarily tell us how it behaves once it has passed the verifier and is executing in the kernel.
- eBPF has specific intrinsics — special maps, tail-calls, special
bpf_*calls, etc. — emulating these is not trivial, and it’s not testing the actual kernel execution path.
This makes ordinary Rust testing awkward, and the most reliable approach is therefore to execute the verifier-approved program in the kernel itself. Fortunately, Linux provides exactly such a mechanism: bpf_prog_test_run, which executes an already-loaded eBPF program against supplied packet data.
The core idea: tests are eBPF programs too
This approach looks trivial: set up everything, call the eBPF program in
kernel-space using bpf_prog_test_run, and then check whether the results
match the expected outcome. However, it’s not that trivial: we cannot
just set up a network packet in userspace, hand it over to kernel-space,
and let the eBPF program process this handcrafted packet. When calling
bpf_prog_test_run, it needs two important parameters: data (the actual
packet bytes) and a context (the metadata, for example where the packet
begins and ends).
While userspace can provide packet bytes, it cannot faithfully construct the execution context expected by an eBPF program. Fields such as packet boundaries ultimately refer to kernel memory, and the kernel may rewrite parts of the context before execution.
Therefore, not only does the program itself, like process_ipv4(), run in
kernel-space — the setup and checks run in kernel-space too, as eBPF
programs, following the classic Arrange / Act / Assert shape:
- arrange — builds an input packet in the test buffer.
- act — runs the production code under test against that packet.
- assert — inspects the resulting packet/context and records pass/fail verdicts — from inside the kernel, since that’s the only place that can inspect the data and context properly.
Only the orchestration (loading the .o files, wiring up test runs,
initializing maps, printing results, etc.) happens in userspace.
Authoring a test: #[arrange] / #[act] / #[assert]
Because every test consists of three programs, we need a way to group them — i.e., to know which three programs together make up one test. The name of each program follows a special structure that encodes both the phase and the test name:
__test_fw_{ptype}_{name}
The prefix __test_fw_ denotes that this is a special test framework
function. {ptype} denotes the phase of the function — one of arrange,
act, or assert — and {name} is the name of the test, which must be
identical across all three parts.
To help write these functions, three macros are defined: #[arrange],
#[act], and #[assert]. These macros rename the function accordingly.
They also accept either tc or xdp as a parameter, which denotes the
section these programs are placed in.
arrange and act functions have the same shape: they take a context
and return TestStatus. assert functions are a bit special (see the
next section): they take a context and a TestSuite as parameters, and
return nothing.
As an example:
#[arrange(tc, "basic_test")]
pub fn does_not_matter_but_unique_1(ctx: TcContext) -> TestStatus {
// setup packet
// ...
TestStatus::Pass
}
#[assert(tc, "basic_test")]
pub fn does_not_matter_but_unique_2(ctx: TcContext, t: &mut TestSuite) {
let data = ...;
let data_end = ...;
let len = ...;
// check/assert status
assert_test!(
t,
data + len <= data_end,
"ctx too short: need %d bytes",
len
);
}
TestSuite and the #[assert] macro
As mentioned, functions annotated with arrange, act, or assert are eBPF
programs too. This means we’re in no_std territory: no allocator, no
format!, no Display. However, when writing a test we’d still like some
checks, like in regular Rust:
assert_eq!(a, b, "a should be equal to b ({a} == {b})");
That’s not possible in an eBPF program. To get some (limited) asserting/logging functionality anyway, the test still evaluates its conditions in-kernel — that part doesn’t change — but instead of printing or panicking, it records the result — name, file, pass/fail status, log lines — into an eBPF map. The runner then reads that map and turns it into a human-readable report: formatting the log messages and printing the verdict the kernel-side code already decided.
Concretely, that all has to be serialized into a fixed-size byte array
living in a BPF map (test_suite_result, 8 KiB), because that’s the only
communication channel back to userspace. no_std + without an allocator,
dynamic containers such as Vec are unavailable, and critically no format!
or Display — the verifier doesn’t run an allocator or a formatting machine,
so anything resembling Rust’s string formatting is off the table in kernel space.
That constraint is why logging is deliberately primitive:
test_log!/assert_test!’s format strings are static, printf-style
literals with up to a handful of u64 arguments, not format!().
The framework doesn’t interpolate anything in the kernel — it writes the
literal format string bytes and the raw u64 argument values as separate
TLV fields, tagged LogFmt / LogArg. All actual substitution
(%llu, %d, %x, %p, …) happens later, in userspace, in
report::format_log. This is also why arbitrary Debug/Display
formatting of dynamic data — e.g. “print this struct” — isn’t supported:
if it isn’t a u64 you can smuggle out as a LogArg, it can’t be logged.
TestSuite is responsible for holding the TlvWriter and the test’s
status. Several convenience macros exist for logging and asserting.
As an example:
#[assert(tc, "1_basic_test_equal_bytes")]
pub fn basic_test_equal_bytes_assert(ctx: TcContext, t: &mut TestSuite) {
...
test_log!(t, "expected drop, got forward, flags=%llu", flags as u64);
}
Here, a &mut TestSuite is provided to the assert function as t, and
using t we can log. In this case the format string
"expected drop, got forward, flags=%llu" is written to the TLV buffer,
together with its argument, the value of flags. Userspace retrieves the
format string and its arguments, and does the actual substitution there.
A few other macros are available as well:
assert_test!(t, cond)/assert_test!(t, cond, "fmt", args...)— the general-purpose assertion; ifcondevaluates to false, logs and fails the test.assert_buffer!(t, &ctx, name, first_layer, offset, buf, len)— compares packet bytes; discussed further down, under scapy diffing.test_log!(t, "fmt", args...)— printf-style logging.test_fatal!/test_skip!— fail-and-break/ skip-and-breakout of the macro-injectedloop { ... break; }wrapping the function body.
Note that the #[assert] macro wraps the annotated function’s body in a
loop — that’s what lets test_fatal!/test_skip! exit early: calling
break exits the loop, completing the test as failed or skipped.
Skipping a test is convenient, for example, when a check depends on a feature flag:
if !feature_enabled() {
test_skip!(t);
}
Execution runs the test body up to this check; if the feature isn’t enabled, the rest of the test is skipped. This check is usually placed at the start of a test.
Running the tests: ebpf_test_runner
All of this — every arrange/act/assert group, across every test
file — runs inside a single, ordinary Rust test: ebpf_test_runner,
a plain #[test].
It starts by loading two compiled objects: the production .o
(dataplane-ebpf-programs.o) and the test .o
(dataplane-ebpf-test-programs.o).
The runner populates the test object’s tail-call map with the production
classifier’s file descriptor, so that an act program can tail-call into the
real production code path — the system under test is the actual compiled
artifact, not a stand-in.
Next, it scans every program name in the test .o for the
__test_fw_{ptype}_{name} pattern from earlier and groups them by
{name} into arrange/act/assert triples — arrange and act are
both optional, only assert is mandatory. This is the entire
test-discovery mechanism; there’s no separate registry. Get the naming
wrong and a test doesn’t fail — it’s simply never run.
Then, one test group at a time, the runner:
- Resets that test’s maps (
test_suite_result,scapy_assert_map_count, and so on), so a previous test’s results can’t leak into this one. - Sets up a zeroed
data/ctxbuffer pair and runsarrangeagainst them viabpf_prog_test_run. - Feeds
arrange’s output — the packet bytes and context it built, both opaque to the runner — intoactas input, andact’s output intoassertthe same way. - Reads the
TestResult(name, file, status, log lines) back out oftest_suite_resultand hands it toreport.rs, which prints a colored[PASS]/[FAIL]/[SKIP]/[INTERNAL ERROR]line plus any log messages.
A test comes back in one of four states:
- Pass — the test succeeded.
- Fail — an assertion failed.
- Skip — the test was intentionally skipped, e.g. via
test_skip!(see above). - FrameworkError — something went wrong in the framework itself rather than in the thing being tested, e.g. the result buffer filled up or a map lookup failed.
If the verdict is Fail or FrameworkError, the runner panics — which is
what makes cargo test/just ebpf-test report the whole thing red.
Running the suite looks like this:
===== RUNNING eBPF TESTS =====
[PASS] 1_basic_test_equal_bytes
[PASS] 2_basic_test_non_equal_bytes
ebpf/dataplane-ebpf-test-programs/src/scapy_tests.rs:41:
CTX and buffer '&scapy_bytes::SCAPY_SST_REP_BYTES' content mismatch
[PASS] 3_basic_test_too_short
ebpf/dataplane-ebpf-test-programs/src/scapy_tests.rs:102:
CTX len (42) - offset (0) < LEN (50)
[PASS] 4_large_packets
[PASS] 5_xlarge_packets
[SKIP] l2_announcement_arp_no_entry
ebpf/dataplane-ebpf-test-programs/src/arp.rs:28:
expected drop, got forward, flags=4
test ebpf_test_runner::ebpf_test_runner ... ok
Every group runs inside that one #[test], which is a real practical
constraint: there’s no way to run a single eBPF test in isolation with
cargo test some_name. Narrowing scope means editing which
#[arrange]/#[act]/#[assert] functions exist in the source, not filtering
by test name.
Known limitations
- No dynamic string formatting. Log/assert messages are static string
literals with
u64arguments substituted on user-space side; there’s noDisplay/Debug/format!equivalent available inno_stdeBPF, so you can’t log arbitrary structured data — only what fits through a handful of integer argument slots. - One process, one
#[test]. All eBPF tests execute inside a single Rust #[test], meaning individual tests cannot currently be selected via cargo test. Abuild.rs-generated one-#[test]-per-group split is a plausible future direction, untried so far. - Naming convention is the entire discovery mechanism. The regex
^__test_fw_(arrange|act|assert)_(.+)$against program names is test discovery. A program that doesn’t match this shape is simply never run — silently, with no error — which makes the macro-generated naming scheme essential. - Pre-installed
.ofiles required. Because this exercises realbpf_prog_test_runagainst loaded programs, these programs (production and test) must be available in theEBPF_DIRfirst. - Root -
bpf_prog_test_runand loading eBPF programs into the kernel requires certain privileges. The mentionedtest_runneris excluded whencargo testis run, because this does not have elevated privileges. The eBPF tests are run before asudocall is made (just ebpf-test).
Why this is worth doing
The alternative to all of this machinery is testing eBPF/XDP/TC logic
indirectly — unit-testing helper functions that happen to be #[cfg]’d to
also compile on the host, or trusting integration tests that only run
against a live network namespace. Both leave the actual verifier-checked,
kernel-executed bytecode path unverified. By making the test’s Arrange,
Act, and Assert phases themselves compile to eBPF and execute via
bpf_prog_test_run, this framework tests the dataplane the way it will
actually run: as BPF bytecode, in the kernel, against the real verifier —
with just enough userspace orchestration to load .o files, wire up
tail-calls, and turn a TLV byte buffer back into a readable pass/fail
report.