Skip to content

perf(tests): cut the unit test suite from 123s to 78s - #4966

Merged
springfall2008 merged 1 commit into
mainfrom
perf/speed-up-unit-test-suite
Sep 6, 2026
Merged

perf(tests): cut the unit test suite from 123s to 78s#4966
springfall2008 merged 1 commit into
mainfrom
perf/speed-up-unit-test-suite

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

What

./run_all --quick went from 122.9s to 78s (−37%), and the full suite from 155s to ~110s. Two production changes carry most of it; the rest are tests that were waiting on real delays no assertion depended on.

How this was found

Profiled the suite by wrapping time.sleep/asyncio.sleep and attributing real seconds to each call site: 38.2s of the 123s was spent literally asleep. Profiling the remaining hot spots with cProfile turned up one avoidable cost dominating the rest — parsing multi-megabyte debug dumps with PyYAML's pure-Python parser.

Production changes

read_debug_yaml() now loads through libyaml's CLoader. CLoader pairs the C parser with the very same (unsafe) Constructor that yaml.unsafe_load uses, so the Python-object tags these dumps carry still load and the result is identical:

pure-Python unsafe_load: 6.55s
libyaml CLoader        : 1.14s   (5.8x faster)
identical result       : True

It falls back to the pure-Python loader when PyYAML was not built with libyaml. This speeds up real --debug_file replays for users too, not just tests — every path that reads a dump was paying it.

ComponentBase.wait_api_started() polls every 0.1s instead of every 1s. Components come up in milliseconds, so the old cadence spent nearly the whole wait asleep after the component was already live. The loop is now bounded by a monotonic deadline rather than by counting ticks, so timeout stays honest in seconds however often the flag is checked.

Test changes

Each of these waited on a real delay nothing asserted on:

  • test_givtcp_component_make_component() records the REST retry waits instead of taking them, mirroring test_givtcp_rest.py's _client. One test asserting a write never verifies was walking the whole 5 × 2s ladder.
  • test_alphaess_apiMockAlphaESS was inheriting initialize()'s real 2s api_delay; only 11 of 50 tests overrode it by hand. The transport-failure test also served the full 3s retry backoff.
  • test_ohme / test_ge_cloud — skip the retry wait and the rate-limit pacing, as neighbouring tests in those same files already do.
  • test_chat_fast_confirm_poll() shortens (rather than removes, so the loop is still exercised) await_confirmation's 0.2s poll for tests whose background thread answers immediately. Same idiom the file already uses for CONFIRM_TIMEOUT_SECONDS.

Tooling

  • --quick's help named optimise_levels, optimise_windows and debug_cases — none of which are marked slow — while the four that are went unmentioned. It is now derived from TEST_REGISTRY so it cannot drift again.
  • Each test logs a wall-clock start/end stamp, so a run that stalls is identifiable from its unmatched start stamp rather than needing a re-run.

Results

test before after
debug_cases 22.56s 12.01s
random 27.94s 22.44s
givtcp_component 10.04s 0.03s
alphaess_api 7.05s 0.04s
window_cache 7.47s 2.09s
web_if 3.37s 1.57s
chat 3.50s 2.52s
debug_yaml_scope 2.04s 0.19s
ge_cloud 1.67s 1.04s
ohme 1.01s 0.01s

window_cache is representative: 93% of it was parsing a 5.3MB dump, not exercising the cache.

What was deliberately left alone

Roughly 19s of sleep remains and most of it should. The test_chat, test_agent_tools and test_db_manager sleeps are the assertion — they prove the event loop is not blocked, and mocking them would delete what the test checks. annual_job spawns real subprocesses. The web.py server heartbeat and test_component_base's already-scaled fast_sleep run concurrently with real work rather than adding wall-clock.

Testing

./run_pre_commit green on the rebased branch — all hooks pass, all 306 tests pass. No test behaviour was weakened: every stubbed sleep is a delay the surrounding assertions never read.

🤖 Generated with Claude Code

Profiling `--quick` by wrapping time.sleep/asyncio.sleep showed 38s of its
123s was spent literally asleep, and most of the rest of the hot spots were
one avoidable cost: parsing multi-megabyte debug dumps with PyYAML's
pure-Python parser.

Production changes:

