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:
- Discovery — find the device by name/address, scan for its services
- Connection — establish a connection and validate connection parameters
- Read/Write — read characteristic values, write commands, verify responses
- Notifications — subscribe to notifications, verify they fire correctly
- 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 NoneTesting 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-modeCommon 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:
- Use Bleak for cross-platform Python BLE automation
- Test GATT profile completeness — verify all required services and characteristics exist with correct properties
- Test characteristic semantics — not just "readable" but "returns valid values"
- Test edge cases — invalid writes, reconnection, concurrent connections
- Use virtual HCI for CI without hardware
- 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.