---
name: bsblan-testing
description: Write and run tests for the python-bsblan library. Use this skill when creating unit tests, working with fixtures, or ensuring code coverage requirements are met.
---

# Testing python-bsblan

This skill guides you through testing practices for the python-bsblan library.

## Test Structure

Tests are located in `tests/` and use pytest with async support.

### Basic Test Pattern

```python
import pytest
from bsblan import BSBLAN

@pytest.mark.asyncio
async def test_feature_name(mock_bsblan: BSBLAN) -> None:
    """Test description."""
    # Arrange
    expected_value = "expected"

    # Act
    result = await mock_bsblan.some_method()

    # Assert
    assert result == expected_value
```

### Using Fixtures

Test fixtures (JSON responses) are in `tests/fixtures/`. Common fixtures:

- `device.json` - Device information
- `state.json` - Current state
- `hot_water_state.json` - Hot water state
- `sensor.json` - Sensor readings

Load fixtures using `load_fixture(filename: str) -> str` from `tests/__init__.py`.

```python
import json

from tests import load_fixture

raw = load_fixture("device.json")
data = json.loads(raw)  # parsed dict[str, Any]
```

## Coverage Requirements

- **Total coverage**: 95%+ required
- **Patch coverage**: 100% required (all new/modified code must be fully tested)

### Check Coverage

```bash
# Full coverage report
uv run pytest --cov=src/bsblan --cov-report=term-missing

# Coverage for specific test file (useful during development)
uv run pytest tests/test_your_file.py --cov=src/bsblan --cov-report=term-missing --cov-fail-under=0

# HTML report for detailed analysis
uv run pytest --cov=src/bsblan --cov-report=html
# Then open htmlcov/index.html in browser
```

### Verify New Code is Covered

After adding new methods, always verify coverage:

1. Run tests with `--cov-report=term-missing`
2. Check the "Missing" column shows no line numbers for your new code
3. Look for uncovered branches (shown as `line->branch` like `382->386`)

Example output showing good coverage:

```text
src/bsblan/bsblan.py     426      0    170      2    99%   382->386, 1393->1391
```

The `382->386` notation means line 382's branch to line 386 isn't covered (an edge case).

### GitHub Actions Coverage

CI enforces:

- Total coverage ≥ 95%
- Patch coverage = 100% (Codecov checks new/modified lines)

If CI fails with coverage issues, check the Codecov report in the PR for uncovered lines.
If a line is genuinely untestable (for example defensive guards), mark it with
`# pragma: no cover` and justify that choice in the PR description.

## Running Tests

```bash
# Run all tests
uv run pytest

# Run specific test file
uv run pytest tests/test_bsblan.py

# Run with verbose output
uv run pytest -v

# Run specific test
uv run pytest tests/test_bsblan.py::test_function_name
```

## Prek Hooks

Always run before committing:

```bash
uv run prek run --all-files
```

This runs:

- **Ruff**: Linting and formatting (88 char line limit)
- **MyPy**: Static type checking
- **Pylint**: Code analysis

## Mock Patterns

For API calls, use `mock_bsblan` fixture and verify calls:

```python
mock_bsblan._request.assert_awaited_with(
    base_path="/JS",
    data={"Parameter": "1610", "Value": "60.0", "Type": "1"},
)
```

### Fixture Setup and Registration

Define shared fixtures in `tests/conftest.py` so pytest auto-discovers them.
Use the `mock_bsblan` pattern for naming and setup:

```python
@pytest.fixture
async def mock_bsblan(
    aresponses: ResponsesMockServer,
    monkeypatch: Any,
) -> AsyncGenerator[BSBLAN, Any]:
    ...
```

Add new JSON payloads in `tests/fixtures/` with descriptive, snake_case names
that match the behavior under test.

### Mocking HTTP Response Handling

Use `monkeypatch` with `AsyncMock` to return fixture payloads when testing
response parsing logic (not only outgoing request arguments):

```python
import json
from unittest.mock import AsyncMock

from tests import load_fixture

request_mock = AsyncMock(return_value=json.loads(load_fixture("state.json")))
monkeypatch.setattr(bsblan, "_request", request_mock)

state = await bsblan.state()
assert state.current_temperature is not None
```

## Testing Lazy Loading

When testing hot water methods, mark param groups as validated to skip network calls:

```python
@pytest.mark.asyncio
async def test_hot_water_no_params_error(monkeypatch: Any) -> None:
    """Test error when no parameters available."""
    bsblan = BSBLAN(config, session=session)

    # Set empty cache and mark group as validated
    bsblan.set_hot_water_cache({})
    bsblan._validated_hot_water_groups.add("essential")  # Skip validation

    with pytest.raises(BSBLANError, match="No essential hot water"):
        await bsblan.hot_water_state()
```

For full integration tests with mocked responses:

```python
# Mark group as validated to use cached params
bsblan._validated_hot_water_groups.add("config")
bsblan.set_hot_water_cache({"1601": "eco_mode_selection", ...})
```

## Testing Concurrent Access

The library uses asyncio locks for race condition prevention. When testing:

- Locks are created per-section/group automatically
- Access `_section_locks` and `_hot_water_group_locks` dicts if needed
- The double-checked locking pattern prevents duplicate validations

```python
# Locks are stored in these dictionaries:
bsblan._section_locks  # {"heating": Lock(), "sensor": Lock(), ...}
bsblan._hot_water_group_locks  # {"essential": Lock(), ...}
```
