Bluetooth BLE Testing: How to Test BLE Devices, GATT Profiles, and Firmware

Bluetooth BLE Testing: How to Test BLE Devices, GATT Profiles, and Firmware

Bluetooth Low Energy testing is notoriously tricky. BLE devices are stateful, connection-oriented, and dependent on radio conditions that vary between test runs. Yet most teams test BLE manually — a developer sits with a phone, taps through a Bluetooth scanner app, and calls it done.

This guide shows you how to build automated BLE test suites that catch regressions, validate GATT profiles, and run reliably in CI.

BLE Testing Fundamentals

A BLE device exposes functionality through its GATT profile — a hierarchy of Services and Characteristics. Testing a BLE device means:

  1. Discovery — find the device by name/address, scan for its services
  2. Connection — establish a connection and validate connection parameters
  3. Read/Write — read characteristic values, write commands, verify responses
  4. Notifications — subscribe to notifications, verify they fire correctly
  5. Edge cases — reconnection, out-of-range, invalid writes, concurrent connections

Python BLE Testing with Bleak

Bleak is the best Python library for cross-platform BLE testing:

pip install bleak pytest pytest-asyncio
# tests/test_ble_device.py
import asyncio
import pytest
from bleak import BleakScanner, BleakClient
from bleak.exc import BleakError

DEVICE_NAME = "MyDevice-1234"
TARGET_ADDRESS = None  # Will be discovered

# GATT UUIDs for your device
BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb"
BATTERY_LEVEL_UUID = "00002a19-0000-1000-8000-00805f9b34fb"

CUSTOM_SERVICE_UUID = "12345678-1234-1234-1234-1234567890ab"
COMMAND_CHAR_UUID = "12345678-1234-1234-1234-1234567890ac"
STATUS_CHAR_UUID = "12345678-1234-1234-1234-1234567890ad"

@pytest.fixture(scope="session")
async def device_address():
    """Discover device by name and return its address."""
    device = await BleakScanner.find_device_by_name(DEVICE_NAME, timeout=10.0)
    
    if device is None:
        pytest.skip(f"BLE device '{DEVICE_NAME}' not found — hardware required")
    
    return device.address

@pytest.fixture
async def ble_client(device_address):
    """Connected BleakClient for test use."""
    async with BleakClient(device_address, timeout=10.0) as client:
        yield client

@pytest.mark.asyncio
async def test_device_advertises(device_address):
    """Device should be discoverable by name."""
    assert device_address is not None

@pytest.mark.asyncio
async def test_battery_service_present(ble_client):
    """Standard Battery Service must be present."""
    services = ble_client.services
    service_uuids = [str(s.uuid) for s in services]
    assert BATTERY_SERVICE_UUID in service_uuids

@pytest.mark.asyncio
async def test_battery_level_readable(ble_client):
    """Battery level must be readable and in valid range."""
    battery_level = await ble_client.read_gatt_char(BATTERY_LEVEL_UUID)
    level = int.from_bytes(battery_level, "little")
    
    assert 0 <= level <= 100, f"Battery level {level} out of range [0, 100]"

@pytest.mark.asyncio
async def test_custom_service_present(ble_client):
    """Custom service with all required characteristics must be present."""
    services = ble_client.services
    custom_service = next(
        (s for s in services if str(s.uuid) == CUSTOM_SERVICE_UUID), None
    )
    
    assert custom_service is not None, "Custom service not found"
    
    char_uuids = [str(c.uuid) for c in custom_service.characteristics]
    assert COMMAND_CHAR_UUID in char_uuids, "Command characteristic missing"
    assert STATUS_CHAR_UUID in char_uuids, "Status characteristic missing"

Testing GATT Operations

@pytest.mark.asyncio
async def test_command_write_triggers_status_update(ble_client):
    """Writing a command should update the status characteristic."""
    
    # Read initial status
    initial_status = await ble_client.read_gatt_char(STATUS_CHAR_UUID)
    
    # Send command (device-specific encoding)
    command = bytes([0x01, 0x02])  # Command ID 1, parameter 2
    await ble_client.write_gatt_char(COMMAND_CHAR_UUID, command, response=True)
    
    # Give device time to process
    await asyncio.sleep(0.1)
    
    # Status should have changed
    new_status = await ble_client.read_gatt_char(STATUS_CHAR_UUID)
    assert new_status != initial_status, "Status did not update after command"

