From 6ae835fca86c6885fb204a65628db5b6bb7f299b Mon Sep 17 00:00:00 2001 From: Fanta Niakate Date: Wed, 29 Jul 2026 01:14:21 +0000 Subject: [PATCH 1/9] Add ReuseServerTestCase for class-scoped server reuse Instead of spawning a fresh valkey-server per test (~42ms), one server is started per class and FLUSHALL + CONFIG RESETSTAT (~5ms) run between tests to reset state. This gives ~5-7x speedup for test classes. Changes: - src/conftest.py: add class_port_tracker fixture (scope="class") - src/valkey_test_case.py: add ReuseServerTestCase class - tests/test_reuse_server.py: demo/test proving reuse and isolation Signed-off-by: Fanta Niakate --- src/conftest.py | 10 +++++++ src/valkey_test_case.py | 54 ++++++++++++++++++++++++++++++++++++++ tests/test_reuse_server.py | 39 +++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 tests/test_reuse_server.py diff --git a/src/conftest.py b/src/conftest.py index 37eda7e..76a3efc 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -108,3 +108,13 @@ def resource_port_tracker(request): """ with PortTracker(request.node.nodeid) as p: yield p + + +@pytest.fixture(scope="class") +def class_port_tracker(request): + """ + Create port tracker shared across all tests in a class. + Used by ReuseServerTestCase to maintain one server per class. + """ + with PortTracker(request.node.nodeid) as p: + yield p diff --git a/src/valkey_test_case.py b/src/valkey_test_case.py index 90a3470..d2cf2cc 100644 --- a/src/valkey_test_case.py +++ b/src/valkey_test_case.py @@ -728,3 +728,57 @@ def waitForReplicaOffsetToSyncUp(self, primary, replica): pinfo.get_primary_repl_offset(), timeout=TEST_MAX_WAIT_TIME_SECONDS, ) + + +class ReuseServerTestCase(ValkeyTestCaseBase): + """Test case that reuses a single server across all tests in the class. + + Instead of spawning a fresh server per test (~42ms each), one server is + started for the entire class and FLUSHALL + CONFIG RESETSTAT run between + tests (~5ms) to reset state. + """ + + server_path = "valkey-server" + + def _ensure_testdir(self): + if not os.path.isdir(self.testdir): + try: + os.mkdir(self.testdir) + except OSError: + assert os.path.isdir(self.testdir) + + @pytest.fixture(autouse=True, scope="class") + def class_server(self, class_port_tracker): + self.__class__.port_tracker = class_port_tracker + self.__class__.port = class_port_tracker.get_unused_port() + self.__class__._server_list = [] + self._ensure_testdir() + server = ValkeyServerHandle( + bind_ip=self.DEFAULT_BIND_IP, + port=self.__class__.port, + port_tracker=class_port_tracker, + cwd=self.testdir, + server_path=self.server_path, + ) + server.start(wait_for_ping=True, connect_client=True) + self.__class__._shared_server = server + self.__class__._shared_client = server.client + self.__class__._server_list.append(server) + yield + for s in self.__class__._server_list: + if s: + s.exit() + + @pytest.fixture(autouse=True) + def reset_between_tests(self, class_server): + yield + self._shared_client.flushall() + self._shared_client.execute_command("CONFIG", "RESETSTAT") + + @property + def server(self): + return self.__class__._shared_server + + @property + def client(self): + return self.__class__._shared_client diff --git a/tests/test_reuse_server.py b/tests/test_reuse_server.py new file mode 100644 index 0000000..bc715c5 --- /dev/null +++ b/tests/test_reuse_server.py @@ -0,0 +1,39 @@ +""" +Demonstrates ReuseServerTestCase usage. + +All tests in this class share ONE server. Between each test, FLUSHALL + CONFIG +RESETSTAT run automatically to give each test a clean slate without the cost of +restarting the server. +""" + +import pytest +from conftest import class_port_tracker, resource_port_tracker +from valkey_test_case import ReuseServerTestCase + + +class TestReuseServer(ReuseServerTestCase): + """Verifies that server reuse works and tests are isolated.""" + + def test_write_and_read(self): + """Basic write/read on the shared server.""" + self.client.set("greeting", "hello") + assert self.client.get("greeting") == b"hello" + + def test_isolation_from_previous(self): + """Proves FLUSHALL cleaned up the previous test's data.""" + result = self.client.get("greeting") + assert result is None, "Key from previous test should not exist" + + def test_server_still_alive(self): + """Proves the server survived across tests (no restart).""" + assert self.client.ping() is True + + def test_multiple_keys(self): + """Write multiple keys, verify they all exist within this test.""" + for i in range(10): + self.client.set(f"key:{i}", f"value:{i}") + assert self.client.dbsize() == 10 + + def test_previous_keys_gone(self): + """Proves the 10 keys from the previous test were flushed.""" + assert self.client.dbsize() == 0 From 88d0f96bbc9e3ba81cd5d5ceeb49eef255aca3c6 Mon Sep 17 00:00:00 2001 From: Fanta Niakate Date: Thu, 30 Jul 2026 17:12:35 +0000 Subject: [PATCH 2/9] Redesign ReuseServerTestCase for zero-friction module adoption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReuseServerTestCase now inherits ValkeyTestCase and overrides create_server() to cache the server on first call. Modules only need to change their base class — no other code modifications required. Key changes: - Inherit ValkeyTestCase instead of ValkeyTestCaseBase so all fixtures (setup, port_tracker_fixture) work automatically - Override create_server() to return cached server on subsequent calls - Snapshot all configs on first creation and restore between tests to prevent config pollution across tests - FLUSHALL + CONFIG RESETSTAT between tests for data isolation - Bump pytest to 7.4.3 Tested against bloom (34/34 pass) and JSON (170/171 pass, 1 unrelated env var issue). JSON sees ~17x speedup (8s vs 138s). Signed-off-by: Fanta Niakate --- requirements.txt | 2 +- src/valkey_test_case.py | 104 ++++++++++++++++++++++++---------------- 2 files changed, 63 insertions(+), 43 deletions(-) diff --git a/requirements.txt b/requirements.txt index 349566f..4583452 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ valkey -pytest==6 +pytest==7.4.3 black pytest-order diff --git a/src/valkey_test_case.py b/src/valkey_test_case.py index d2cf2cc..2a90644 100644 --- a/src/valkey_test_case.py +++ b/src/valkey_test_case.py @@ -730,55 +730,75 @@ def waitForReplicaOffsetToSyncUp(self, primary, replica): ) -class ReuseServerTestCase(ValkeyTestCaseBase): +class ReuseServerTestCase(ValkeyTestCase): """Test case that reuses a single server across all tests in the class. - Instead of spawning a fresh server per test (~42ms each), one server is - started for the entire class and FLUSHALL + CONFIG RESETSTAT run between - tests (~5ms) to reset state. + Instead of spawning a fresh server per test, one server is started on the + first create_server() call and reused for all subsequent tests. FLUSHALL + + CONFIG RESETSTAT run between tests to reset state. + + Usage — just change your base class: + + class MyModuleTestCase(ReuseServerTestCase): + ... # keep your existing setup_test exactly as-is + + That's it. self.server, self.client, create_server() all work as before. """ - server_path = "valkey-server" + def create_server( + self, + testdir=None, + bind_ip=None, + port=None, + server_path=None, + args="", + skip_teardown=False, + conf_file=None, + external_server=False, + wait_for_ping=True, + connect_client=True, + ): + if hasattr(self.__class__, '_shared_server') and self.__class__._shared_server: + return self.__class__._shared_server, self.__class__._shared_client - def _ensure_testdir(self): - if not os.path.isdir(self.testdir): - try: - os.mkdir(self.testdir) - except OSError: - assert os.path.isdir(self.testdir) + if server_path is None: + server_path = self.server_path - @pytest.fixture(autouse=True, scope="class") - def class_server(self, class_port_tracker): - self.__class__.port_tracker = class_port_tracker - self.__class__.port = class_port_tracker.get_unused_port() - self.__class__._server_list = [] - self._ensure_testdir() - server = ValkeyServerHandle( - bind_ip=self.DEFAULT_BIND_IP, - port=self.__class__.port, - port_tracker=class_port_tracker, - cwd=self.testdir, - server_path=self.server_path, + server, client = super().create_server( + testdir=testdir, + bind_ip=bind_ip, + port=port, + server_path=server_path, + args=args, + skip_teardown=skip_teardown, + conf_file=conf_file, + external_server=external_server, + wait_for_ping=wait_for_ping, + connect_client=connect_client, ) - server.start(wait_for_ping=True, connect_client=True) self.__class__._shared_server = server - self.__class__._shared_client = server.client - self.__class__._server_list.append(server) - yield - for s in self.__class__._server_list: - if s: - s.exit() - - @pytest.fixture(autouse=True) - def reset_between_tests(self, class_server): - yield - self._shared_client.flushall() - self._shared_client.execute_command("CONFIG", "RESETSTAT") + self.__class__._shared_client = client + self.__class__._initial_config = client.config_get("*") + return server, client - @property - def server(self): - return self.__class__._shared_server + def teardown(self): + if hasattr(self.__class__, '_shared_server') and self.__class__._shared_server: + client = self.__class__._shared_client + client.flushall() + client.execute_command("CONFIG", "RESETSTAT") + if hasattr(self.__class__, '_initial_config'): + current = client.config_get("*") + for key, val in self.__class__._initial_config.items(): + if current.get(key) != val: + try: + client.config_set(key, val) + except Exception: + pass - @property - def client(self): - return self.__class__._shared_client + @pytest.fixture(autouse=True, scope="class") + def class_teardown(self, request): + yield + if hasattr(self.__class__, '_shared_server') and self.__class__._shared_server: + self.__class__._shared_server.exit() + self.__class__._shared_server = None + self.__class__._shared_client = None From eb44776f2e0084a6d89c95fe83b30face3cb1f8a Mon Sep 17 00:00:00 2001 From: Fanta Niakate Date: Thu, 30 Jul 2026 23:52:57 +0000 Subject: [PATCH 3/9] Address PR feedback: crash handling, testdir fallback, and setup_test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Handle server crash in teardown: if flushall/config reset fails, discard the shared server so next test creates a fresh one - Default testdir to self.testdir when not passed (same pattern as server_path) so None never reaches the parent - Add setup_test fixture to test_reuse_server.py to call create_server() matching the pattern modules use - Remove unused class_port_tracker import from test file - Revert pytest version bump — not needed for server reuse Signed-off-by: Fanta Niakate --- requirements.txt | 2 +- src/conftest.py | 10 ---------- src/valkey_test_case.py | 32 +++++++++++++++++++------------- tests/test_reuse_server.py | 6 +++++- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4583452..349566f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ valkey -pytest==7.4.3 +pytest==6 black pytest-order diff --git a/src/conftest.py b/src/conftest.py index 76a3efc..37eda7e 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -108,13 +108,3 @@ def resource_port_tracker(request): """ with PortTracker(request.node.nodeid) as p: yield p - - -@pytest.fixture(scope="class") -def class_port_tracker(request): - """ - Create port tracker shared across all tests in a class. - Used by ReuseServerTestCase to maintain one server per class. - """ - with PortTracker(request.node.nodeid) as p: - yield p diff --git a/src/valkey_test_case.py b/src/valkey_test_case.py index 2a90644..2715442 100644 --- a/src/valkey_test_case.py +++ b/src/valkey_test_case.py @@ -758,9 +758,11 @@ def create_server( wait_for_ping=True, connect_client=True, ): - if hasattr(self.__class__, '_shared_server') and self.__class__._shared_server: + if hasattr(self.__class__, "_shared_server") and self.__class__._shared_server: return self.__class__._shared_server, self.__class__._shared_client + if testdir is None: + testdir = self.testdir if server_path is None: server_path = self.server_path @@ -782,23 +784,27 @@ def create_server( return server, client def teardown(self): - if hasattr(self.__class__, '_shared_server') and self.__class__._shared_server: + if hasattr(self.__class__, "_shared_server") and self.__class__._shared_server: client = self.__class__._shared_client - client.flushall() - client.execute_command("CONFIG", "RESETSTAT") - if hasattr(self.__class__, '_initial_config'): - current = client.config_get("*") - for key, val in self.__class__._initial_config.items(): - if current.get(key) != val: - try: - client.config_set(key, val) - except Exception: - pass + try: + client.flushall() + client.execute_command("CONFIG", "RESETSTAT") + if hasattr(self.__class__, "_initial_config"): + current = client.config_get("*") + for key, val in self.__class__._initial_config.items(): + if current.get(key) != val: + try: + client.config_set(key, val) + except Exception: + pass + except Exception: + self.__class__._shared_server = None + self.__class__._shared_client = None @pytest.fixture(autouse=True, scope="class") def class_teardown(self, request): yield - if hasattr(self.__class__, '_shared_server') and self.__class__._shared_server: + if hasattr(self.__class__, "_shared_server") and self.__class__._shared_server: self.__class__._shared_server.exit() self.__class__._shared_server = None self.__class__._shared_client = None diff --git a/tests/test_reuse_server.py b/tests/test_reuse_server.py index bc715c5..f8f303e 100644 --- a/tests/test_reuse_server.py +++ b/tests/test_reuse_server.py @@ -7,13 +7,17 @@ """ import pytest -from conftest import class_port_tracker, resource_port_tracker +from conftest import resource_port_tracker from valkey_test_case import ReuseServerTestCase class TestReuseServer(ReuseServerTestCase): """Verifies that server reuse works and tests are isolated.""" + @pytest.fixture(autouse=True) + def setup_test(self, setup): + self.server, self.client = self.create_server(testdir=self.testdir) + def test_write_and_read(self): """Basic write/read on the shared server.""" self.client.set("greeting", "hello") From c4c01606f3b3b3d2e1ccc58f841edc1894643d25 Mon Sep 17 00:00:00 2001 From: Fanta Niakate Date: Tue, 4 Aug 2026 00:04:36 +0000 Subject: [PATCH 4/9] Address PR feedback: README docs, config reset test, teardown safety - Add ReuseServerTestCase usage section to README - Add note about test ordering (top-to-bottom via pytest-order) - Log warning and tear down server when config_set fails to restore - Call exit() on server before discarding when teardown hits an exception - Add test_config_change_is_restored and test_config_restored_after_previous Signed-off-by: Fanta Niakate --- README.md | 33 +++++++++++++++++++++++++++++++++ src/valkey_test_case.py | 11 ++++++++++- tests/test_reuse_server.py | 15 +++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index acd6a56..aaa27fc 100644 --- a/README.md +++ b/README.md @@ -67,4 +67,37 @@ class TestExamplePerClassSetup(ExampleTestCaseBase): client.execute_command("SET K V") ``` +**Reusing a Single Server Across All Tests in a Class** + +If your tests don't need a fresh server each time (most data-operation tests), use `ReuseServerTestCase` to share one server across the entire class. This avoids the overhead of spawning a new process per test — especially useful when module loading is expensive. + +``` +class ExampleModuleTestCase(ReuseServerTestCase): + @pytest.fixture(autouse=True) + def setup_test(self, setup): + server_path = "/path_to_your_valkey_server_binary" + args = {"loadmodule": "/path/to/your/module.so"} + self.server, self.client = self.create_server( + testdir=self.testdir, server_path=server_path, args=args + ) + +class TestExampleReuse(ExampleModuleTestCase): + """ + All tests share the same server. FLUSHALL + CONFIG RESETSTAT + runs between tests automatically for isolation. + """ + + def test_basic1(self): + self.client.execute_command("SET K V") + assert self.client.execute_command("GET K") == b"V" + + def test_basic2(self): + # Previous test's data is flushed — this starts clean + assert self.client.execute_command("GET K") is None +``` + +`ReuseServerTestCase` inherits `ValkeyTestCase`, so all existing fixtures, `create_server()` calls, and `self.server`/`self.client` assignments work unchanged. To adopt it in your module, just change the base class — no other code changes needed. + +Tests run top-to-bottom in definition order (via `pytest-order` with `--order-scope=class`). If a config cannot be restored between tests, the server is torn down and a fresh one starts for the next test. + For more examples, refer to the `tests` directory of this package. diff --git a/src/valkey_test_case.py b/src/valkey_test_case.py index 2715442..c030c95 100644 --- a/src/valkey_test_case.py +++ b/src/valkey_test_case.py @@ -796,8 +796,17 @@ def teardown(self): try: client.config_set(key, val) except Exception: - pass + logging.warning( + f"Could not reset config '{key}' — " + f"tearing down server for fresh restart" + ) + self.__class__._shared_server.exit() + self.__class__._shared_server = None + self.__class__._shared_client = None + return except Exception: + logging.warning("Server unreachable during teardown — killing process") + self.__class__._shared_server.exit() self.__class__._shared_server = None self.__class__._shared_client = None diff --git a/tests/test_reuse_server.py b/tests/test_reuse_server.py index f8f303e..fbabda7 100644 --- a/tests/test_reuse_server.py +++ b/tests/test_reuse_server.py @@ -4,6 +4,10 @@ All tests in this class share ONE server. Between each test, FLUSHALL + CONFIG RESETSTAT run automatically to give each test a clean slate without the cost of restarting the server. + +Tests run top-to-bottom in definition order (via pytest-order with +--order-scope=class). Some tests verify isolation from the previous test, +so ordering matters. """ import pytest @@ -41,3 +45,14 @@ def test_multiple_keys(self): def test_previous_keys_gone(self): """Proves the 10 keys from the previous test were flushed.""" assert self.client.dbsize() == 0 + + def test_config_change_is_restored(self): + """Proves configs modified during a test get restored for the next.""" + original = self.client.config_get("hz")["hz"] + self.client.config_set("hz", "50") + assert self.client.config_get("hz")["hz"] == "50" + + def test_config_restored_after_previous(self): + """Proves the config changed in the previous test was reset.""" + current = self.client.config_get("hz")["hz"] + assert current == "10", f"Expected hz=10 (default), got hz={current}" From 1336e187a92d439b93f455590a0ba0603398fdac Mon Sep 17 00:00:00 2001 From: Fanta Niakate Date: Tue, 4 Aug 2026 00:11:12 +0000 Subject: [PATCH 5/9] Fix test_reuse_server to use built binary path from SERVER_VERSION Signed-off-by: Fanta Niakate --- tests/test_reuse_server.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_reuse_server.py b/tests/test_reuse_server.py index fbabda7..5f03e08 100644 --- a/tests/test_reuse_server.py +++ b/tests/test_reuse_server.py @@ -10,6 +10,7 @@ so ordering matters. """ +import os import pytest from conftest import resource_port_tracker from valkey_test_case import ReuseServerTestCase @@ -20,7 +21,10 @@ class TestReuseServer(ReuseServerTestCase): @pytest.fixture(autouse=True) def setup_test(self, setup): - self.server, self.client = self.create_server(testdir=self.testdir) + server_path = f"{os.path.dirname(os.path.realpath(__file__))}/.build/binaries/{os.environ['SERVER_VERSION']}/valkey-server" + self.server, self.client = self.create_server( + testdir=self.testdir, server_path=server_path + ) def test_write_and_read(self): """Basic write/read on the shared server.""" From 3aba829ac1e852ea6f782f8d95f605c5e3a381ad Mon Sep 17 00:00:00 2001 From: Fanta Niakate Date: Tue, 4 Aug 2026 05:28:35 +0000 Subject: [PATCH 6/9] Add explicit logging import and reset ACL/scripts/replication in teardown ReuseServerTestCase.teardown() now also resets: - REPLICAOF NO ONE (restore to primary if made replica) - SCRIPT FLUSH (clear cached Lua scripts) - FUNCTION FLUSH (clear loaded functions, no-op on Valkey < 7) - ACL DELUSER for non-default users + reset default user permissions Also adds explicit `import logging` instead of relying on wildcard import. Signed-off-by: Fanta Niakate --- src/valkey_test_case.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/valkey_test_case.py b/src/valkey_test_case.py index c030c95..d0ee44a 100644 --- a/src/valkey_test_case.py +++ b/src/valkey_test_case.py @@ -1,3 +1,4 @@ +import logging import subprocess import time import os @@ -787,8 +788,24 @@ def teardown(self): if hasattr(self.__class__, "_shared_server") and self.__class__._shared_server: client = self.__class__._shared_client try: + client.execute_command("REPLICAOF", "NO", "ONE") client.flushall() client.execute_command("CONFIG", "RESETSTAT") + client.execute_command("SCRIPT", "FLUSH") + try: + client.execute_command("FUNCTION", "FLUSH") + except Exception: + pass + users = client.execute_command("ACL", "LIST") + for entry in users: + if isinstance(entry, bytes): + entry = entry.decode() + if not entry.startswith("user default "): + username = entry.split(" ")[1] + client.execute_command("ACL", "DELUSER", username) + client.execute_command( + "ACL", "SETUSER", "default", "reset", "on", "~*", "&*", "+@all" + ) if hasattr(self.__class__, "_initial_config"): current = client.config_get("*") for key, val in self.__class__._initial_config.items(): From d4b376857338d64ee83390e49be43e390008c494 Mon Sep 17 00:00:00 2001 From: Fanta Niakate Date: Tue, 4 Aug 2026 19:45:47 +0000 Subject: [PATCH 7/9] Address PR feedback: ACL nopass, refactor teardown, SERVER_VERSION fallback - Add nopass to ACL SETUSER default reset to ensure no password required - Extract _reset_server_state() from teardown for clarity - Add comment on create_server() explaining cached return behavior - Capture initial hz value in test instead of hardcoding - Fall back to "unstable" if SERVER_VERSION env var not set - Clarify README on caching and reset behavior Signed-off-by: Fanta Niakate --- README.md | 4 +- src/valkey_test_case.py | 89 ++++++++++++++++++++++---------------- tests/test_reuse_server.py | 7 ++- 3 files changed, 59 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index aaa27fc..bd2b4d8 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,8 @@ class TestExampleReuse(ExampleModuleTestCase): `ReuseServerTestCase` inherits `ValkeyTestCase`, so all existing fixtures, `create_server()` calls, and `self.server`/`self.client` assignments work unchanged. To adopt it in your module, just change the base class — no other code changes needed. -Tests run top-to-bottom in definition order (via `pytest-order` with `--order-scope=class`). If a config cannot be restored between tests, the server is torn down and a fresh one starts for the next test. +`create_server()` only starts the server on the first call — subsequent calls return the cached instance. Between tests, the overridden `teardown()` resets state (FLUSHALL, config restore, ACL reset, etc.) instead of killing the server. If the server becomes unreachable or a config cannot be restored, it is torn down and a fresh one starts for the next test. + +Tests run top-to-bottom in definition order (via `pytest-order` with `--order-scope=class`). For more examples, refer to the `tests` directory of this package. diff --git a/src/valkey_test_case.py b/src/valkey_test_case.py index d0ee44a..9341c09 100644 --- a/src/valkey_test_case.py +++ b/src/valkey_test_case.py @@ -759,6 +759,7 @@ def create_server( wait_for_ping=True, connect_client=True, ): + # Return cached server if already running — no new server is created. if hasattr(self.__class__, "_shared_server") and self.__class__._shared_server: return self.__class__._shared_server, self.__class__._shared_client @@ -785,47 +786,59 @@ def create_server( return server, client def teardown(self): + # Reset shared server state between tests instead of shutting it down. if hasattr(self.__class__, "_shared_server") and self.__class__._shared_server: - client = self.__class__._shared_client + self._reset_server_state() + + def _reset_server_state(self): + client = self.__class__._shared_client + try: + client.execute_command("REPLICAOF", "NO", "ONE") + client.flushall() + client.execute_command("CONFIG", "RESETSTAT") + client.execute_command("SCRIPT", "FLUSH") try: - client.execute_command("REPLICAOF", "NO", "ONE") - client.flushall() - client.execute_command("CONFIG", "RESETSTAT") - client.execute_command("SCRIPT", "FLUSH") - try: - client.execute_command("FUNCTION", "FLUSH") - except Exception: - pass - users = client.execute_command("ACL", "LIST") - for entry in users: - if isinstance(entry, bytes): - entry = entry.decode() - if not entry.startswith("user default "): - username = entry.split(" ")[1] - client.execute_command("ACL", "DELUSER", username) - client.execute_command( - "ACL", "SETUSER", "default", "reset", "on", "~*", "&*", "+@all" - ) - if hasattr(self.__class__, "_initial_config"): - current = client.config_get("*") - for key, val in self.__class__._initial_config.items(): - if current.get(key) != val: - try: - client.config_set(key, val) - except Exception: - logging.warning( - f"Could not reset config '{key}' — " - f"tearing down server for fresh restart" - ) - self.__class__._shared_server.exit() - self.__class__._shared_server = None - self.__class__._shared_client = None - return + client.execute_command("FUNCTION", "FLUSH") except Exception: - logging.warning("Server unreachable during teardown — killing process") - self.__class__._shared_server.exit() - self.__class__._shared_server = None - self.__class__._shared_client = None + pass + users = client.execute_command("ACL", "LIST") + for entry in users: + if isinstance(entry, bytes): + entry = entry.decode() + if not entry.startswith("user default "): + username = entry.split(" ")[1] + client.execute_command("ACL", "DELUSER", username) + client.execute_command( + "ACL", + "SETUSER", + "default", + "reset", + "on", + "nopass", + "~*", + "&*", + "+@all", + ) + if hasattr(self.__class__, "_initial_config"): + current = client.config_get("*") + for key, val in self.__class__._initial_config.items(): + if current.get(key) != val: + try: + client.config_set(key, val) + except Exception: + logging.warning( + f"Could not reset config '{key}' — " + f"tearing down server for fresh restart" + ) + self.__class__._shared_server.exit() + self.__class__._shared_server = None + self.__class__._shared_client = None + return + except Exception: + logging.warning("Server unreachable during teardown — killing process") + self.__class__._shared_server.exit() + self.__class__._shared_server = None + self.__class__._shared_client = None @pytest.fixture(autouse=True, scope="class") def class_teardown(self, request): diff --git a/tests/test_reuse_server.py b/tests/test_reuse_server.py index 5f03e08..e807109 100644 --- a/tests/test_reuse_server.py +++ b/tests/test_reuse_server.py @@ -21,7 +21,8 @@ class TestReuseServer(ReuseServerTestCase): @pytest.fixture(autouse=True) def setup_test(self, setup): - server_path = f"{os.path.dirname(os.path.realpath(__file__))}/.build/binaries/{os.environ['SERVER_VERSION']}/valkey-server" + version = os.environ.get("SERVER_VERSION", "unstable") + server_path = f"{os.path.dirname(os.path.realpath(__file__))}/.build/binaries/{version}/valkey-server" self.server, self.client = self.create_server( testdir=self.testdir, server_path=server_path ) @@ -53,10 +54,12 @@ def test_previous_keys_gone(self): def test_config_change_is_restored(self): """Proves configs modified during a test get restored for the next.""" original = self.client.config_get("hz")["hz"] + self.__class__._original_hz = original self.client.config_set("hz", "50") assert self.client.config_get("hz")["hz"] == "50" def test_config_restored_after_previous(self): """Proves the config changed in the previous test was reset.""" + expected = self.__class__._original_hz current = self.client.config_get("hz")["hz"] - assert current == "10", f"Expected hz=10 (default), got hz={current}" + assert current == expected, f"Expected hz={expected}, got hz={current}" From 6e9aeaaa8582bb060623744ce86c21337c1b6625 Mon Sep 17 00:00:00 2001 From: Fanta Niakate Date: Tue, 4 Aug 2026 23:46:14 +0000 Subject: [PATCH 8/9] Add server_list cleanup and _reset_server_state docstring - Clean up additional servers in teardown() (skip shared server) - Add safety-net server_list cleanup in class_teardown with getattr - Add docstring to _reset_server_state explaining reset behavior - Update class docstring to accurately list all reset operations - Document additional server cleanup in README Signed-off-by: Fanta Niakate --- README.md | 2 ++ src/valkey_test_case.py | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bd2b4d8..bf3ed8c 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ class TestExampleReuse(ExampleModuleTestCase): `create_server()` only starts the server on the first call — subsequent calls return the cached instance. Between tests, the overridden `teardown()` resets state (FLUSHALL, config restore, ACL reset, etc.) instead of killing the server. If the server becomes unreachable or a config cannot be restored, it is torn down and a fresh one starts for the next test. +If a test creates additional servers (e.g. a server without a module loaded for RDB testing), those are tracked in `server_list` and automatically cleaned up at the end of that test. Only the shared server persists across tests. + Tests run top-to-bottom in definition order (via `pytest-order` with `--order-scope=class`). For more examples, refer to the `tests` directory of this package. diff --git a/src/valkey_test_case.py b/src/valkey_test_case.py index 9341c09..0ee9d9b 100644 --- a/src/valkey_test_case.py +++ b/src/valkey_test_case.py @@ -735,8 +735,10 @@ class ReuseServerTestCase(ValkeyTestCase): """Test case that reuses a single server across all tests in the class. Instead of spawning a fresh server per test, one server is started on the - first create_server() call and reused for all subsequent tests. FLUSHALL + - CONFIG RESETSTAT run between tests to reset state. + first create_server() call and reused for all subsequent tests. Between + tests, _reset_server_state() restores isolation by running: REPLICAOF NO + ONE, FLUSHALL, CONFIG RESETSTAT, SCRIPT FLUSH, FUNCTION FLUSH, ACL reset, + and full config restore. Usage — just change your base class: @@ -789,8 +791,20 @@ def teardown(self): # Reset shared server state between tests instead of shutting it down. if hasattr(self.__class__, "_shared_server") and self.__class__._shared_server: self._reset_server_state() + # Clean up any additional servers created during this test. + for server in self.server_list: + if server and server is not self.__class__._shared_server: + server.exit() + self.server_list = [] def _reset_server_state(self): + """Reset the shared server to a clean state between tests. + + Clears data, scripts, functions, ACL users, replication, and restores + all config values to their initial state. If the server is unreachable + or a config cannot be restored, the server is killed so the next test + gets a fresh instance. + """ client = self.__class__._shared_client try: client.execute_command("REPLICAOF", "NO", "ONE") @@ -847,3 +861,7 @@ def class_teardown(self, request): self.__class__._shared_server.exit() self.__class__._shared_server = None self.__class__._shared_client = None + for server in getattr(self, "server_list", []): + if server: + server.exit() + self.server_list = [] From c68779832c3b881746869a4af4dd20786505a8ca Mon Sep 17 00:00:00 2001 From: Fanta Niakate Date: Fri, 7 Aug 2026 22:35:16 +0000 Subject: [PATCH 9/9] Reset connection, kill spawned clients, and clear logs between tests Addresses mentor feedback on additional state to reset between tests: - RESET the shared connection first (discards MULTI/WATCH, disables CLIENT TRACKING, restores RESP2/db0/READWRITE, etc.) so later commands aren't queued inside a lingering MULTI - CLIENT KILL TYPE normal to drop any connections a test spawned (SKIPME yes default keeps the shared client alive) - SLOWLOG RESET, LATENCY RESET, ACL LOG RESET so per-test log checks start clean Add tests proving a spawned connection and an ACL user left behind in one test are gone in the next, and sync the class/method/README/test docstrings with the full set of reset operations. Signed-off-by: Fanta Niakate --- README.md | 7 +++--- src/valkey_test_case.py | 29 +++++++++++++++++++------ tests/test_reuse_server.py | 44 +++++++++++++++++++++++++++++++++++--- 3 files changed, 67 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index bf3ed8c..5791e4d 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,9 @@ class ExampleModuleTestCase(ReuseServerTestCase): class TestExampleReuse(ExampleModuleTestCase): """ - All tests share the same server. FLUSHALL + CONFIG RESETSTAT - runs between tests automatically for isolation. + All tests share the same server. Server state is reset between + tests automatically (FLUSHALL, config restore, ACL reset, etc.) + for isolation. """ def test_basic1(self): @@ -98,7 +99,7 @@ class TestExampleReuse(ExampleModuleTestCase): `ReuseServerTestCase` inherits `ValkeyTestCase`, so all existing fixtures, `create_server()` calls, and `self.server`/`self.client` assignments work unchanged. To adopt it in your module, just change the base class — no other code changes needed. -`create_server()` only starts the server on the first call — subsequent calls return the cached instance. Between tests, the overridden `teardown()` resets state (FLUSHALL, config restore, ACL reset, etc.) instead of killing the server. If the server becomes unreachable or a config cannot be restored, it is torn down and a fresh one starts for the next test. +`create_server()` only starts the server on the first call — subsequent calls return the cached instance. Between tests, the overridden `teardown()` resets state instead of killing the server: it issues `RESET` on the shared connection, kills any client connections a test spawned, flushes data/scripts/functions, resets ACL users and the slowlog/latency/ACL logs, and restores any modified config values. If the server becomes unreachable or a config cannot be restored, it is torn down and a fresh one starts for the next test. If a test creates additional servers (e.g. a server without a module loaded for RDB testing), those are tracked in `server_list` and automatically cleaned up at the end of that test. Only the shared server persists across tests. diff --git a/src/valkey_test_case.py b/src/valkey_test_case.py index 0ee9d9b..40aeefe 100644 --- a/src/valkey_test_case.py +++ b/src/valkey_test_case.py @@ -736,9 +736,10 @@ class ReuseServerTestCase(ValkeyTestCase): Instead of spawning a fresh server per test, one server is started on the first create_server() call and reused for all subsequent tests. Between - tests, _reset_server_state() restores isolation by running: REPLICAOF NO - ONE, FLUSHALL, CONFIG RESETSTAT, SCRIPT FLUSH, FUNCTION FLUSH, ACL reset, - and full config restore. + tests, _reset_server_state() restores isolation by running: RESET on the + shared connection, CLIENT KILL for connections a test spawned, REPLICAOF NO + ONE, FLUSHALL, CONFIG RESETSTAT, SCRIPT FLUSH, FUNCTION FLUSH, SLOWLOG / + LATENCY / ACL LOG resets, ACL user reset, and full config restore. Usage — just change your base class: @@ -800,13 +801,23 @@ def teardown(self): def _reset_server_state(self): """Reset the shared server to a clean state between tests. - Clears data, scripts, functions, ACL users, replication, and restores - all config values to their initial state. If the server is unreachable - or a config cannot be restored, the server is killed so the next test - gets a fresh instance. + Resets the shared connection, kills any client connections a test + spawned, clears data, scripts, functions, server-side logs (slowlog, + latency, ACL log), and ACL users, unwinds replication, and restores all + config values to their initial state. If the server is unreachable or a + config cannot be restored, the server is killed so the next test gets a + fresh instance. """ client = self.__class__._shared_client try: + # RESET the shared connection first to clear any per-connection + # state a test left behind (MULTI/WATCH, CLIENT TRACKING, RESP + # version, selected DB, MONITOR/pubsub). Doing this first ensures + # the following commands aren't silently queued inside a MULTI. + client.execute_command("RESET") + # Kill any client connections a test spawned. CLIENT KILL defaults + # to SKIPME yes, so the shared client issuing this is not killed. + client.execute_command("CLIENT", "KILL", "TYPE", "normal") client.execute_command("REPLICAOF", "NO", "ONE") client.flushall() client.execute_command("CONFIG", "RESETSTAT") @@ -815,6 +826,10 @@ def _reset_server_state(self): client.execute_command("FUNCTION", "FLUSH") except Exception: pass + # Clear server-side logs so per-test log checks start clean. + client.execute_command("SLOWLOG", "RESET") + client.execute_command("LATENCY", "RESET") + client.execute_command("ACL", "LOG", "RESET") users = client.execute_command("ACL", "LIST") for entry in users: if isinstance(entry, bytes): diff --git a/tests/test_reuse_server.py b/tests/test_reuse_server.py index e807109..388ff13 100644 --- a/tests/test_reuse_server.py +++ b/tests/test_reuse_server.py @@ -1,9 +1,10 @@ """ Demonstrates ReuseServerTestCase usage. -All tests in this class share ONE server. Between each test, FLUSHALL + CONFIG -RESETSTAT run automatically to give each test a clean slate without the cost of -restarting the server. +All tests in this class share ONE server. Between each test, the server state +is reset automatically (connection RESET, spawned clients killed, FLUSHALL, +config restore, ACL reset, log resets, etc.) to give each test a clean slate +without the cost of restarting the server. Tests run top-to-bottom in definition order (via pytest-order with --order-scope=class). Some tests verify isolation from the previous test, @@ -63,3 +64,40 @@ def test_config_restored_after_previous(self): expected = self.__class__._original_hz current = self.client.config_get("hz")["hz"] assert current == expected, f"Expected hz={expected}, got hz={current}" + + def test_spawn_extra_connection_and_acl_user(self): + """Leave an extra connection and an ACL user behind for teardown.""" + extra = self.server.get_new_client() + self.__class__._extra_client_id = extra.execute_command("CLIENT", "ID") + self.client.execute_command( + "ACL", "SETUSER", "leaked", "on", ">pw", "~*", "+@all" + ) + assert self._acl_user_exists("leaked") + + def test_extra_connection_and_acl_user_gone(self): + """Proves teardown killed the spare connection and deleted the ACL user.""" + # The connection spawned in the previous test should no longer exist. + live_ids = { + int(line.split("id=")[1].split(" ")[0]) + for line in self._client_list().splitlines() + if "id=" in line + } + assert ( + self.__class__._extra_client_id not in live_ids + ), "Spawned connection should have been killed by CLIENT KILL" + # The ACL user created in the previous test should be gone. + assert not self._acl_user_exists( + "leaked" + ), "ACL user from previous test should have been deleted" + + def _client_list(self): + result = self.client.execute_command("CLIENT", "LIST") + return result.decode() if isinstance(result, bytes) else result + + def _acl_user_exists(self, name): + for entry in self.client.execute_command("ACL", "LIST"): + if isinstance(entry, bytes): + entry = entry.decode() + if entry.startswith(f"user {name} "): + return True + return False