Files
ss-tools/backend/tests/plugins/test_load_testing_plugin.py
busya 488a8f349b test(backend): raise coverage to 95%+ statements and branches (97.8%/95.0%)
- ~60 new/extended test files across api, core, plugins, services, schemas:
  routes, superset clients, task_manager, lineage, git, translate,
  dashboard-testing, load-testing, migration, llm_analysis, scheduler, ssl
- .coveragerc: enable branch coverage; exclude src/__tests__ (test files)
  and src/scripts (CLI/ops tools) from the denominator
- bug fixes found while testing:
  * settings: PUT /settings/reports registered under duplicated prefix
  * schemas/lineage: FleetReportDTO missing run_status (route always 500)
  * dashboard_testing/baseline_inheritance: visual entry read wrong field
  * superset_client/_databases: logger extra name shadowed LogRecord attr
  * routes/datasets: _yaml_string_paths recursion without yield from
  * translate/sql_generator: restore explicit-type timestamp contract
  * baseline_catalog: remove unreachable dashboard_id fallback
- conftest fixes: pytest_plugins to rootdir conftest (pytest 9), test
  filename collision, TMPDIR-safe integration fixtures
2026-08-19 17:14:32 +03:00

394 lines
18 KiB
Python

# #region Test.LoadTesting.Plugin [C:3] [TYPE Module] [SEMANTICS test,load-testing,plugin,lifecycle]
# @BRIEF Verify LoadTestingPlugin metadata + execute() + run_load_run lifecycle: happy path,
# lifecycle-only (no env), user cancellation, unexpected failure, circuit-breaker abort,
# missing run, missing param, hold_seconds ramp delay.
# @RELATION BINDS_TO -> [Plugin.LoadTesting.LoadTestingPlugin]
# @TEST_EDGE: missing_load_run_id -> execute raises ValueError
# @TEST_EDGE: run_not_found -> execute returns failed dict and closes the session
# @TEST_EDGE: user_stop -> stopped_by_user persisted, CancelledError re-raised
# @TEST_EDGE: unexpected_error -> failed persisted with stop_reason
# @TEST_EDGE: breaker_trip -> circuit_breaker_abort persisted with stop_reason
# @TEST_EDGE: zero_items_lifecycle_only -> run still reaches completed
# @TEST_INVARIANT: terminal_run_immutable -> VERIFIED_BY: test_execute_breaker_abort_persists_terminal, test_execute_cancel_persists_terminal
# @TEST_INVARIANT: one_task_per_run -> VERIFIED_BY: test_execute_happy_path_binds_task_id
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session
from src.models.load_testing import LoadRun, RunPhase, RunStatus
from src.models.mapping import Base
from src.plugins.load_testing import LoadTestingPlugin, run_load_run
# #region Test.LoadTesting.Plugin.Fakes [C:1] [TYPE Class]
# @BRIEF Hardcoded fakes for external boundaries (RunnerPool stand-in, task manager, task context).
class _FakeRunnerPool:
"""RunnerPool stand-in: drains items and invokes on_result per item (no real workers)."""
def __init__(self, *, capacity, executor, env_capacity_semaphore, breaker, on_result):
self.capacity = capacity
self.executor = executor
self.env_capacity_semaphore = env_capacity_semaphore
self.breaker = breaker
self.on_result = on_result
self.outcome = "success"
self.results = []
async def run(self, items):
self.results = []
for it in items:
record = {
"execution_id": it.get("execution_id"),
"outcome": self.outcome,
"upstream_latency_ms": 11,
}
if self.on_result is not None:
await self.on_result(record)
self.results.append(record)
return self.results
class _FakeTaskManager:
def __init__(self, task_id="task-1"):
self.task_id = task_id
self.calls = []
async def create_task(self, plugin_id, params):
self.calls.append((plugin_id, params))
return SimpleNamespace(id=self.task_id)
def _make_context(info_side_effect=None):
logger = SimpleNamespace(
info=AsyncMock(side_effect=info_side_effect),
progress=MagicMock(),
)
return SimpleNamespace(logger=logger)
# #endregion Test.LoadTesting.Plugin.Fakes
# #region Test.LoadTesting.Plugin.Db [C:1] [TYPE Function]
# @BRIEF Build an isolated FK-enforced in-memory SQLite session with all tables.
@pytest.fixture()
def db():
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON"))
Base.metadata.create_all(bind=engine)
session = Session(bind=engine)
yield session
session.close()
engine.dispose()
# #endregion Test.LoadTesting.Plugin.Db
def _make_run(db: Session, run_id: str = "run-1", **overrides) -> LoadRun:
fields = dict(
id=run_id,
environment_id="env-dev",
dashboard_id=400,
status=RunStatus.QUEUED,
phase=RunPhase.RAMP,
)
fields.update(overrides)
run = LoadRun(**fields)
db.add(run)
db.commit()
return run
def _lifecycle_patches(outcome="success"):
"""Boundary mocks so run_load_run can complete through a fake RunnerPool."""
cm = MagicMock()
cm.get_environment.return_value = SimpleNamespace(id="env-dev")
fake_pool = None
def _make_pool(**kwargs):
nonlocal fake_pool
fake_pool = _FakeRunnerPool(**kwargs)
fake_pool.outcome = outcome
return fake_pool
patchers = [
patch("src.dependencies.get_config_manager", return_value=cm),
patch("src.services.load_testing.runner_pool.RunnerPool", side_effect=_make_pool),
patch("src.services.load_testing.persistence.write_load_executions"),
]
return patchers, lambda: fake_pool
# #region Test.LoadTesting.Plugin.Metadata [C:2] [TYPE Class]
# @BRIEF Plugin static metadata + param schema contract.
class TestPluginMetadata:
"""Verify plugin identity and schema contract."""
# #region Test.LoadTesting.Plugin.TestMetadataProperties [C:2] [TYPE Function]
# @BRIEF id/name/description/version/ui_route return the documented constants.
def test_metadata_properties(self):
plugin = LoadTestingPlugin()
assert plugin.id == "load_testing"
assert plugin.name == "Dashboard Load Testing"
assert plugin.description.startswith("Executes one bounded load run")
assert plugin.version == "1.0.0"
assert plugin.ui_route == ""
# #endregion Test.LoadTesting.Plugin.TestMetadataProperties
# #region Test.LoadTesting.Plugin.TestSchemaRequiresRunId [C:2] [TYPE Function]
# @BRIEF get_schema declares load_run_id required and exposes test/seed hooks.
def test_schema_requires_run_id(self):
schema = LoadTestingPlugin().get_schema()
assert schema["type"] == "object"
assert schema["required"] == ["load_run_id"]
assert "hold_seconds" in schema["properties"]
assert schema["properties"]["execution_specs"]["type"] == "array"
# #endregion Test.LoadTesting.Plugin.TestSchemaRequiresRunId
# #endregion Test.LoadTesting.Plugin.Metadata
# #region Test.LoadTesting.Plugin.Execute [C:2] [TYPE Class]
# @BRIEF execute() param validation, run resolution, task_id reconciliation, session close.
class TestExecute:
"""Verify the plugin entrypoint contract."""
# #region Test.LoadTesting.Plugin.TestExecuteMissingRunId [C:2] [TYPE Function]
# @BRIEF Missing/empty load_run_id raises ValueError before touching the DB.
@pytest.mark.asyncio
async def test_execute_missing_run_id_raises(self):
plugin = LoadTestingPlugin()
with pytest.raises(ValueError, match="load_run_id is required"):
await plugin.execute({}, context=None)
with pytest.raises(ValueError, match="load_run_id is required"):
await plugin.execute({"load_run_id": ""}, context=None)
# #endregion Test.LoadTesting.Plugin.TestExecuteMissingRunId
# #region Test.LoadTesting.Plugin.TestExecuteRunNotFound [C:2] [TYPE Function]
# @BRIEF Unknown run id returns a failed result and still closes the session.
@pytest.mark.asyncio
async def test_execute_run_not_found(self):
plugin = LoadTestingPlugin()
fake_db = MagicMock()
fake_db.get.return_value = None
with patch("src.plugins.load_testing.SessionLocal", return_value=fake_db):
result = await plugin.execute({"load_run_id": "nope"}, context=None)
assert result == {"status": "failed", "error": "LoadRun nope not found"}
fake_db.close.assert_called_once()
# #endregion Test.LoadTesting.Plugin.TestExecuteRunNotFound
# #region Test.LoadTesting.Plugin.TestExecuteHappyPathBindsTaskId [C:2] [TYPE Function]
# @BRIEF Happy path: run resolves, _task_id is bound when run.task_id is empty, run completes.
@pytest.mark.asyncio
async def test_execute_happy_path_binds_task_id(self, db: Session):
_make_run(db, run_id="run-ok")
plugin = LoadTestingPlugin()
patchers, get_pool = _lifecycle_patches()
with (
patch("src.plugins.load_testing.SessionLocal", return_value=db),
patchers[0], patchers[1], patchers[2],
):
result = await plugin.execute(
{
"load_run_id": "run-ok",
"_task_id": "task-9",
"execution_specs": [{"execution_id": "exec-1", "chart_id": 7, "variation_id": "v1", "filters_hash": "h1"}],
},
context=None,
)
assert result["status"] == "completed"
assert result["load_run_id"] == "run-ok"
run = db.get(LoadRun, "run-ok")
assert run.task_id == "task-9"
assert run.status == RunStatus.COMPLETED
assert run.phase == RunPhase.TERMINAL
assert run.finished_at is not None
assert get_pool().capacity == 1
# #endregion Test.LoadTesting.Plugin.TestExecuteHappyPathBindsTaskId
# #region Test.LoadTesting.Plugin.TestExecuteKeepsExistingTaskId [C:2] [TYPE Function]
# @BRIEF When run.task_id is already set, _task_id must not overwrite it.
@pytest.mark.asyncio
async def test_execute_keeps_existing_task_id(self, db: Session):
_make_run(db, run_id="run-bound", task_id="task-old")
plugin = LoadTestingPlugin()
patchers, _ = _lifecycle_patches()
with (
patch("src.plugins.load_testing.SessionLocal", return_value=db),
patchers[0], patchers[1], patchers[2],
):
await plugin.execute({"load_run_id": "run-bound", "_task_id": "task-new"}, context=None)
assert db.get(LoadRun, "run-bound").task_id == "task-old"
# #endregion Test.LoadTesting.Plugin.TestExecuteKeepsExistingTaskId
# #endregion Test.LoadTesting.Plugin.Execute
# #region Test.LoadTesting.Plugin.RunLifecycle [C:2] [TYPE Class]
# @BRIEF run_load_run transitions ramp -> steady -> drain -> terminal with cancellation/failure.
class TestRunLifecycle:
"""Verify the run lifecycle state machine."""
# #region Test.LoadTesting.Plugin.TestLifecycleHappyPath [C:2] [TYPE Function]
# @BRIEF specs path: pool drains, breaker stays closed, run completes with total_executions.
@pytest.mark.asyncio
async def test_lifecycle_happy_path(self, db: Session):
run = _make_run(db, run_id="run-happy")
ctx = _make_context()
patchers, get_pool = _lifecycle_patches()
with patchers[0], patchers[1], patchers[2]:
result = await run_load_run(
db,
run,
{"load_run_id": "run-happy", "execution_specs": [{"execution_id": "e1"}, {"execution_id": "e2"}]},
ctx,
)
assert result == {"status": "completed", "load_run_id": "run-happy"}
fresh = db.get(LoadRun, "run-happy")
assert fresh.status == RunStatus.COMPLETED
assert fresh.phase == RunPhase.TERMINAL
assert fresh.total_executions == 2
assert fresh.started_at is not None
assert len(get_pool().results) == 2
ctx.logger.info.assert_awaited_once()
ctx.logger.progress.assert_called()
# #endregion Test.LoadTesting.Plugin.TestLifecycleHappyPath
# #region Test.LoadTesting.Plugin.TestLifecycleKeepsExistingTotal [C:2] [TYPE Function]
# @BRIEF A pre-set total_executions is never overwritten by the specs length.
@pytest.mark.asyncio
async def test_lifecycle_keeps_existing_total(self, db: Session):
run = _make_run(db, run_id="run-total", total_executions=7)
patchers, _ = _lifecycle_patches()
with patchers[0], patchers[1], patchers[2]:
await run_load_run(db, run, {"load_run_id": "run-total", "execution_specs": [{"execution_id": "e1"}]}, None)
assert db.get(LoadRun, "run-total").total_executions == 7
# #endregion Test.LoadTesting.Plugin.TestLifecycleKeepsExistingTotal
# #region Test.LoadTesting.Plugin.TestLifecycleHoldSeconds [C:2] [TYPE Function]
# @BRIEF hold_seconds > 0 delays the ramp with asyncio.sleep before steady.
@pytest.mark.asyncio
async def test_lifecycle_hold_seconds_sleeps(self, db: Session):
run = _make_run(db, run_id="run-hold")
patchers, _ = _lifecycle_patches()
with patchers[0], patchers[1], patchers[2], patch(
"src.plugins.load_testing.asyncio.sleep", new=AsyncMock()
) as mock_sleep:
await run_load_run(db, run, {"load_run_id": "run-hold", "hold_seconds": 3}, None)
mock_sleep.assert_awaited_once_with(3)
# #endregion Test.LoadTesting.Plugin.TestLifecycleHoldSeconds
# #region Test.LoadTesting.Plugin.TestLifecycleOnlyNoEnv [C:2] [TYPE Function]
# @BRIEF Missing environment -> resolved scope None -> lifecycle-only run still completes.
@pytest.mark.asyncio
async def test_lifecycle_only_no_env(self, db: Session):
run = _make_run(db, run_id="run-lifeonly")
cm = MagicMock()
cm.get_environment.return_value = None
with patch("src.dependencies.get_config_manager", return_value=cm):
result = await run_load_run(db, run, {"load_run_id": "run-lifeonly"}, None)
assert result["status"] == "completed"
assert db.get(LoadRun, "run-lifeonly").status == RunStatus.COMPLETED
# #endregion Test.LoadTesting.Plugin.TestLifecycleOnlyNoEnv
# #region Test.LoadTesting.Plugin.TestLifecycleBreakerAbort [C:2] [TYPE Function]
# @BRIEF >min_samples error outcomes trip the breaker -> circuit_breaker_abort persisted,
# with a 100% progress entry emitted through the task context.
@pytest.mark.asyncio
async def test_lifecycle_breaker_abort(self, db: Session):
run = _make_run(db, run_id="run-abort")
ctx = _make_context()
specs = [{"execution_id": f"e{i}", "chart_id": 1, "variation_id": "v", "filters_hash": "h"} for i in range(21)]
patchers, _ = _lifecycle_patches(outcome="error")
with patchers[0], patchers[1], patchers[2]:
result = await run_load_run(db, run, {"load_run_id": "run-abort", "execution_specs": specs}, ctx)
assert result == {"status": RunStatus.CIRCUIT_BREAKER_ABORT, "load_run_id": "run-abort"}
fresh = db.get(LoadRun, "run-abort")
assert fresh.status == RunStatus.CIRCUIT_BREAKER_ABORT
assert fresh.phase == RunPhase.TERMINAL
assert fresh.stop_reason == "circuit_breaker_abort:error_rate"
ctx.logger.progress.assert_any_call("Load run run-abort aborted by breaker", percent=100)
# #endregion Test.LoadTesting.Plugin.TestLifecycleBreakerAbort
# #region Test.LoadTesting.Plugin.TestLifecycleBreakerAbortNoContext [C:2] [TYPE Function]
# @BRIEF Breaker abort without a task context still persists the terminal state.
@pytest.mark.asyncio
async def test_lifecycle_breaker_abort_no_context(self, db: Session):
run = _make_run(db, run_id="run-abort2")
specs = [{"execution_id": f"e{i}", "chart_id": 1, "variation_id": "v", "filters_hash": "h"} for i in range(21)]
patchers, _ = _lifecycle_patches(outcome="error")
with patchers[0], patchers[1], patchers[2]:
result = await run_load_run(db, run, {"load_run_id": "run-abort2", "execution_specs": specs}, None)
assert result["status"] == RunStatus.CIRCUIT_BREAKER_ABORT
assert db.get(LoadRun, "run-abort2").status == RunStatus.CIRCUIT_BREAKER_ABORT
# #endregion Test.LoadTesting.Plugin.TestLifecycleBreakerAbortNoContext
# #region Test.LoadTesting.Plugin.TestLifecycleBreakerNoneGuard [C:2] [TYPE Function]
# @BRIEF Defensive guard: a None breaker (factory seam) must not crash the abort check.
# _build_breaker is patched to return None to exercise the defensive branch that the
# real factory never produces (run_load_run treats breaker as optional).
@pytest.mark.asyncio
async def test_lifecycle_breaker_none_guard(self, db: Session):
run = _make_run(db, run_id="run-none")
cm = MagicMock()
cm.get_environment.return_value = SimpleNamespace(id="env-dev")
fake_pool = _FakeRunnerPool(capacity=1, executor=None, env_capacity_semaphore=None, breaker=None, on_result=None)
with (
patch("src.dependencies.get_config_manager", return_value=cm),
patch("src.services.load_testing.runner_pool.RunnerPool", return_value=fake_pool),
patch("src.plugins.load_testing._build_breaker", return_value=None),
):
result = await run_load_run(
db, run, {"load_run_id": "run-none", "execution_specs": [{"execution_id": "e1"}]}, None
)
assert result["status"] == "completed"
assert db.get(LoadRun, "run-none").status == RunStatus.COMPLETED
# #endregion Test.LoadTesting.Plugin.TestLifecycleBreakerNoneGuard
# #region Test.LoadTesting.Plugin.TestLifecycleCancel [C:2] [TYPE Function]
# @BRIEF User stop: CancelledError inside the try -> stopped_by_user persisted and re-raised.
@pytest.mark.asyncio
async def test_lifecycle_cancel_persists_terminal(self, db: Session):
_make_run(db, run_id="run-cancel")
ctx = _make_context()
with patch(
"src.plugins.load_testing.asyncio.sleep",
new=AsyncMock(side_effect=asyncio.CancelledError()),
):
with pytest.raises(asyncio.CancelledError):
await run_load_run(
db, db.get(LoadRun, "run-cancel"),
{"load_run_id": "run-cancel", "hold_seconds": 1}, ctx,
)
fresh = db.get(LoadRun, "run-cancel")
assert fresh.status == RunStatus.STOPPED_BY_USER
assert fresh.phase == RunPhase.TERMINAL
assert fresh.stop_reason == "user_stop"
assert fresh.finished_at is not None
# #endregion Test.LoadTesting.Plugin.TestLifecycleCancel
# #region Test.LoadTesting.Plugin.TestLifecycleFailure [C:2] [TYPE Function]
# @BRIEF Unexpected error inside the try: rollback + reload -> failed persisted with stop_reason.
@pytest.mark.asyncio
async def test_lifecycle_failure_persists_terminal(self, db: Session):
_make_run(db, run_id="run-fail")
ctx = _make_context()
with patch(
"src.plugins.load_testing.asyncio.sleep",
new=AsyncMock(side_effect=RuntimeError("boom")),
):
result = await run_load_run(
db, db.get(LoadRun, "run-fail"),
{"load_run_id": "run-fail", "hold_seconds": 1}, ctx,
)
assert result == {"status": "failed", "error": "boom"}
fresh = db.get(LoadRun, "run-fail")
assert fresh.status == RunStatus.FAILED
assert fresh.phase == RunPhase.TERMINAL
assert fresh.stop_reason == "boom"
assert fresh.finished_at is not None
# #endregion Test.LoadTesting.Plugin.TestLifecycleFailure
# #endregion Test.LoadTesting.Plugin.RunLifecycle
# #endregion Test.LoadTesting.Plugin