libbpf Unit Testing: Testing BPF Programs with libbpf

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 kernel
  • bpf_map__fd() — get a file descriptor for a BPF map
  • bpf_map_update_elem() / bpf_map_lookup_elem() — manipulate maps from userspace
  • bpf_program__attach() — attach a program to a hook point
  • bpf_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 header

Makefile 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_prog

A 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.

Read more