libbpf Unit Testing: Testing BPF Programs with libbpf
Writing eBPF programs is hard. Testing them is harder. The kernel-userspace boundary, the BPF verifier, the lack of standard debugging tools — all of it conspires to make eBPF a domain where bugs hide for a long time. libbpf, the canonical C library for loading and interacting with BPF programs, provides the primitives you need to build a real unit testing strategy for your BPF code. This post walks through how to do it.
Why BPF Programs Need Dedicated Unit Tests
Most eBPF programs sit in the critical path of kernel event processing: network packets, system calls, filesystem operations. A bug in a BPF program can silently drop packets, corrupt tracing data, or, worst case, trigger a kernel panic. The BPF verifier catches some categories of bugs at load time, but runtime logic errors pass through untouched.
The traditional approach — "load the program and see what happens" — is not testing. It is hoping. A proper libbpf-based unit test harness lets you:
- Test BPF map operations in isolation without running the full kernel event path
- Verify your BPF program's logic against known inputs
- Catch verifier rejections as CI failures instead of production incidents
- Run tests in a reproducible environment with known kernel versions
The libbpf Testing Architecture
libbpf exposes the full BPF syscall interface through a C API. For testing purposes, the key components are:
bpf_object__open()/bpf_object__load()— load your BPF ELF into the kernelbpf_map__fd()— get a file descriptor for a BPF mapbpf_map_update_elem()/bpf_map_lookup_elem()— manipulate maps from userspacebpf_program__attach()— attach a program to a hook pointbpf_link__destroy()— detach and clean up
The testing pattern is: load the program, set up map state, trigger the event path, read the map output, assert.
Setting Up the Test Harness
Project Structure
A minimal libbpf unit test project looks like this:
ebpf-project/
├── src/
│ ├── my_prog.bpf.c # BPF C code
│ └── my_prog.h # Shared structs
├── tests/
│ ├── test_my_prog.c # C test runner
│ └── test_helpers.h # Common test utilities
├── Makefile
└── vmlinux.h # Generated BTF headerMakefile for BPF + Test Build
CLANG ?= clang
CC ?= gcc
BPFTOOL ?= bpftool
BPF_CFLAGS = -target bpf -O2 -g -Wall
CFLAGS = -O2 -g -Wall -I./src
# Build BPF object
my_prog.bpf.o: src/my_prog.bpf.c
$(CLANG) $(BPF_CFLAGS) -c $< -o $@
# Generate skeleton
my_prog.skel.h: my_prog.bpf.o
$(BPFTOOL) gen skeleton $< > $@
# Build test runner
tests/test_my_prog: tests/test_my_prog.c my_prog.skel.h
$(CC) $(CFLAGS) $< -o $@ -lbpf -lelf -lz
test: tests/test_my_prog
sudo ./tests/test_my_progA Simple BPF Program to Test
Let's test a program that counts events per PID:
// src/my_prog.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 1024);
__type(key, __u32);
__type(value, __u64);
} event_count SEC(".maps");
SEC("tracepoint/syscalls/sys_enter_openat")
int count_opens(struct trace_event_raw_sys_enter *ctx)
{
__u32 pid = bpf_get_current_pid_tgid() >> 32;
__u64 *count = bpf_map_lookup_elem(&event_count, &pid);
if (count) {
__sync_fetch_and_add(count, 1);
} else {
__u64 one = 1;
bpf_map_update_elem(&event_count, &pid, &one, BPF_ANY);
}
return 0;
}
char LICENSE[] SEC("license") = "GPL";Writing Unit Tests with libbpf Skeletons
libbpf's bpftool gen skeleton generates a C header that wraps your BPF program with a clean API. Tests written against skeletons are type-safe and refactor-friendly.
// tests/test_my_prog.c
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <bpf/libbpf.h>
#include "my_prog.skel.h"
#define TEST_PASS(name) printf("PASS: %s\n", name)
#define TEST_FAIL(name, msg) fprintf(stderr, "FAIL: %s — %s\n", name, msg)
static struct my_prog_bpf *skel;
static int setup(void)
{
skel = my_prog_bpf__open_and_load();
if (!skel) {
fprintf(stderr, "Failed to open and load BPF skeleton\n");
return -1;
}
if (my_prog_bpf__attach(skel)) {
fprintf(stderr, "Failed to attach BPF program\n");
return -1;
}
return 0;
}
static void teardown(void)
{
my_prog_bpf__destroy(skel);
}
/* Test: opening a file increments the event count for our PID */
static void test_open_increments_count(void)
{
__u32 pid = getpid();
__u64 count_before = 0, count_after = 0;
int map_fd = bpf_map__fd(skel->maps.event_count);
/* Read initial count (may not exist yet) */
bpf_map_lookup_elem(map_fd, &pid, &count_before);
/* Trigger an openat syscall */
int fd = open("/dev/null", O_RDONLY);
assert(fd >= 0);
close(fd);
/* Give the tracepoint handler time to execute */
usleep(10000);
int ret = bpf_map_lookup_elem(map_fd, &pid, &count_after);
if (ret != 0) {
TEST_FAIL("test_open_increments_count", "PID not found in map");
return;
}
if (count_after > count_before) {
TEST_PASS("test_open_increments_count");
} else {
TEST_FAIL("test_open_increments_count",
"count did not increase after open()");
}
}
/* Test: map lookup returns zero for unknown PID */
static void test_unknown_pid_returns_notfound(void)
{
/* Use a PID that cannot exist as a valid process */
__u32 fake_pid = 0xDEADBEEF;
__u64 count = 0;
int map_fd = bpf_map__fd(skel->maps.event_count);
int ret = bpf_map_lookup_elem(map_fd, &fake_pid, &count);
if (ret == -1) {
TEST_PASS("test_unknown_pid_returns_notfound");
} else {
TEST_FAIL("test_unknown_pid_returns_notfound",
"unexpected entry for fake PID");
}
}
/* Test: program loads without verifier errors */
static void test_program_loads(void)
{
/* If we reach this point, open_and_load() succeeded */
int prog_fd = bpf_program__fd(skel->progs.count_opens);
if (prog_fd > 0) {
TEST_PASS("test_program_loads");
} else {
TEST_FAIL("test_program_loads", "invalid program fd");
}
}
int main(void)
{
if (setup() != 0)
return 1;
test_program_loads();
test_open_increments_count();
test_unknown_pid_returns_notfound();
teardown();
printf("All tests complete.\n");
return 0;
}Testing BPF Maps in Isolation
Not all BPF tests need to go through the program. You can create and test maps entirely in userspace to validate your data structures and lookup logic:
static void test_map_capacity_limit(void)
{
int map_fd = bpf_map__fd(skel->maps.event_count);
int max_entries = 1024;
int i, ret;
/* Fill the map to capacity */
for (i = 0; i < max_entries; i++) {
__u32 key = ((__u32)i) + 0x10000; /* Avoid real PIDs */
__u64 val = i;
ret = bpf_map_update_elem(map_fd, &key, &val, BPF_NOEXIST);
if (ret != 0 && i < max_entries - 1) {
TEST_FAIL("test_map_capacity_limit",
"map rejected entry before full");
goto cleanup;
}
}
/* The next insert should fail */
__u32 overflow_key = 0xFFFFFFFF;
__u64 overflow_val = 99;
ret = bpf_map_update_elem(map_fd, &overflow_key, &overflow_val, BPF_NOEXIST);
if (ret == -1) {
TEST_PASS("test_map_capacity_limit");
} else {
TEST_FAIL("test_map_capacity_limit",
"map accepted entry beyond max_entries");
}
cleanup:
/* Clean up test entries */
for (i = 0; i < max_entries; i++) {
__u32 key = ((__u32)i) + 0x10000;
bpf_map_delete_elem(map_fd, &key);
}
}Running libbpf Tests in CI
GitHub Actions with a Compatible Kernel
libbpf tests require a real Linux kernel. On GitHub Actions, use a self-hosted runner or a VM-based runner. The ubuntu-22.04 runner (kernel 5.15) supports most libbpf features:
name: BPF Unit Tests
on: [push, pull_request]
jobs:
bpf-tests:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
clang llvm libelf-dev libbpf-dev \
linux-headers-$(uname -r) \
bpftool
- name: Build BPF objects and tests
run: make tests/test_my_prog
- name: Run BPF unit tests
run: sudo ./tests/test_my_prog
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: bpf-test-results
path: test-results/Kernel Version Matrix
eBPF features are gated by kernel version. Test against multiple kernels using a matrix strategy with QEMU:
strategy:
matrix:
kernel: ["5.15", "6.1", "6.6"]Each matrix entry boots a minimal QEMU VM with the target kernel, runs your test binary, and reports results. The vmtest tool from the kernel BPF CI infrastructure automates this.
Handling the BPF Verifier in Tests
The verifier is the BPF runtime's safety check. It rejects programs with unbounded loops, out-of-bounds accesses, or uninitialized reads. In testing, a verifier rejection manifests as bpf_object__load() returning an error with a verbose log:
static int setup_with_log(void)
{
skel = my_prog_bpf__open();
if (!skel) return -1;
/* Enable verifier log for debugging */
libbpf_set_print(libbpf_print_fn);
if (my_prog_bpf__load(skel)) {
fprintf(stderr, "Verifier rejected the program\n");
my_prog_bpf__destroy(skel);
return -1;
}
return 0;
}
static int libbpf_print_fn(enum libbpf_print_level level,
const char *format, va_list args)
{
return vfprintf(stderr, format, args);
}This approach surfaces verifier rejections as explicit test failures in CI, with the full verifier log attached for debugging.
Connecting BPF Test Results to Your Overall Testing Pipeline
libbpf unit tests cover the BPF layer in isolation. They do not verify the userspace integration, the control plane logic, or the end-to-end behavior of your system. That is where higher-level test orchestration comes in.
Teams using HelpMeTest alongside libbpf tests get the full stack covered: libbpf tests run in CI to verify the kernel programs, while HelpMeTest's AI-powered runner exercises the application layer that sits on top. At $100/month for the Pro plan, HelpMeTest brings Robot Framework and Playwright automation to the parts of your stack that libbpf cannot reach — the UI, the API, the user workflows.
Common Pitfalls
Root requirement. libbpf tests almost always need CAP_BPF or root. Plan for this in CI by pre-configuring your runner environment.
Map cleanup between tests. Always delete test entries from shared maps between test cases. Leaked map entries cause false positives in subsequent tests.
Timing windows. Tracepoint handlers execute asynchronously. After triggering a syscall in a test, add a small usleep() before reading map output. Use ring buffers or perf events for tighter synchronization in latency-sensitive tests.
BTF dependency. Modern libbpf features (CO-RE, type-safe map definitions) require a kernel built with BTF support. Confirm your CI kernel has BTF enabled: zcat /proc/config.gz | grep CONFIG_DEBUG_INFO_BTF.
Conclusion
libbpf gives BPF developers the primitives to build a real unit testing strategy — not just "load and hope," but structured tests against known map state with CI-enforced pass/fail criteria. The pattern is consistent: generate a skeleton, load the program, manipulate maps, trigger events, assert on output. Start with the three tests shown here (load validation, event counting, capacity limits) and expand from there. Every BPF program that ships without unit tests is a kernel event handler with no safety net.