From 9d311240aa1ffc5bad1beacfe06125c981fa6c74 Mon Sep 17 00:00:00 2001 From: fcostaoliveira Date: Sun, 25 Oct 2026 07:32:55 +0000 Subject: [PATCH 1/4] Add add_test_hook() to run a hook around each function test A hook is a callable that takes the env and returns a context manager. It is entered before each function test that takes an env and exited after it. Failed assertions and exceptions in the hook fail the test. Co-Authored-By: Claude Opus 5.5 --- README.md | 20 ++++++++++ RLTest/__init__.py | 2 + RLTest/__main__.py | 3 +- RLTest/test_hooks.py | 25 +++++++++++++ tests/unit/test_test_hooks.py | 70 +++++++++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 RLTest/test_hooks.py create mode 100644 tests/unit/test_test_hooks.py diff --git a/README.md b/README.md index 2c6bf94..fa02b77 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,26 @@ def test_example_3(): ``` +### Test hooks + +`add_test_hook(hook)` runs `hook(env)` around every function test that takes an +`env`. The hook returns a context manager; failed assertions and exceptions in +it fail the test. For example, to log how long each test takes: + +```python +import contextlib +import time +from RLTest import add_test_hook + +@contextlib.contextmanager +def timed(env): + start = time.monotonic() + yield + env.debugPrint('took %.1f s' % (time.monotonic() - start), force=True) + +add_test_hook(timed) +``` + ### Shutdown grace period With the default shutdown policy (`terminateRetries=None`), RLTest retries diff --git a/RLTest/__init__.py b/RLTest/__init__.py index 5a95f53..8e8b70e 100644 --- a/RLTest/__init__.py +++ b/RLTest/__init__.py @@ -1,12 +1,14 @@ from RLTest.env import Env, Defaults from RLTest.env_spec import env_spec from RLTest.redis_std import StandardEnv +from RLTest.test_hooks import add_test_hook from ._version import __version__ __all__ = [ 'Defaults', 'Env', 'StandardEnv', + 'add_test_hook', 'env_spec', ] diff --git a/RLTest/__main__.py b/RLTest/__main__.py index e6a5a4a..9cc7263 100644 --- a/RLTest/__main__.py +++ b/RLTest/__main__.py @@ -14,6 +14,7 @@ import json from multiprocessing import Process, Queue, set_start_method +from RLTest.test_hooks import run_with_hooks from RLTest.env import Env, TestAssertionFailure, Defaults from RLTest.utils import Colors, fix_modules, fix_modulesArgs, is_github_actions from RLTest.loader import TestLoader @@ -746,7 +747,7 @@ def _runTest(self, test, numberOfAssertionFailed=0, prefix='', before=lambda x=N return 0 if env is not None: - fn = lambda: test.target(env) + fn = lambda: run_with_hooks(test.target, env) before_func = lambda: before(env) after_func = lambda: after(env) else: diff --git a/RLTest/test_hooks.py b/RLTest/test_hooks.py new file mode 100644 index 0000000..8ddfde3 --- /dev/null +++ b/RLTest/test_hooks.py @@ -0,0 +1,25 @@ +"""Hooks that run around each function test that takes an ``env``.""" +import contextlib + +_hooks = [] + + +def add_test_hook(hook): + """ + Run ``hook(env)`` around every function test that takes an ``env``. + + ``hook`` returns a context manager, such as a generator decorated with + ``contextlib.contextmanager``: it is entered before the test and exited + after it. Failed assertions and exceptions in the hook fail the test, as + the test's own would. Hooks run in the order added; adding one again does + nothing. + """ + if hook not in _hooks: + _hooks.append(hook) + + +def run_with_hooks(target, env): + with contextlib.ExitStack() as stack: + for hook in list(_hooks): + stack.enter_context(hook(env)) + return target(env) diff --git a/tests/unit/test_test_hooks.py b/tests/unit/test_test_hooks.py new file mode 100644 index 0000000..1043cc4 --- /dev/null +++ b/tests/unit/test_test_hooks.py @@ -0,0 +1,70 @@ +"""Unit tests for add_test_hook.""" +import contextlib + +import pytest + +from RLTest import add_test_hook +from RLTest import test_hooks +from RLTest.test_hooks import run_with_hooks + + +@pytest.fixture(autouse=True) +def no_hooks(): + saved = list(test_hooks._hooks) + del test_hooks._hooks[:] + yield + test_hooks._hooks[:] = saved + + +def recorder(log, name): + @contextlib.contextmanager + def hook(env): + log.append((name, 'enter', env)) + yield + log.append((name, 'exit', env)) + return hook + + +def test_no_hooks_runs_the_test(): + assert run_with_hooks(lambda env: env * 2, 21) == 42 + + +def test_hooks_wrap_the_test_in_order(): + log = [] + add_test_hook(recorder(log, 'a')) + add_test_hook(recorder(log, 'b')) + run_with_hooks(lambda env: log.append(('test', env)), 'env') + assert log == [('a', 'enter', 'env'), ('b', 'enter', 'env'), ('test', 'env'), + ('b', 'exit', 'env'), ('a', 'exit', 'env')] + + +def test_adding_a_hook_twice_runs_it_once(): + log = [] + hook = recorder(log, 'a') + add_test_hook(hook) + add_test_hook(hook) + run_with_hooks(lambda env: None, 'env') + assert log == [('a', 'enter', 'env'), ('a', 'exit', 'env')] + + +def test_a_failing_test_still_exits_the_hooks(): + log = [] + add_test_hook(recorder(log, 'a')) + + def failing(env): + raise ValueError('boom') + + with pytest.raises(ValueError, match='boom'): + run_with_hooks(failing, 'env') + assert log == [('a', 'enter', 'env')] # a contextmanager hook sees the exception + + +def test_a_hook_error_fails_the_test(): + @contextlib.contextmanager + def check(env): + yield + raise AssertionError('hook check failed') + + add_test_hook(check) + with pytest.raises(AssertionError, match='hook check failed'): + run_with_hooks(lambda env: None, 'env') From 3f3e5426d3e2faf12bf8eb05a7a3b02715e3e939 Mon Sep 17 00:00:00 2001 From: fcostaoliveira Date: Sun, 25 Oct 2026 07:33:37 +0000 Subject: [PATCH 2/4] Pass the test function to test hooks A hook can then honour markers set on the test, such as an opt-out. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +++++----- RLTest/test_hooks.py | 6 ++++-- tests/unit/test_test_hooks.py | 21 +++++++++++++++++++-- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index fa02b77..df803f2 100644 --- a/README.md +++ b/README.md @@ -270,9 +270,9 @@ def test_example_3(): ### Test hooks -`add_test_hook(hook)` runs `hook(env)` around every function test that takes an -`env`. The hook returns a context manager; failed assertions and exceptions in -it fail the test. For example, to log how long each test takes: +`add_test_hook(hook)` runs `hook(env, test)` around every function test that +takes an `env`, where `test` is the test function. The hook returns a context +manager; failed assertions and exceptions in it fail the test. For example, to log how long each test takes: ```python import contextlib @@ -280,10 +280,10 @@ import time from RLTest import add_test_hook @contextlib.contextmanager -def timed(env): +def timed(env, test): start = time.monotonic() yield - env.debugPrint('took %.1f s' % (time.monotonic() - start), force=True) + env.debugPrint('%s took %.1f s' % (test.__name__, time.monotonic() - start), force=True) add_test_hook(timed) ``` diff --git a/RLTest/test_hooks.py b/RLTest/test_hooks.py index 8ddfde3..021f656 100644 --- a/RLTest/test_hooks.py +++ b/RLTest/test_hooks.py @@ -6,7 +6,9 @@ def add_test_hook(hook): """ - Run ``hook(env)`` around every function test that takes an ``env``. + Run ``hook(env, test)`` around every function test that takes an + ``env``; ``test`` is the test function, so a hook can honour markers set + on it. ``hook`` returns a context manager, such as a generator decorated with ``contextlib.contextmanager``: it is entered before the test and exited @@ -21,5 +23,5 @@ def add_test_hook(hook): def run_with_hooks(target, env): with contextlib.ExitStack() as stack: for hook in list(_hooks): - stack.enter_context(hook(env)) + stack.enter_context(hook(env, target)) return target(env) diff --git a/tests/unit/test_test_hooks.py b/tests/unit/test_test_hooks.py index 1043cc4..00784d6 100644 --- a/tests/unit/test_test_hooks.py +++ b/tests/unit/test_test_hooks.py @@ -18,7 +18,7 @@ def no_hooks(): def recorder(log, name): @contextlib.contextmanager - def hook(env): + def hook(env, test): log.append((name, 'enter', env)) yield log.append((name, 'exit', env)) @@ -61,10 +61,27 @@ def failing(env): def test_a_hook_error_fails_the_test(): @contextlib.contextmanager - def check(env): + def check(env, test): yield raise AssertionError('hook check failed') add_test_hook(check) with pytest.raises(AssertionError, match='hook check failed'): run_with_hooks(lambda env: None, 'env') + + +def test_a_hook_gets_the_test_function(): + seen = [] + + @contextlib.contextmanager + def hook(env, test): + seen.append(getattr(test, 'marker', None)) + yield + + def marked(env): + pass + marked.marker = 'skip-check' + + add_test_hook(hook) + run_with_hooks(marked, 'env') + assert seen == ['skip-check'] From 297b820819dc8fd4a0b6d807521ad1892afc5e23 Mon Sep 17 00:00:00 2001 From: fcostaoliveira Date: Sun, 25 Oct 2026 07:51:28 +0000 Subject: [PATCH 3/4] Keep one copy of a hook a test file adds again on re-import RLTest re-imports a test file before each of its tests, so a hook added there was a new function each time and ran once more per test. Treat a hook with the same module and name as the same hook. A flow test checks, through the runner, that such a hook runs once per test. Co-Authored-By: Claude Opus 5.5 --- RLTest/test_hooks.py | 18 ++++++++++++++---- tests/flow/test_hooks_flow.py | 23 +++++++++++++++++++++++ tests/unit/test_test_hooks.py | 20 +++++++++++++++++--- 3 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 tests/flow/test_hooks_flow.py diff --git a/RLTest/test_hooks.py b/RLTest/test_hooks.py index 021f656..d5570ee 100644 --- a/RLTest/test_hooks.py +++ b/RLTest/test_hooks.py @@ -13,11 +13,21 @@ def add_test_hook(hook): ``hook`` returns a context manager, such as a generator decorated with ``contextlib.contextmanager``: it is entered before the test and exited after it. Failed assertions and exceptions in the hook fail the test, as - the test's own would. Hooks run in the order added; adding one again does - nothing. + the test's own would. Hooks run in the order added. RLTest re-imports a + test file before each of its tests, so a hook with the same module and + name as one already added replaces it rather than adding another. """ - if hook not in _hooks: - _hooks.append(hook) + key = _key(hook) + for i, added in enumerate(_hooks): + if added is hook or _key(added) == key: + _hooks[i] = hook + return + _hooks.append(hook) + + +def _key(hook): + name = getattr(hook, '__qualname__', None) + return (getattr(hook, '__module__', None), name) if name else (id(hook),) def run_with_hooks(target, env): diff --git a/tests/flow/test_hooks_flow.py b/tests/flow/test_hooks_flow.py new file mode 100644 index 0000000..96ccd37 --- /dev/null +++ b/tests/flow/test_hooks_flow.py @@ -0,0 +1,23 @@ +import contextlib + +from RLTest import add_test_hook + + +# Added at import, as a test file would. RLTest re-imports this file before +# each of its tests, so a hook added more than once would run more than once. +@contextlib.contextmanager +def count_calls(env, test): + calls = env.__dict__.setdefault('_hook_calls', {}) + calls[test.__name__] = calls.get(test.__name__, 0) + 1 + yield + + +add_test_hook(count_calls) + + +def test_hook_runs_once_per_test(env): + env.assertEqual(env._hook_calls.get('test_hook_runs_once_per_test'), 1) + + +def test_hook_runs_once_per_later_test(env): + env.assertEqual(env._hook_calls.get('test_hook_runs_once_per_later_test'), 1) diff --git a/tests/unit/test_test_hooks.py b/tests/unit/test_test_hooks.py index 00784d6..78e16ea 100644 --- a/tests/unit/test_test_hooks.py +++ b/tests/unit/test_test_hooks.py @@ -20,8 +20,11 @@ def recorder(log, name): @contextlib.contextmanager def hook(env, test): log.append((name, 'enter', env)) - yield - log.append((name, 'exit', env)) + try: + yield + finally: + log.append((name, 'exit', env)) + hook.__qualname__ = 'hook_' + name return hook @@ -47,6 +50,17 @@ def test_adding_a_hook_twice_runs_it_once(): assert log == [('a', 'enter', 'env'), ('a', 'exit', 'env')] +def test_a_hook_redefined_by_a_reimport_replaces_the_old_one(): + # RLTest re-imports a test file before each of its tests, so a hook + # defined there is a new function each time, with the same name. + log = [] + add_test_hook(recorder(log, 'a')) + add_test_hook(recorder(log, 'b')) + add_test_hook(recorder(log, 'a')) + run_with_hooks(lambda env: None, 'env') + assert [entry[0] for entry in log] == ['a', 'b', 'b', 'a'] + + def test_a_failing_test_still_exits_the_hooks(): log = [] add_test_hook(recorder(log, 'a')) @@ -56,7 +70,7 @@ def failing(env): with pytest.raises(ValueError, match='boom'): run_with_hooks(failing, 'env') - assert log == [('a', 'enter', 'env')] # a contextmanager hook sees the exception + assert log == [('a', 'enter', 'env'), ('a', 'exit', 'env')] def test_a_hook_error_fails_the_test(): From d9eda2acf0acd731396172b83f7f084e0ce64288 Mon Sep 17 00:00:00 2001 From: fcostaoliveira Date: Sun, 25 Oct 2026 07:57:13 +0000 Subject: [PATCH 4/4] Match a re-added hook by name only for top-level functions Lambdas, closures from one factory and bound methods share names while being different hooks, so keying them by name dropped all but the last. Match by module and name only a top-level function, which is what a re-imported test file adds again, and anything else by identity. The unit tests now import a test file twice, check distinct same-named hooks all run, and check a hook exits with the test's exception. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 ++++-- RLTest/test_hooks.py | 17 ++++++++---- tests/unit/test_test_hooks.py | 49 ++++++++++++++++++++++++++++------- 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index df803f2..1454888 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,10 @@ def test_example_3(): `add_test_hook(hook)` runs `hook(env, test)` around every function test that takes an `env`, where `test` is the test function. The hook returns a context -manager; failed assertions and exceptions in it fail the test. For example, to log how long each test takes: +manager; failed assertions and exceptions in it fail the test. A top-level +function can be added from a test file; add a lambda, closure or other callable +from a module that is imported once. For example, to log how long each test +takes: ```python import contextlib @@ -283,7 +286,8 @@ from RLTest import add_test_hook def timed(env, test): start = time.monotonic() yield - env.debugPrint('%s took %.1f s' % (test.__name__, time.monotonic() - start), force=True) + elapsed = time.monotonic() - start + env.debugPrint('%s took %.1f s' % (test.__name__, elapsed), force=True) add_test_hook(timed) ``` diff --git a/RLTest/test_hooks.py b/RLTest/test_hooks.py index d5570ee..41a3c8b 100644 --- a/RLTest/test_hooks.py +++ b/RLTest/test_hooks.py @@ -1,5 +1,6 @@ """Hooks that run around each function test that takes an ``env``.""" import contextlib +import inspect _hooks = [] @@ -13,9 +14,13 @@ def add_test_hook(hook): ``hook`` returns a context manager, such as a generator decorated with ``contextlib.contextmanager``: it is entered before the test and exited after it. Failed assertions and exceptions in the hook fail the test, as - the test's own would. Hooks run in the order added. RLTest re-imports a - test file before each of its tests, so a hook with the same module and - name as one already added replaces it rather than adding another. + the test's own would. Hooks run in the order added. + + RLTest re-imports a test file before each of its tests, so a top-level + function added again with the same module and name replaces the copy + added before. Any other hook (a lambda, closure, bound method or other + callable) counts as the same only if it is the same object, so add those + from a module that is imported once. """ key = _key(hook) for i, added in enumerate(_hooks): @@ -26,8 +31,10 @@ def add_test_hook(hook): def _key(hook): - name = getattr(hook, '__qualname__', None) - return (getattr(hook, '__module__', None), name) if name else (id(hook),) + name = getattr(hook, '__qualname__', '') + if inspect.isfunction(hook) and name.isidentifier(): + return (hook.__module__, name) + return (id(hook),) def run_with_hooks(target, env): diff --git a/tests/unit/test_test_hooks.py b/tests/unit/test_test_hooks.py index 78e16ea..cba0f58 100644 --- a/tests/unit/test_test_hooks.py +++ b/tests/unit/test_test_hooks.py @@ -1,5 +1,6 @@ """Unit tests for add_test_hook.""" import contextlib +import types import pytest @@ -22,12 +23,31 @@ def hook(env, test): log.append((name, 'enter', env)) try: yield - finally: - log.append((name, 'exit', env)) - hook.__qualname__ = 'hook_' + name + except BaseException as error: + log.append((name, 'exit', type(error).__name__)) + raise + log.append((name, 'exit', env)) return hook +TEST_FILE = ''' +import contextlib + +@contextlib.contextmanager +def hook(env, test): + LOG.append(TAG) + yield +''' + + +def import_test_file(log, tag): + """Run TEST_FILE as RLTest's loader does, as a fresh module each time.""" + module = types.ModuleType('test_file') + module.LOG, module.TAG = log, tag + exec(compile(TEST_FILE, 'test_file.py', 'exec'), module.__dict__) + return module.hook + + def test_no_hooks_runs_the_test(): assert run_with_hooks(lambda env: env * 2, 21) == 42 @@ -50,15 +70,24 @@ def test_adding_a_hook_twice_runs_it_once(): assert log == [('a', 'enter', 'env'), ('a', 'exit', 'env')] -def test_a_hook_redefined_by_a_reimport_replaces_the_old_one(): - # RLTest re-imports a test file before each of its tests, so a hook - # defined there is a new function each time, with the same name. +def test_a_hook_from_a_reimported_test_file_replaces_the_old_one(): log = [] - add_test_hook(recorder(log, 'a')) + add_test_hook(import_test_file(log, 'first import')) + add_test_hook(recorder(log, 'other')) + add_test_hook(import_test_file(log, 'second import')) + run_with_hooks(lambda env: None, 'env') + # One copy, the newer, still first. + assert log == ['second import', ('other', 'enter', 'env'), ('other', 'exit', 'env')] + + +def test_distinct_hooks_that_share_a_name_all_run(): + log = [] + add_test_hook(recorder(log, 'a')) # closures from one factory add_test_hook(recorder(log, 'b')) - add_test_hook(recorder(log, 'a')) + add_test_hook(lambda env, test: recorder(log, 'c')(env, test)) + add_test_hook(lambda env, test: recorder(log, 'd')(env, test)) run_with_hooks(lambda env: None, 'env') - assert [entry[0] for entry in log] == ['a', 'b', 'b', 'a'] + assert [name for name, step, _ in log if step == 'enter'] == ['a', 'b', 'c', 'd'] def test_a_failing_test_still_exits_the_hooks(): @@ -70,7 +99,7 @@ def failing(env): with pytest.raises(ValueError, match='boom'): run_with_hooks(failing, 'env') - assert log == [('a', 'enter', 'env'), ('a', 'exit', 'env')] + assert log == [('a', 'enter', 'env'), ('a', 'exit', 'ValueError')] def test_a_hook_error_fails_the_test():