@pytest.mark.asyncio
async def test_notification_fires_on_event(ble_client):
    """Notifications must fire when device state changes."""
    notifications = []
    
    def notification_handler(handle, data):
        notifications.append(data)
    
    # Subscribe to notifications
    await ble_client.start_notify(STATUS_CHAR_UUID, notification_handler)
    
    # Trigger an event that should cause a notification
    trigger_command = bytes([0x05])
    await ble_client.write_gatt_char(COMMAND_CHAR_UUID, trigger_command, response=True)
    
    # Wait for notification (with timeout)
    deadline = asyncio.get_event_loop().time() + 5.0  # 5 second timeout
    while len(notifications) == 0 and asyncio.get_event_loop().time() < deadline:
        await asyncio.sleep(0.1)
    
    await ble_client.stop_notify(STATUS_CHAR_UUID)
    
    assert len(notifications) > 0, "No notifications received within 5 seconds"
    assert len(notifications[0]) > 0, "Empty notification payload"

@pytest.mark.asyncio
async def test_invalid_command_does_not_crash_device(ble_client):
    """Device should handle invalid commands gracefully."""
    invalid_command = bytes([0xFF, 0xFF, 0xFF])
    
    # Should not raise an exception or disconnect
    try:
        await ble_client.write_gatt_char(
            COMMAND_CHAR_UUID, invalid_command, response=True
        )
    except BleakError as e:
        # Device may return an ATT error — that's acceptable
        assert "error" in str(e).lower() or "att" in str(e).lower()
    
    # Device should still be connected and responsive
    assert ble_client.is_connected
    battery = await ble_client.read_gatt_char(BATTERY_LEVEL_UUID)
    assert battery is not None

@pytest.mark.asyncio
async def test_reconnection_restores_state(device_address):
    """Device should work correctly after disconnect and reconnect."""
    
    # First connection: write some state
    async with BleakClient(device_address) as client1:
        await client1.write_gatt_char(COMMAND_CHAR_UUID, bytes([0x01]), response=True)
        status_after_write = await client1.read_gatt_char(STATUS_CHAR_UUID)
    
    # Disconnect happens here (async with exit)
    await asyncio.sleep(0.5)  # Let device settle
    
    # Second connection: verify state persisted (or verify clean reset)
    async with BleakClient(device_address) as client2:
        assert client2.is_connected
        # Verify device responds normally after reconnect
        battery = await client2.read_gatt_char(BATTERY_LEVEL_UUID)
        assert battery is not None

Testing GATT Profile Completeness

@pytest.mark.asyncio
async def test_all_required_characteristics_have_correct_properties(ble_client):
    """Validate GATT characteristic properties match specification."""
    
    EXPECTED_PROPERTIES = {
        BATTERY_LEVEL_UUID: {"read", "notify"},
        COMMAND_CHAR_UUID: {"write", "write-without-response"},
        STATUS_CHAR_UUID: {"read", "notify"},
    }
    
    for uuid, expected_props in EXPECTED_PROPERTIES.items():
        char = ble_client.services.get_characteristic(uuid)
        assert char is not None, f"Characteristic {uuid} not found"
        
        actual_props = set(char.properties)
        missing = expected_props - actual_props
        
        assert not missing, \
            f"Characteristic {uuid} missing properties: {missing}. " \
            f"Has: {actual_props}"

@pytest.mark.asyncio
async def test_characteristic_value_format(ble_client):
    """Characteristic values must conform to expected format."""
    
    status = await ble_client.read_gatt_char(STATUS_CHAR_UUID)
    
    # Expect: [state_byte, error_flags_byte, reserved_2bytes]
    assert len(status) == 4, f"Expected 4-byte status, got {len(status)}"
    
    state = status[0]
    assert state in {0x00, 0x01, 0x02}, f"Unknown state byte: {state:#04x}"
    
    error_flags = status[1]
    reserved = status[2:]
    assert all(b == 0 for b in reserved), f"Reserved bytes non-zero: {reserved.hex()}"

Testing Connection Parameters

@pytest.mark.asyncio
async def test_connection_interval_negotiated(ble_client):
    """Connection interval should be within spec for this device type."""
    # Connection parameters via HCI or platform-specific APIs
    # This is platform-dependent; example for Linux BlueZ
    import subprocess
    import re
    
    # hcitool con shows connection parameters
    result = subprocess.run(
        ["hcitool", "con"],
        capture_output=True, text=True
    )
    
    # Parse connection interval (in units of 1.25ms)
    match = re.search(r"interval (\d+)", result.stdout)
    if match:
        interval_units = int(match.group(1))
        interval_ms = interval_units * 1.25
        
        # For a peripheral that sends data frequently: 20-100ms is typical
        assert 15 <= interval_ms <= 200, \
            f"Connection interval {interval_ms}ms outside expected range"

