Skip to content

Commit 2e62e04

Browse files
committed
feat: Introduce dual testing with redis and bitmapist-server
1 parent b490ef5 commit 2e62e04

File tree

4 files changed

+270
-44
lines changed

4 files changed

+270
-44
lines changed

README.md

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -342,22 +342,70 @@ uv sync
342342

343343
## Testing
344344

345-
To run our tests will need to ensure a local redis server is installed.
345+
### Quick Start with Docker (Recommended)
346346

347-
You can use these environment variables to tell the tests about Redis:
347+
The easiest way to run tests locally is with Docker:
348348

349-
- `BITMAPIST_REDIS_SERVER_PATH`: Path to the Redis server executable (defaults to the first one in the path or `/usr/bin/redis-server`)
350-
- `BITMAPIST_REDIS_PORT`: Port number for the Redis server (defaults to 6399)
349+
```bash
350+
# Start both backend servers
351+
docker compose up -d
352+
353+
# Run tests
354+
uv run pytest
355+
356+
# Stop servers when done
357+
docker compose down
358+
```
359+
360+
This runs tests against both Redis and bitmapist-server backends automatically.
351361

352-
We use `pytest` to run unit tests, which you can run with:
362+
### Alternative: Native Binaries
353363

364+
To run tests with native binaries, you'll need at least one backend server installed:
365+
366+
**Redis:**
367+
- Install `redis-server` using your package manager
368+
- Ensure it's in your `PATH`, or set `BITMAPIST_REDIS_SERVER_PATH`
369+
370+
**Bitmapist-server:**
371+
- Download from the [releases page](https://github.com/Doist/bitmapist-server/releases)
372+
- Ensure it's in your PATH, or set `BITMAPIST_SERVER_PATH`
373+
374+
Then run:
354375
```bash
355376
uv run pytest
356377
```
357378

358-
> [!TIP]
359-
> You can also run tests against the [bitmapist-server](https://github.com/Doist/bitmapist-server) backend instead of Redis.
360-
> To do this, set the `BITMAPIST_REDIS_SERVER_PATH` variable to the path of the `bitmapist-server` executable.
379+
The test suite auto-detects available backends and runs accordingly:
380+
- **Docker containers running?** Uses them
381+
- **Native binaries available?** Starts them automatically
382+
- **Nothing available?** Shows error
383+
384+
### Configuration
385+
386+
#### Environment Variables
387+
388+
Customize backend locations and ports if needed:
389+
390+
```bash
391+
# Backend binary paths (optional - auto-detected from PATH by default)
392+
export BITMAPIST_REDIS_SERVER_PATH=/custom/path/to/redis-server
393+
export BITMAPIST_SERVER_PATH=/custom/path/to/bitmapist-server
394+
395+
# Backend ports (optional - defaults shown)
396+
export BITMAPIST_REDIS_PORT=6399
397+
export BITMAPIST_SERVER_PORT=6400
398+
```
399+
400+
#### Testing Specific Backends
401+
402+
```bash
403+
# Test only Redis
404+
uv run pytest -k redis
405+
406+
# Test only bitmapist-server
407+
uv run pytest -k bitmapist-server
408+
```
361409

362410
## Releasing new versions
363411

docker-compose.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# This file is used to start the Redis and bitmapist-server services for local
2+
# development and testing.
3+
4+
services:
5+
redis:
6+
image: redis:7-alpine
7+
ports:
8+
- "${BITMAPIST_REDIS_PORT:-6399}:6379"
9+
command: redis-server --port 6379
10+
11+
bitmapist-server:
12+
image: ghcr.io/doist/bitmapist-server:v1.9.8
13+
ports:
14+
- "${BITMAPIST_SERVER_PORT:-6400}:6379"

test/conftest.py

Lines changed: 195 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,55 +4,221 @@
44
import socket
55
import subprocess
66
import time
7+
import warnings
8+
from pathlib import Path
79

810
import pytest
911

10-
from bitmapist import delete_all_events, setup_redis
12+
from bitmapist import delete_all_events, get_redis, setup_redis
13+
14+
# Backend types
15+
BACKEND_REDIS = "redis"
16+
BACKEND_BITMAPIST_SERVER = "bitmapist-server"
17+
18+
# Single source of truth for backend configuration
19+
BACKEND_CONFIGS = {
20+
BACKEND_REDIS: {
21+
"port_env": "BITMAPIST_REDIS_PORT",
22+
"default_port": 6399,
23+
"path_env": "BITMAPIST_REDIS_SERVER_PATH",
24+
"binary_name": "redis-server",
25+
"fallback_path": "/usr/bin/redis-server",
26+
"install_hint": "Install redis-server using your package manager",
27+
"start_args": lambda port: ["--port", str(port)],
28+
},
29+
BACKEND_BITMAPIST_SERVER: {
30+
"port_env": "BITMAPIST_SERVER_PORT",
31+
"default_port": 6400,
32+
"path_env": "BITMAPIST_SERVER_PATH",
33+
"binary_name": "bitmapist-server",
34+
"fallback_path": None,
35+
"install_hint": "Download from https://github.com/Doist/bitmapist-server/releases",
36+
"start_args": lambda port: ["-addr", f"0.0.0.0:{port}"],
37+
},
38+
}
39+
40+
41+
def is_socket_open(host, port):
42+
"""Helper function which tests is the socket open"""
43+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
44+
sock.settimeout(0.1)
45+
return sock.connect_ex((host, port)) == 0
46+
47+
48+
@pytest.fixture(scope="session")
49+
def available_backends():
50+
"""
51+
Check which backend servers are available on the system.
52+
Checks for running servers (Docker) OR available binaries.
53+
Fails the test suite if NO backends are available.
54+
"""
55+
backends = []
56+
57+
for backend_name, config in BACKEND_CONFIGS.items():
58+
port = int(os.getenv(config["port_env"], str(config["default_port"])))
59+
60+
# Check if server is already running (Docker or external)
61+
if is_socket_open("127.0.0.1", port):
62+
backends.append(backend_name)
63+
continue
64+
65+
# Check if binary is available
66+
path_str = os.getenv(config["path_env"])
67+
if path_str and Path(path_str).exists():
68+
backends.append(backend_name)
69+
continue
70+
71+
# Check for binary in PATH or fallback location
72+
if shutil.which(config["binary_name"]):
73+
backends.append(backend_name)
74+
continue
75+
76+
if config["fallback_path"] and Path(config["fallback_path"]).exists():
77+
backends.append(backend_name)
78+
79+
if not backends:
80+
pytest.fail(
81+
"No backend servers available. Please install redis-server or bitmapist-server.\n"
82+
"Or set BITMAPIST_REDIS_SERVER_PATH or BITMAPIST_SERVER_PATH environment variables."
83+
)
84+
85+
return backends
86+
87+
88+
@pytest.fixture(params=[BACKEND_REDIS, BACKEND_BITMAPIST_SERVER], scope="session")
89+
def backend_type(request):
90+
"""
91+
Parametrized fixture that will cause the entire test suite to run twice:
92+
once with Redis, once with bitmapist-server.
93+
"""
94+
return request.param
1195

1296

1397
@pytest.fixture(scope="session")
14-
def redis_settings():
15-
# Find the first redis-server in PATH, fallback to /usr/bin/redis-server
16-
default_path = shutil.which("redis-server") or "/usr/bin/redis-server"
98+
def backend_settings(backend_type, available_backends):
99+
"""
100+
Provides backend-specific configuration.
101+
Skips tests if the requested backend is not available.
102+
103+
Uses environment variables to locate binaries:
104+
- BITMAPIST_REDIS_SERVER_PATH: Custom path to redis-server
105+
- BITMAPIST_SERVER_PATH: Custom path to bitmapist-server
106+
- BITMAPIST_REDIS_PORT: Custom port for Redis (default: 6399)
107+
- BITMAPIST_SERVER_PORT: Custom port for bitmapist-server (default: 6400)
108+
"""
109+
# Skip if this backend is not available
110+
if backend_type not in available_backends:
111+
pytest.skip(f"{backend_type} not available on this system")
112+
113+
config = BACKEND_CONFIGS[backend_type]
114+
115+
# Try env var first, then auto-detect
116+
default_path = shutil.which(config["binary_name"])
117+
if not default_path and config["fallback_path"]:
118+
default_path = config["fallback_path"]
119+
server_path = os.getenv(config["path_env"], default_path or "")
120+
port = int(os.getenv(config["port_env"], str(config["default_port"])))
121+
17122
return {
18-
"server_path": os.getenv("BITMAPIST_REDIS_SERVER_PATH", default_path),
19-
"port": int(os.getenv("BITMAPIST_REDIS_PORT", "6399")),
123+
"server_path": server_path,
124+
"port": port,
125+
"backend_type": backend_type,
20126
}
21127

22128

23129
@pytest.fixture(scope="session", autouse=True)
24-
def redis_server(redis_settings):
25-
"""Fixture starting the Redis server"""
26-
redis_host = "127.0.0.1"
27-
redis_port = redis_settings["port"]
28-
if is_socket_open(redis_host, redis_port):
130+
def backend_server(backend_settings):
131+
"""
132+
Smart backend server management with auto-detection.
133+
134+
1. Check if server already running on the port → Use it (Docker/external)
135+
2. Try to find and start binary → Start it (managed mode)
136+
3. Nothing available → Fail with helpful error
137+
"""
138+
host = "127.0.0.1"
139+
port = backend_settings["port"]
140+
backend_type = backend_settings["backend_type"]
141+
142+
# Step 1: Check if already running (Docker or external process)
143+
if is_socket_open(host, port):
29144
yield None
30-
else:
31-
proc = start_redis_server(redis_settings["server_path"], redis_port)
32-
# Give Redis a moment to start up
145+
return
146+
147+
# Step 2: Try to find and start binary
148+
server_path = backend_settings.get("server_path")
149+
if server_path and Path(server_path).exists():
150+
# Binary found, start it
151+
proc = start_backend_server(server_path, port, backend_type)
33152
time.sleep(0.1)
34-
wait_for_socket(redis_host, redis_port)
153+
wait_for_socket(host, port)
35154
yield proc
36155
proc.terminate()
156+
return
157+
158+
# Step 3: Nothing available - provide helpful error
159+
config = BACKEND_CONFIGS[backend_type]
160+
pytest.fail(
161+
f"{backend_type} not available.\n\n"
162+
f"Option 1 (Recommended): Start with Docker\n"
163+
f" docker compose up -d\n\n"
164+
f"Option 2: Install {backend_type} binary\n"
165+
f" {config['install_hint']}\n"
166+
f" Ensure it's in your PATH\n\n"
167+
f"Option 3: Specify binary path\n"
168+
f" export {config['path_env']}=/path/to/{backend_type}\n\n"
169+
f" pytest"
170+
)
37171

38172

39173
@pytest.fixture(scope="session", autouse=True)
40-
def setup_redis_for_bitmapist(redis_settings):
41-
setup_redis("default", "localhost", redis_settings["port"])
42-
setup_redis("default_copy", "localhost", redis_settings["port"])
43-
setup_redis("db1", "localhost", redis_settings["port"], db=1)
174+
def setup_redis_for_bitmapist(backend_settings):
175+
"""Setup Redis connection for current backend"""
176+
port = backend_settings["port"]
177+
178+
setup_redis("default", "localhost", port)
179+
setup_redis("default_copy", "localhost", port)
180+
setup_redis("db1", "localhost", port, db=1)
181+
182+
183+
@pytest.fixture(scope="session", autouse=True)
184+
def check_existing_data(backend_settings, setup_redis_for_bitmapist):
185+
"""
186+
Check for existing data at session start.
187+
Warns if data exists but doesn't delete it (safety first).
188+
"""
189+
cli = get_redis("default")
190+
existing_keys = cli.keys("trackist_*")
191+
192+
if existing_keys:
193+
warnings.warn(
194+
f"\n{'=' * 70}\n"
195+
f"WARNING: Found {len(existing_keys)} existing bitmapist keys in backend.\n"
196+
f"Backend: {backend_settings['backend_type']} on port {backend_settings['port']}\n"
197+
f"\n"
198+
f"This may indicate:\n"
199+
f"1. Docker containers with data from previous runs\n"
200+
f"2. Shared backend being used by multiple projects\n"
201+
f"3. Production data in the backend (DANGER!)\n"
202+
f"\n"
203+
f"Tests will continue but results may be affected by existing data.\n"
204+
f"\n"
205+
f"To clean: docker compose down -v (removes volumes), or manually FLUSHDB.\n"
206+
f"{'=' * 70}\n",
207+
UserWarning,
208+
stacklevel=2,
209+
)
44210

45211

46212
@pytest.fixture(autouse=True)
47213
def clean_redis():
48214
delete_all_events()
49215

50216

51-
def start_redis_server(server_path, port):
52-
"""Helper function starting Redis server"""
217+
def start_backend_server(server_path, port, backend_type):
218+
"""Helper function starting backend server (Redis or bitmapist-server)"""
53219
devzero = open(os.devnull)
54220
devnull = open(os.devnull, "w")
55-
command = get_redis_command(server_path, port)
221+
command = get_backend_command(server_path, port, backend_type)
56222
proc = subprocess.Popen(
57223
command,
58224
stdin=devzero,
@@ -64,19 +230,13 @@ def start_redis_server(server_path, port):
64230
return proc
65231

66232

67-
def get_redis_command(server_path, port):
68-
"""Run with --version to determine if this is redis or bitmapist-server"""
69-
output = subprocess.check_output([server_path, "--version"])
70-
if b"bitmapist-server" in output:
71-
return [server_path, "-addr", f"0.0.0.0:{port}"]
72-
return [server_path, "--port", str(port)]
73-
74-
75-
def is_socket_open(host, port):
76-
"""Helper function which tests is the socket open"""
77-
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
78-
sock.settimeout(0.1)
79-
return sock.connect_ex((host, port)) == 0
233+
def get_backend_command(server_path, port, backend_type):
234+
"""
235+
Build the command to start the backend server.
236+
No need to detect which server type - we already know from backend_type.
237+
"""
238+
config = BACKEND_CONFIGS[backend_type]
239+
return [server_path, *config["start_args"](port)]
80240

81241

82242
def wait_for_socket(host, port, seconds=3):

test/test_bitmapist.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from datetime import datetime, timedelta, timezone
22

3+
import pytest
4+
35
from bitmapist import (
46
BitOpAnd,
57
BitOpOr,
@@ -247,6 +249,8 @@ def test_bit_operations_magic():
247249
assert list(~foo & bar) == [3]
248250

249251

250-
def test_year_events():
252+
def test_year_events(backend_type):
253+
if backend_type == "bitmapist-server":
254+
pytest.skip("bitmapist-server does not support multiple databases (db != 0)")
251255
mark_event("foo", 1, system="db1")
252256
assert 1 in YearEvents("foo", system="db1")

0 commit comments

Comments
 (0)