Testing Guide¶
Comprehensive guide to testing with the Bluetooth SIG Standards Library.
Running Tests¶
Run All Tests¶
python -m pytest tests/ -v
Run with Coverage¶
python -m pytest tests/ --cov=src/bluetooth_sig --cov-report=html --cov-report=term-missing
Run Specific Tests¶
# Run tests for a specific module
python -m pytest tests/test_battery_level.py -v
# Run a specific test
python -m pytest tests/test_battery_level.py::TestBatteryLevel::test_decode_valid -v
# Run tests matching a pattern
python -m pytest tests/ -k "battery" -v
Testing Without Hardware¶
One of the key advantages of this library’s architecture is that you can test BLE data parsing without any BLE hardware.
Type-Safe Unit Testing (Recommended)¶
For known characteristics, use characteristic classes directly for full type inference in your tests:
import pytest
from bluetooth_sig.gatt.characteristics import (
BatteryLevelCharacteristic,
TemperatureCharacteristic,
)
class TestTypeSafeParsing:
"""Type-safe tests with full IDE support."""
def test_battery_level_parsing(self):
"""Test battery level with type-safe API."""
battery = BatteryLevelCharacteristic()
mock_data = bytearray([75]) # 75% battery
# Parse - result is int (IDE knows!)
level = battery.parse_value(mock_data)
assert level == 75
assert 0 <= level <= 100
def test_temperature_parsing(self):
"""Test temperature with type-safe API."""
temp = TemperatureCharacteristic()
mock_data = bytearray([0x64, 0x09]) # 24.04°C
# Parse - result is Temperature dataclass (IDE knows!)
result = temp.parse_value(mock_data)
assert result.temperature == pytest.approx(24.04, rel=0.01)
assert isinstance(result.temperature, float)
def test_round_trip(self):
"""Test encode/decode round-trip."""
battery = BatteryLevelCharacteristic()
original_value = 85
# Encode
encoded = battery.build_value(original_value)
# Decode back
decoded = battery.parse_value(encoded)
assert decoded == original_value
Dynamic Unit Testing (Discovery Scenarios)¶
Use the Translator API when testing code that handles unknown devices or UUID-based discovery:
import pytest
from bluetooth_sig import BluetoothSIGTranslator
class TestDynamicParsing:
"""Test dynamic parsing for discovery scenarios."""
def test_battery_level_parsing(self):
"""Test battery level parsing with UUID from discovery."""
# Simulates UUID received from BLE device discovery
battery_uuid = "2A19" # From device.services
mock_data = bytearray([75]) # 75% battery
translator = BluetoothSIGTranslator()
# Parse - library auto-identifies the characteristic
value = translator.parse_characteristic(battery_uuid, mock_data)
info = translator.get_characteristic_info_by_uuid(battery_uuid)
assert value == 75
assert info.name == "Battery Level"
def test_unknown_characteristic(self):
"""Test handling of unknown/custom characteristics."""
custom_uuid = "12345678-1234-1234-1234-123456789abc"
mock_data = bytearray([0x01, 0x02, 0x03])
translator = BluetoothSIGTranslator()
# Unknown characteristics are not supported
assert not translator.supports(custom_uuid)
# Attempting to parse raises CharacteristicParseError
from bluetooth_sig.gatt.exceptions import CharacteristicParseError
with pytest.raises(CharacteristicParseError):
translator.parse_characteristic(custom_uuid, mock_data)
Testing Error Conditions¶
Test error handling with characteristic classes (type-safe):
import pytest
from bluetooth_sig.gatt.characteristics import BatteryLevelCharacteristic
from bluetooth_sig.gatt.exceptions import (
InsufficientDataError,
ValueRangeError,
)
class TestErrorHandling:
"""Test error handling with type-safe API."""
def test_insufficient_data(self):
"""Test error when data is too short."""
battery = BatteryLevelCharacteristic()
with pytest.raises(InsufficientDataError):
battery.parse_value(bytearray([])) # Empty data
def test_out_of_range_value(self):
"""Test error when value is out of range."""
battery = BatteryLevelCharacteristic()
with pytest.raises(ValueRangeError):
battery.parse_value(bytearray([150])) # > 100%
Mocking BLE Interactions¶
When integrating with BLE libraries, you can mock the BLE operations:
Type-Safe Mocking with bleak (Recommended)¶
from unittest.mock import AsyncMock, patch
import pytest
from bluetooth_sig.gatt.characteristics import BatteryLevelCharacteristic
@pytest.fixture
def mock_bleak_client():
"""Mock BleakClient for testing."""
with patch("bleak.BleakClient") as mock:
client = AsyncMock()
mock.return_value.__aenter__.return_value = client
yield client
@pytest.mark.asyncio
async def test_read_battery_type_safe(mock_bleak_client):
"""Test reading battery level with type-safe API."""
battery = BatteryLevelCharacteristic()
mock_battery_data = bytearray([85]) # 85% battery
# Setup mock
mock_bleak_client.read_gatt_char.return_value = mock_battery_data
# Read using characteristic's UUID property
raw_data = await mock_bleak_client.read_gatt_char(str(battery.uuid))
# Parse with type-safety - level is int
level = battery.parse_value(bytearray(raw_data))
assert level == 85
mock_bleak_client.read_gatt_char.assert_called_once_with(str(battery.uuid))
Dynamic Mocking with bleak¶
For testing discovery scenarios with unknown UUIDs:
from unittest.mock import AsyncMock, patch
import pytest
from bluetooth_sig import BluetoothSIGTranslator
@pytest.fixture
def mock_bleak_client():
"""Mock BleakClient for testing."""
with patch("bleak.BleakClient") as mock:
client = AsyncMock()
mock.return_value.__aenter__.return_value = client
yield client
@pytest.mark.asyncio
async def test_read_battery_dynamic(mock_bleak_client):
"""Test reading battery level with dynamic API."""
# UUID from device discovery
discovered_uuid = "2A19"
mock_battery_data = bytearray([85]) # 85% battery
mock_bleak_client.read_gatt_char.return_value = mock_battery_data
translator = BluetoothSIGTranslator()
raw_data = await mock_bleak_client.read_gatt_char(discovered_uuid)
value = translator.parse_characteristic(discovered_uuid, raw_data)
info = translator.get_characteristic_info_by_uuid(discovered_uuid)
assert value == 85
assert info.name == "Battery Level"
Mocking simplepyble¶
from unittest.mock import Mock
from bluetooth_sig.gatt.characteristics import BatteryLevelCharacteristic
from bluetooth_sig.gatt.services import BatteryService
def test_read_battery_simplepyble_mock():
"""Test reading battery with mocked simplepyble."""
battery = BatteryLevelCharacteristic()
battery_service = BatteryService()
mock_battery_data = bytes([75]) # 75% battery
# Create mock peripheral
mock_peripheral = Mock()
mock_peripheral.read.return_value = mock_battery_data
# Read using service and characteristic UUIDs
raw_data = mock_peripheral.read(
str(battery_service.uuid), str(battery.uuid)
)
# Parse with type-safety
level = battery.parse_value(bytearray(raw_data))
assert level == 75
mock_peripheral.read.assert_called_once()
Test Data Generation¶
Creating Test Vectors¶
class TestDataFactory:
"""Factory for creating test data."""
@staticmethod
def battery_level(percentage: int) -> bytearray:
"""Create battery level test data."""
assert 0 <= percentage <= 100
return bytearray([percentage])
@staticmethod
def temperature(celsius: float) -> bytearray:
"""Create temperature test data."""
# Temperature encoded as sint16 with 0.01°C resolution
value = int(celsius * 100)
return bytearray(value.to_bytes(2, byteorder="little", signed=True))
@staticmethod
def humidity(percentage: float) -> bytearray:
"""Create humidity test data."""
# Humidity encoded as uint16 with 0.01% resolution
value = int(percentage * 100)
return bytearray(value.to_bytes(2, byteorder="little", signed=False))
# Usage
def test_with_factory():
# ============================================
# TEST DATA - From factory helpers
# ============================================
BATTERY_UUID = "2A19"
TEMP_UUID = "2A6E"
HUMIDITY_UUID = "2A6F"
translator = BluetoothSIGTranslator()
# Generate test data
battery_data = TestDataFactory.battery_level(85)
temp_data = TestDataFactory.temperature(24.04)
humidity_data = TestDataFactory.humidity(49.22)
# Test parsing - returns values directly (not wrapped)
assert translator.parse_characteristic(BATTERY_UUID, battery_data) == 85
assert translator.parse_characteristic(TEMP_UUID, temp_data) == 24.04
assert (
translator.parse_characteristic(HUMIDITY_UUID, humidity_data) == 49.22
)
Parametrized Testing¶
Test multiple scenarios efficiently:
import pytest
@pytest.mark.parametrize(
"battery_level,expected",
[
(0, 0),
(25, 25),
(50, 50),
(75, 75),
(100, 100),
],
)
def test_battery_levels(battery_level, expected):
"""Test various battery levels."""
BATTERY_LEVEL_UUID = "2A19" # UUID from BLE spec
translator = BluetoothSIGTranslator()
data = bytearray([battery_level])
value = translator.parse_characteristic(BATTERY_LEVEL_UUID, data)
assert value == expected
@pytest.mark.parametrize(
"invalid_data",
[
bytearray([]), # Too short
bytearray([101]), # Too high
bytearray([255]), # Way too high
],
)
def test_invalid_battery_data(invalid_data):
"""Test error handling for invalid data."""
BATTERY_LEVEL_UUID = "2A19" # UUID from BLE spec
translator = BluetoothSIGTranslator()
with pytest.raises((InsufficientDataError, ValueRangeError)):
translator.parse_characteristic(BATTERY_LEVEL_UUID, invalid_data)
Testing with Fixtures¶
Pytest Fixtures¶
import pytest
from bluetooth_sig import BluetoothSIGTranslator
@pytest.fixture
def translator():
"""Provide a translator instance."""
return BluetoothSIGTranslator()
@pytest.fixture
def valid_battery_data():
"""Provide valid battery level data."""
return bytearray([75])
@pytest.fixture
def valid_temp_data():
"""Provide valid temperature data."""
return bytearray([0x64, 0x09]) # 24.04°C
def test_with_fixtures(translator, valid_battery_data):
"""Test using fixtures."""
BATTERY_LEVEL_UUID = "2A19" # UUID from BLE spec
value = translator.parse_characteristic(
BATTERY_LEVEL_UUID, valid_battery_data
)
assert value == 75
Integration Testing¶
Test complete workflows:
class TestIntegration:
"""Integration tests for complete workflows."""
def test_multiple_characteristics(self):
"""Test parsing multiple characteristics."""
# ============================================
# SIMULATED DATA - Multiple sensor readings
# ============================================
BATTERY_UUID = "2A19"
TEMP_UUID = "2A6E"
HUMIDITY_UUID = "2A6F"
translator = BluetoothSIGTranslator()
# Simulate reading multiple characteristics
sensor_data = {
BATTERY_UUID: bytearray([85]), # Battery: 85%
TEMP_UUID: bytearray([0x64, 0x09]), # Temp: 24.04°C
HUMIDITY_UUID: bytearray([0x3A, 0x13]), # Humidity: 49.22%
}
results = {}
for uuid, data in sensor_data.items():
results[uuid] = translator.parse_characteristic(uuid, data)
# Verify all parsed correctly - values returned directly
assert results[BATTERY_UUID] == 85
assert results[TEMP_UUID] == 24.04
assert results[HUMIDITY_UUID] == 49.22
def test_uuid_resolution_workflow(self):
"""Test UUID resolution workflow."""
BATTERY_LEVEL_UUID = "2A19" # UUID from BLE spec
translator = BluetoothSIGTranslator()
# Resolve UUID to name
char_info = translator.get_sig_info_by_uuid(BATTERY_LEVEL_UUID)
assert char_info.name == "Battery Level"
# Resolve name to UUID
battery_uuid = translator.get_sig_info_by_name("Battery Level")
assert battery_uuid.uuid == BATTERY_LEVEL_UUID
# Round-trip
assert (
translator.get_sig_info_by_uuid(battery_uuid.uuid).name
== char_info.name
)
Performance Testing¶
import time
def test_parsing_performance():
"""Test parsing performance."""
BATTERY_LEVEL_UUID = "2A19" # UUID from BLE spec
data = bytearray([75]) # Test data
translator = BluetoothSIGTranslator()
# Warm up
for _ in range(100):
translator.parse_characteristic(BATTERY_LEVEL_UUID, data)
# Measure
start = time.perf_counter()
iterations = 10000
for _ in range(iterations):
translator.parse_characteristic(BATTERY_LEVEL_UUID, data)
elapsed = time.perf_counter() - start
# Should be fast (< 100μs per parse)
avg_time = elapsed / iterations
assert avg_time < 0.0001, (
f"Parsing too slow: {avg_time:.6f}s per iteration"
)
print(f"Average parse time: {avg_time * 1000000:.1f}μs")
Test Organization¶
Recommended test structure:
tests/
├── conftest.py # Shared fixtures
├── test_core/
│ ├── test_translator.py # Core API tests
│ └── test_uuid_resolution.py # UUID resolution tests
├── test_characteristics/
│ ├── test_battery_level.py # Battery characteristic
│ ├── test_temperature.py # Temperature characteristic
│ └── test_humidity.py # Humidity characteristic
├── test_services/
│ └── test_battery_service.py # Service tests
├── test_registry/
│ └── test_uuid_registry.py # Registry tests
└── test_integration/
└── test_workflows.py # End-to-end tests
Continuous Integration¶
Example GitHub Actions workflow:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip install -e ".[dev,test]"
- name: Run tests
run: |
pytest tests/ --cov=src/bluetooth_sig --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v3
Best Practices¶
1. Test One Thing at a Time¶
# ✅ Good - tests one aspect
def test_battery_valid_value():
BATTERY_LEVEL_UUID = "2A19" # UUID from BLE spec
translator = BluetoothSIGTranslator()
value = translator.parse_characteristic(
BATTERY_LEVEL_UUID, bytearray([75])
)
assert value == 75
def test_battery_invalid_value():
BATTERY_LEVEL_UUID = "2A19" # UUID from BLE spec
translator = BluetoothSIGTranslator()
with pytest.raises(ValueRangeError):
translator.parse_characteristic(BATTERY_LEVEL_UUID, bytearray([150]))
# ❌ Bad - tests multiple things
def test_battery_everything():
translator = BluetoothSIGTranslator()
# Tests too many scenarios in one test
...
2. Use Descriptive Names¶
# ✅ Good
def test_battery_level_rejects_value_above_100(): ...
# ❌ Bad
def test_battery_1(): ...
3. Arrange-Act-Assert Pattern¶
def test_temperature_parsing():
# Arrange
TEMP_UUID = "2A6E" # Temperature characteristic UUID
data = bytearray([0x64, 0x09]) # 24.04°C
translator = BluetoothSIGTranslator()
# Act
value = translator.parse_characteristic(TEMP_UUID, data)
# Assert
assert value == 24.04
Next Steps¶
Contributing Guide - Contribute to the project
API Reference - API documentation
Examples - More examples