BLE Protocol Testing with nRF Connect (Scripted)

Nordic's nRF Connect mobile app supports scripted BLE testing. For automated hardware validation, you can use their SDK:

# Using nRF Connect SDK's pytest integration
# Requires: nrfutil, nrf-pytest-plugin

import pytest
from nrf_pytest import ble_device

@pytest.fixture
def nrf_device(serial_port):
    """Connect to nRF development kit for peripheral testing."""
    return ble_device(serial_port, "MyPeripheral")

def test_throughput_meets_spec(nrf_device):
    """BLE throughput should meet minimum spec for this application."""
    data = bytes(range(256)) * 20  # 5KB of test data
    
    start = time.time()
    nrf_device.write_characteristic(DATA_CHAR_UUID, data)
    elapsed = time.time() - start
    
    throughput_kbps = (len(data) * 8) / elapsed / 1000
    
    assert throughput_kbps >= 100, \
        f"BLE throughput {throughput_kbps:.0f} kbps below 100 kbps requirement"

Virtual BLE Testing with BlueZ

For CI without real hardware, use Linux BlueZ's virtual HCI for unit-level testing:

# Create virtual BLE adapter
sudo modprobe hci_vhci
sudo btmgmt -i hci1 power on

# Run a BLE peripheral simulator in the background
python3 tests/ble_simulator.py --adapter hci1 &

# Run tests against the simulated device
pytest tests/test_ble_device.py --device-address AA:BB:CC:DD:EE:FF
# tests/ble_simulator.py — simple BLE peripheral simulator
import asyncio
from bless import BlessServer, BlessGATTCharacteristic

async def run_simulator():
    server = BlessServer(name="MyDevice-1234")
    
    # Battery service
    await server.add_new_service(BATTERY_SERVICE_UUID)
    await server.add_new_characteristic(
        BATTERY_SERVICE_UUID,
        BATTERY_LEVEL_UUID,
        properties=0x12,  # READ | NOTIFY
        value=bytearray([75]),  # 75% battery
    )
    
    # Custom service
    await server.add_new_service(CUSTOM_SERVICE_UUID)
    await server.add_new_characteristic(
        CUSTOM_SERVICE_UUID,
        STATUS_CHAR_UUID,
        properties=0x12,
        value=bytearray([0x00, 0x00, 0x00, 0x00]),
    )
    
    await server.start()
    print("BLE simulator running...")
    await asyncio.sleep(300)  # Run for 5 minutes
    await server.stop()

asyncio.run(run_simulator())

CI Integration

# .github/workflows/ble-tests.yml
name: BLE Tests

on:
  push:
    paths: ['firmware/**', 'tests/ble/**']

jobs:
  simulated-ble:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup BlueZ virtual HCI
        run: |
          sudo apt-get install -y bluez bluetooth
          sudo modprobe hci_vhci || true
          sudo systemctl start bluetooth
      
      - name: Install Python deps
        run: pip install bleak bless pytest pytest-asyncio
      
      - name: Start BLE simulator
        run: python3 tests/ble_simulator.py &
      
      - name: Wait for simulator
        run: sleep 2
      
      - name: Run BLE tests
        run: pytest tests/test_ble_device.py -v --simulator-mode

Common BLE Testing Issues

Flakiness from radio interference — BLE operates in the 2.4 GHz band alongside WiFi and other devices. Tests that pass in a quiet lab may fail in an office. Solutions: add retries with pytest-rerunfailures, use shielded enclosures for critical tests, prefer wired (USB/HCI) connections when available.

Timing-dependent tests — BLE connection events happen on a schedule. await asyncio.sleep(0.1) after writes is often not enough. Use notification-based synchronization instead of fixed sleeps when possible.

State from previous tests — if one test leaves the device in an unexpected state, subsequent tests fail. Use proper setup/teardown to reset device state, or run tests in isolated connections.

Summary

Automated BLE testing is achievable and worth the investment:

  1. Use Bleak for cross-platform Python BLE automation
  2. Test GATT profile completeness — verify all required services and characteristics exist with correct properties
  3. Test characteristic semantics — not just "readable" but "returns valid values"
  4. Test edge cases — invalid writes, reconnection, concurrent connections
  5. Use virtual HCI for CI without hardware
  6. Design for flakiness — BLE is wireless; add retries and use event-based sync

Hardware BLE testing still requires physical devices for RF behavior, power consumption, and real-world radio conditions. But protocol logic, GATT profile validation, and state machine testing all work reliably in automated suites.

Read more

Start now free