- read_debug_yaml() now loads through libyaml's CLoader. It pairs the C
  parser with the very same (unsafe) Constructor yaml.unsafe_load uses, so
  the Python-object tags these dumps carry still load and the result is
  identical - verified equal on the committed cases - about 6x faster on a
  5MB dump. Falls back to the pure-Python loader when PyYAML was not built
  with libyaml. This speeds up real --debug_file replays too, not just tests.

- ComponentBase.wait_api_started() polls every 0.1s instead of every 1s.
  Components come up in milliseconds, so the old cadence spent nearly the
  whole wait asleep after the component was already live. The loop is now
  bounded by a monotonic deadline rather than by counting ticks, so `timeout`
  stays honest in seconds however often the flag is checked.

Test changes - each of these waited on a real delay that no assertion
depended on:

- test_givtcp_component: _make_component() records the REST retry waits
  instead of taking them, mirroring test_givtcp_rest.py's _client. One test
  asserting a write never verifies was walking the whole 5 x 2s ladder.
- test_alphaess_api: MockAlphaESS was inheriting initialize()'s real 2s
  api_delay, and the transport-failure test served the full retry backoff.
- test_ohme / test_ge_cloud: skip the retry wait and the rate-limit pacing,
  as the neighbouring tests in those files already do.
- test_chat: _fast_confirm_poll() shortens (not removes) await_confirmation's
  0.2s poll for tests whose background thread answers immediately.

Tooling:

- --quick's help named three tests that are not marked slow while the four
  that are went unmentioned; it is now derived from TEST_REGISTRY so it
  cannot drift again.
- Each test logs a wall-clock start/end stamp, so a run that stalls can be
  read straight from the log by its unmatched start stamp.

  debug_cases       22.56s -> 12.01s      random       27.94s -> 22.44s
  givtcp_component  10.04s ->  0.03s      web_if        3.37s ->  1.57s
  alphaess_api       7.05s ->  0.04s      chat          3.50s ->  2.52s
  window_cache       7.47s ->  2.09s      debug_yaml_scope 2.04s -> 0.19s
  ge_cloud           1.67s ->  1.04s      ohme          1.01s ->  0.01s

Sleeps left in place are the ones that are themselves the assertion - the
tests proving the event loop is not blocked, and annual_job's real
subprocesses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 6, 2026 15:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The changes are localized, consistent with existing module patterns (loader selection and sleep patching), and don’t show any correctness or contract regressions in the modified code paths.

Pull request overview

This PR reduces overall unit-test wall-clock time by eliminating unneeded real sleeps in tests, speeding debug YAML replay parsing via libyaml when available, and improving --quick UX/logging in the test runner—while keeping behavior/coverage intact.

Changes:

  • Speed up debug dump replays by switching read_debug_yaml() to use PyYAML’s C-backed loader when present, with a safe fallback.
  • Make component startup waits more responsive by polling api_started at 0.1s with a monotonic deadline.
  • Remove dead wall-clock delays in several tests by patching asyncio.sleep, and improve unit_test.py output/help text (dynamic slow-test list + start/end stamps).
File summaries
File Description
apps/predbat/userinterface.py Use a faster YAML loader for debug dump replays with fallback when libyaml isn’t available.
apps/predbat/component_base.py Reduce wait polling interval and enforce a monotonic timeout deadline.
apps/predbat/unit_test.py Derive --quick help from TEST_REGISTRY and add per-test start/end timestamps.
apps/predbat/tests/test_ohme.py Patch ohme.asyncio.sleep to skip an unasserted retry delay.
apps/predbat/tests/test_givtcp_component.py Record REST retry “sleeps” instead of serving real waits to cut runtime.
apps/predbat/tests/test_ge_cloud.py Patch gecloud.asyncio.sleep to skip rate-limit pacing in mock-only reads.
apps/predbat/tests/test_chat.py Add a context manager to temporarily shorten confirmation polling in fast-answer tests.
apps/predbat/tests/test_alphaess_api.py Default mock api_delay to 0 and patch retry backoff sleeps in the transport-failure test.
.cspell/custom-dictionary-workspace.txt Add libyaml to the custom dictionary.
Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@springfall2008
springfall2008 merged commit 64a58d6 into main Sep 6, 2026
3 checks passed
@springfall2008
springfall2008 deleted the perf/speed-up-unit-test-suite branch September 6, 2026 17:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants