feat: speed up and stabilise test suite (#676)

## ℹ️ Description
*Provide a concise summary of the changes introduced in this pull
request.*

- Link to the related issue(s): Issue #
- Describe the motivation and context for this change.

Refactors the test harness for faster and more reliable feedback: adds
deterministic time freezing for update checks, accelerates and refactors
smoke tests to run in-process, defaults pytest to xdist with durations
tracking, and adjusts CI triggers so PRs run the test matrix only once.

## 📋 Changes Summary

- add pytest-xdist + durations reporting defaults, force deterministic
locale and slow markers, and document the workflow adjustments
- run smoke tests in-process (no subprocess churn), mock update
checks/logging, and mark slow specs appropriately
- deflake update check interval tests by freezing datetime and simplify
FixedDateTime helper
- limit GitHub Actions `push` trigger to `main` so feature branches rely
on the single pull_request run

### ⚙️ Type of Change
Select the type(s) of change(s) included in this pull request:
- [ ] 🐞 Bug fix (non-breaking change which fixes an issue)
- [x]  New feature (adds new functionality without breaking existing
usage)
- [ ] 💥 Breaking change (changes that might break existing user setups,
scripts, or configurations)


##  Checklist
Before requesting a review, confirm the following:
- [x] I have reviewed my changes to ensure they meet the project's
standards.
- [x] I have tested my changes and ensured that all tests pass (`pdm run
test`).
- [x] I have formatted the code (`pdm run format`).
- [x] I have verified that linting passes (`pdm run lint`).
- [x] I have updated documentation where necessary.

By submitting this pull request, I confirm that you can use, modify,
copy, and redistribute this contribution, under the terms of your
choice.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
* Ensure tests run in a consistent English locale and restore prior
locale after each run
  * Mark integration scraping tests as slow for clearer categorization
* Replace subprocess-based CLI tests with an in-process runner that
returns structured results and captures combined stdout/stderr/logs;
disable update checks during smoke tests
* Freeze current time in update-check tests for deterministic assertions
* Add mock for process enumeration in web‑scraping unit tests to
stabilize macOS-specific warnings

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jens
2025-11-12 21:29:51 +01:00
committed by GitHub
parent 91cb677d17
commit 33d1964f86
9 changed files with 181 additions and 24 deletions

View File

@@ -10,9 +10,8 @@ on: # https://docs.github.com/en/actions/reference/workflows-and-actions/events
# https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule # https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule
- cron: '0 15 1 * *' - cron: '0 15 1 * *'
push: push:
branches-ignore: # build all branches except: branches:
- 'dependabot/**' # prevent GHA triggered twice (once for commit to the branch and once for opening/syncing the PR) - main
- 'dependencies/pdm' # prevent GHA triggered twice (once for commit to the branch and once for opening/syncing the PR)
tags-ignore: # don't build tags tags-ignore: # don't build tags
- '**' - '**'
paths-ignore: paths-ignore:
@@ -55,6 +54,8 @@ jobs:
build: build:
########################################################### ###########################################################
if: github.event_name != 'push' || github.ref == 'refs/heads/main'
permissions: permissions:
packages: write packages: write

View File

@@ -62,6 +62,13 @@ This project uses a layered testing approach, with a focus on reliability and fa
- **All tests in order:** - **All tests in order:**
- Run with: `pdm run test` (runs unit, then integration, then smoke) - Run with: `pdm run test` (runs unit, then integration, then smoke)
### Parallel Execution and Slow-Test Tracking
- `pytest-xdist` runs every invocation with `-n auto`, so the suite is split across CPU cores automatically.
- Pytest now reports the slowest 25 tests (`--durations=25 --durations-min=0.5`), making regressions easy to spot in CI logs.
- Long-running scenarios are tagged with `@pytest.mark.slow` (smoke CLI checks and browser integrations). Keep them in CI, but skip locally via `pytest -m "not slow"` when you only need a quick signal.
- Coverage commands (`pdm run test:cov`, etc.) remain compatible—`pytest-cov` merges the per-worker data transparently.
### CI Test Order ### CI Test Order
- CI runs unit tests first, then integration tests, then smoke tests. - CI runs unit tests first, then integration tests, then smoke tests.

28
pdm.lock generated
View File

@@ -5,7 +5,7 @@
groups = ["default", "dev"] groups = ["default", "dev"]
strategy = ["inherit_metadata"] strategy = ["inherit_metadata"]
lock_version = "4.5.0" lock_version = "4.5.0"
content_hash = "sha256:db686d496c2229b3bd3d33e5cddb92024bb15b0641c8cad58af92e97bab507b8" content_hash = "sha256:2786f3461151a94a8f76ed4ca2b2f3d8d7242d0004e17f1e1ae326b0d6529acd"
[[metadata.targets]] [[metadata.targets]]
requires_python = ">=3.10,<3.15" requires_python = ">=3.10,<3.15"
@@ -553,6 +553,17 @@ files = [
{file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"},
] ]
[[package]]
name = "execnet"
version = "2.1.1"
requires_python = ">=3.8"
summary = "execnet: rapid multi-Python deployment"
groups = ["dev"]
files = [
{file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"},
{file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"},
]
[[package]] [[package]]
name = "filelock" name = "filelock"
version = "3.20.0" version = "3.20.0"
@@ -1360,6 +1371,21 @@ files = [
{file = "pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e"}, {file = "pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e"},
] ]
[[package]]
name = "pytest-xdist"
version = "3.8.0"
requires_python = ">=3.9"
summary = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs"
groups = ["dev"]
dependencies = [
"execnet>=2.1",
"pytest>=7.0.0",
]
files = [
{file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"},
{file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"},
]
[[package]] [[package]]
name = "pywin32-ctypes" name = "pywin32-ctypes"
version = "0.2.3" version = "0.2.3"

View File

@@ -51,6 +51,7 @@ dev = [
"pip-audit", "pip-audit",
"pytest>=8.3.4", "pytest>=8.3.4",
"pytest-asyncio>=0.25.3", "pytest-asyncio>=0.25.3",
"pytest-xdist>=3.6.1",
"pytest-rerunfailures", "pytest-rerunfailures",
"pytest-cov>=6.0.0", "pytest-cov>=6.0.0",
"ruff", "ruff",
@@ -347,8 +348,12 @@ addopts = """
--strict-markers --strict-markers
--doctest-modules --doctest-modules
--cov-report=term-missing --cov-report=term-missing
-n auto
--durations=25
--durations-min=0.5
""" """
markers = [ markers = [
"slow: marks a test as long running",
"smoke: marks a test as a high-level smoke test (critical path, no mocks)", "smoke: marks a test as a high-level smoke test (critical path, no mocks)",
"itest: marks a test as an integration test (i.e. a test with external dependencies)", "itest: marks a test as an integration test (i.e. a test with external dependencies)",
"asyncio: mark test as async" "asyncio: mark test as async"

View File

@@ -15,6 +15,7 @@ Fixture Organization:
- Test data fixtures: Shared test data (description_test_cases) - Test data fixtures: Shared test data (description_test_cases)
""" """
import os import os
from collections.abc import Iterator
from typing import Any, Final, cast from typing import Any, Final, cast
from unittest.mock import MagicMock from unittest.mock import MagicMock
@@ -23,7 +24,7 @@ import pytest
from kleinanzeigen_bot import KleinanzeigenBot from kleinanzeigen_bot import KleinanzeigenBot
from kleinanzeigen_bot.model.ad_model import Ad from kleinanzeigen_bot.model.ad_model import Ad
from kleinanzeigen_bot.model.config_model import Config from kleinanzeigen_bot.model.config_model import Config
from kleinanzeigen_bot.utils import loggers from kleinanzeigen_bot.utils import i18n, loggers
from kleinanzeigen_bot.utils.web_scraping_mixin import Browser from kleinanzeigen_bot.utils.web_scraping_mixin import Browser
loggers.configure_console_logging() loggers.configure_console_logging()
@@ -198,6 +199,15 @@ def silence_nodriver_logs() -> None:
loggers.get_logger("nodriver").setLevel(loggers.WARNING) loggers.get_logger("nodriver").setLevel(loggers.WARNING)
@pytest.fixture(autouse = True)
def force_english_locale() -> Iterator[None]:
"""Ensure tests run with a deterministic English locale."""
previous_locale = i18n.get_current_locale()
i18n.set_current_locale(i18n.Locale("en", "US", "UTF-8"))
yield
i18n.set_current_locale(previous_locale)
# ============================================================================ # ============================================================================
# Smoke Test Fixtures - Special fixtures for smoke tests # Smoke Test Fixtures - Special fixtures for smoke tests
# ============================================================================ # ============================================================================

View File

@@ -10,6 +10,8 @@ import pytest
from kleinanzeigen_bot.utils.misc import ensure from kleinanzeigen_bot.utils.misc import ensure
from kleinanzeigen_bot.utils.web_scraping_mixin import WebScrapingMixin from kleinanzeigen_bot.utils.web_scraping_mixin import WebScrapingMixin
pytestmark = pytest.mark.slow
# Configure logging for integration tests # Configure logging for integration tests
# The main bot already handles nodriver logging via silence_nodriver_logs fixture # The main bot already handles nodriver logging via silence_nodriver_logs fixture
# and pytest handles verbosity with -v flag automatically # and pytest handles verbosity with -v flag automatically

View File

@@ -6,28 +6,91 @@ Minimal smoke tests: post-deployment health checks for kleinanzeigen-bot.
These tests verify that the most essential components are operational. These tests verify that the most essential components are operational.
""" """
import contextlib
import io
import json import json
import logging
import os
import re import re
import subprocess # noqa: S404 from dataclasses import dataclass
import sys
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Any, Callable
from unittest.mock import patch
import pytest import pytest
from ruyaml import YAML from ruyaml import YAML
import kleinanzeigen_bot
from kleinanzeigen_bot.model.config_model import Config from kleinanzeigen_bot.model.config_model import Config
from kleinanzeigen_bot.utils.i18n import get_current_locale, set_current_locale
from tests.conftest import SmokeKleinanzeigenBot from tests.conftest import SmokeKleinanzeigenBot
pytestmark = pytest.mark.slow
def run_cli_subcommand(args:list[str], cwd:str | None = None) -> subprocess.CompletedProcess[str]:
@dataclass(slots = True)
class CLIResult:
returncode:int
stdout:str
stderr:str
def invoke_cli(args:list[str], cwd:Path | None = None) -> CLIResult:
""" """
Run the kleinanzeigen-bot CLI as a subprocess with the given arguments. Run the kleinanzeigen-bot CLI in-process and capture stdout/stderr.
Returns the CompletedProcess object.
""" """
cli_module = "kleinanzeigen_bot.__main__" stdout = io.StringIO()
cmd = [sys.executable, "-m", cli_module] + args stderr = io.StringIO()
return subprocess.run(cmd, check = False, capture_output = True, text = True, cwd = cwd) # noqa: S603 previous_cwd:Path | None = None
previous_locale = get_current_locale()
def capture_register(func:Callable[..., object], *_cb_args:Any, **_cb_kwargs:Any) -> Callable[..., object]:
return func
log_capture = io.StringIO()
log_handler = logging.StreamHandler(log_capture)
log_handler.setLevel(logging.DEBUG)
def build_result(exit_code:object) -> CLIResult:
if exit_code is None:
normalized = 0
elif isinstance(exit_code, int):
normalized = exit_code
else:
normalized = 1
combined_stderr = stderr.getvalue() + log_capture.getvalue()
return CLIResult(normalized, stdout.getvalue(), combined_stderr)
try:
if cwd is not None:
previous_cwd = Path.cwd()
os.chdir(os.fspath(cwd))
logging.getLogger().addHandler(log_handler)
with contextlib.ExitStack() as stack:
stack.enter_context(patch("kleinanzeigen_bot.atexit.register", capture_register))
stack.enter_context(contextlib.redirect_stdout(stdout))
stack.enter_context(contextlib.redirect_stderr(stderr))
try:
kleinanzeigen_bot.main(["kleinanzeigen-bot", *args])
except SystemExit as exc:
return build_result(exc.code)
return build_result(0)
finally:
logging.getLogger().removeHandler(log_handler)
log_handler.close()
if previous_cwd is not None:
os.chdir(previous_cwd)
set_current_locale(previous_locale)
@pytest.fixture(autouse = True)
def disable_update_checker(monkeypatch:pytest.MonkeyPatch) -> None:
"""Prevent smoke tests from hitting GitHub for update checks."""
def _no_update(*_args:object, **_kwargs:object) -> None:
return None
monkeypatch.setattr("kleinanzeigen_bot.update_checker.UpdateChecker.check_for_updates", _no_update)
@pytest.mark.smoke @pytest.mark.smoke
@@ -50,7 +113,7 @@ def test_cli_subcommands_no_config(subcommand:str, tmp_path:Path) -> None:
Smoke: CLI subcommands that do not require a config file (--help, help, version, diagnose). Smoke: CLI subcommands that do not require a config file (--help, help, version, diagnose).
""" """
args = [subcommand] args = [subcommand]
result = run_cli_subcommand(args, cwd = str(tmp_path)) result = invoke_cli(args, cwd = tmp_path)
assert result.returncode == 0 assert result.returncode == 0
out = (result.stdout + "\n" + result.stderr).lower() out = (result.stdout + "\n" + result.stderr).lower()
if subcommand in {"--help", "help"}: if subcommand in {"--help", "help"}:
@@ -66,7 +129,7 @@ def test_cli_subcommands_create_config_creates_file(tmp_path:Path) -> None:
""" """
Smoke: CLI 'create-config' creates a config.yaml file in the current directory. Smoke: CLI 'create-config' creates a config.yaml file in the current directory.
""" """
result = run_cli_subcommand(["create-config"], cwd = str(tmp_path)) result = invoke_cli(["create-config"], cwd = tmp_path)
config_file = tmp_path / "config.yaml" config_file = tmp_path / "config.yaml"
assert result.returncode == 0 assert result.returncode == 0
assert config_file.exists(), "config.yaml was not created by create-config command" assert config_file.exists(), "config.yaml was not created by create-config command"
@@ -82,7 +145,7 @@ def test_cli_subcommands_create_config_fails_if_exists(tmp_path:Path) -> None:
""" """
config_file = tmp_path / "config.yaml" config_file = tmp_path / "config.yaml"
config_file.write_text("# dummy config\n", encoding = "utf-8") config_file.write_text("# dummy config\n", encoding = "utf-8")
result = run_cli_subcommand(["create-config"], cwd = str(tmp_path)) result = invoke_cli(["create-config"], cwd = tmp_path)
assert result.returncode == 0 assert result.returncode == 0
assert config_file.exists(), "config.yaml was deleted or not present after second create-config run" assert config_file.exists(), "config.yaml was deleted or not present after second create-config run"
out = (result.stdout + "\n" + result.stderr).lower() out = (result.stdout + "\n" + result.stderr).lower()
@@ -126,7 +189,7 @@ def test_cli_subcommands_with_config_formats(
elif serializer is not None: elif serializer is not None:
config_path.write_text(serializer(config_dict), encoding = "utf-8") config_path.write_text(serializer(config_dict), encoding = "utf-8")
args = [subcommand, "--config", str(config_path)] args = [subcommand, "--config", str(config_path)]
result = run_cli_subcommand(args, cwd = str(tmp_path)) result = invoke_cli(args, cwd = tmp_path)
assert result.returncode == 0 assert result.returncode == 0
out = (result.stdout + "\n" + result.stderr).lower() out = (result.stdout + "\n" + result.stderr).lower()
if subcommand == "verify": if subcommand == "verify":

View File

@@ -5,7 +5,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone, tzinfo
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -18,11 +18,47 @@ import requests
if TYPE_CHECKING: if TYPE_CHECKING:
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from kleinanzeigen_bot.model import update_check_state as update_check_state_module
from kleinanzeigen_bot.model.config_model import Config from kleinanzeigen_bot.model.config_model import Config
from kleinanzeigen_bot.model.update_check_state import UpdateCheckState from kleinanzeigen_bot.model.update_check_state import UpdateCheckState
from kleinanzeigen_bot.update_checker import UpdateChecker from kleinanzeigen_bot.update_checker import UpdateChecker
def _freeze_update_state_datetime(monkeypatch:pytest.MonkeyPatch, fixed_now:datetime) -> None:
"""Patch UpdateCheckState to return a deterministic datetime.now/utcnow."""
class FixedDateTime(datetime):
@classmethod
def now(cls, tz:tzinfo | None = None) -> "FixedDateTime":
base = fixed_now.replace(tzinfo = None) if tz is None else fixed_now.astimezone(tz)
return cls(
base.year,
base.month,
base.day,
base.hour,
base.minute,
base.second,
base.microsecond,
tzinfo = base.tzinfo
)
@classmethod
def utcnow(cls) -> "FixedDateTime":
base = fixed_now.astimezone(timezone.utc).replace(tzinfo = None)
return cls(
base.year,
base.month,
base.day,
base.hour,
base.minute,
base.second,
base.microsecond
)
datetime_module = getattr(update_check_state_module, "datetime")
monkeypatch.setattr(datetime_module, "datetime", FixedDateTime)
@pytest.fixture @pytest.fixture
def config() -> Config: def config() -> Config:
return Config.model_validate({ return Config.model_validate({
@@ -256,10 +292,12 @@ class TestUpdateChecker:
# Should not raise an exception # Should not raise an exception
state.save(state_file) state.save(state_file)
def test_update_check_state_interval_units(self) -> None: def test_update_check_state_interval_units(self, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that different interval units are handled correctly.""" """Test that different interval units are handled correctly."""
state = UpdateCheckState() state = UpdateCheckState()
now = datetime.now(timezone.utc) fixed_now = datetime(2025, 1, 15, 8, 0, tzinfo = timezone.utc)
_freeze_update_state_datetime(monkeypatch, fixed_now)
now = fixed_now
# Test seconds (should always be too short, fallback to 7d, only 2 days elapsed, so should_check is False) # Test seconds (should always be too short, fallback to 7d, only 2 days elapsed, so should_check is False)
state.last_check = now - timedelta(seconds = 30) state.last_check = now - timedelta(seconds = 30)
@@ -303,10 +341,14 @@ class TestUpdateChecker:
state.last_check = now - timedelta(days = 6) state.last_check = now - timedelta(days = 6)
assert state.should_check("1z") is False assert state.should_check("1z") is False
def test_update_check_state_interval_validation(self) -> None: def test_update_check_state_interval_validation(self, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test that interval validation works correctly.""" """Test that interval validation works correctly."""
state = UpdateCheckState() state = UpdateCheckState()
now = datetime.now(timezone.utc) fixed_now = datetime(2025, 1, 1, 12, 0, tzinfo = timezone.utc)
_freeze_update_state_datetime(monkeypatch, fixed_now)
now = fixed_now
state.last_check = now - timedelta(days = 1) state.last_check = now - timedelta(days = 1)
# Test minimum value (1d) # Test minimum value (1d)

View File

@@ -1393,6 +1393,7 @@ class TestWebScrapingDiagnostics:
with patch("os.path.exists", return_value = True), \ with patch("os.path.exists", return_value = True), \
patch("os.access", return_value = True), \ patch("os.access", return_value = True), \
patch("psutil.process_iter", return_value = []), \
patch("kleinanzeigen_bot.utils.web_scraping_mixin.net.is_port_open", return_value = False), \ patch("kleinanzeigen_bot.utils.web_scraping_mixin.net.is_port_open", return_value = False), \
patch("platform.system", return_value = "Darwin"), \ patch("platform.system", return_value = "Darwin"), \
patch("kleinanzeigen_bot.utils.web_scraping_mixin._is_admin", return_value = False), \ patch("kleinanzeigen_bot.utils.web_scraping_mixin._is_admin", return_value = False), \