Skip to content

Commit 0eb5cf0

Browse files
authored
Merge pull request #175 from ghinks/feat/integration-test
feat: add integration test comparing local vs released versions
2 parents 064092b + f327c93 commit 0eb5cf0

File tree

6 files changed

+337
-1
lines changed

6 files changed

+337
-1
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
poetry run mypy reviewtally --python-version=${{ matrix.python-version }}
2929
- name: run unit tests
3030
run: |
31-
poetry run pytest
31+
poetry run pytest -m "not integration"
3232
- name: build package
3333
run: |
3434
poetry build

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ coverage.xml
5151
.pytest_cache/
5252
cover/
5353

54+
# Integration test outputs
55+
tests/integration/outputs/*.txt
56+
5457
# Translations
5558
*.mo
5659
*.pot

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ ignore = ["D100", "D101", "D102", "D103", "D203", "D212", ]
5858
[tool.ruff.lint.per-file-ignores]
5959
"tests/**/test*.py" = ["S101", "PT009", "PT027", "ANN401"]
6060
"tests/**/__init__.py" = ["D104"]
61+
"tests/integration/test*.py" = ["C901", "PLR0912", "S603", "T201"]
6162
#E: Errors
6263
#W: Warnings
6364
#F: Pyflakes (logical errors)
@@ -73,3 +74,6 @@ ignore = ["D100", "D101", "D102", "D103", "D203", "D212", ]
7374
[tool.pytest.ini_options]
7475
asyncio_mode = "auto" # or "strict"
7576
required_plugins = ["pytest-asyncio"]
77+
markers = [
78+
"integration: marks tests as integration tests (deselect with '-m \"not integration\"')",
79+
]

tests/integration/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Integration tests for review-tally."""

tests/integration/outputs/.gitkeep

Whitespace-only changes.
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
"""Integration test comparing local and released versions of review-tally."""
2+
3+
import os
4+
import re
5+
import subprocess
6+
from datetime import UTC, datetime
7+
from pathlib import Path
8+
from typing import Any
9+
10+
import pytest
11+
12+
# Constants
13+
FLOAT_TOLERANCE = 0.01 # Tolerance for floating point comparisons
14+
15+
16+
def parse_tabulated_output(output: str) -> dict[str, dict[str, Any]]:
17+
"""
18+
Parse tabulated output into a dictionary of user stats.
19+
20+
Args:
21+
output: The raw output from review-tally command
22+
23+
Returns:
24+
Dictionary mapping username to their stats
25+
Example: {'user1': {'reviews': 10, 'comments': 25, ...}, ...}
26+
27+
"""
28+
lines = output.strip().split("\n")
29+
user_stats: dict[str, dict[str, Any]] = {}
30+
31+
# Find the header line to extract column names
32+
header_line = None
33+
data_start_idx = 0
34+
35+
for idx, line in enumerate(lines):
36+
# Skip empty lines
37+
if not line.strip():
38+
continue
39+
40+
# Look for separator line (contains dashes/hyphens)
41+
if re.match(r"^[\s\-+|]+$", line) and idx > 0:
42+
header_line = lines[idx - 1]
43+
data_start_idx = idx + 1
44+
break
45+
46+
if header_line is None:
47+
# If no separator found, try to parse first non-empty line as header
48+
for idx, line in enumerate(lines):
49+
if line.strip():
50+
header_line = line
51+
data_start_idx = idx + 1
52+
break
53+
54+
if header_line is None:
55+
return user_stats
56+
57+
# Parse header to get column names
58+
# Split by multiple spaces or pipe characters
59+
headers = [
60+
h.strip().lower().replace(" ", "-")
61+
for h in re.split(r"\s{2,}|\|", header_line)
62+
if h.strip()
63+
]
64+
65+
# Parse data rows
66+
for line in lines[data_start_idx:]:
67+
# Skip empty lines and separator lines
68+
if not line.strip() or re.match(r"^[\s\-+|]+$", line):
69+
continue
70+
71+
# Split by multiple spaces or pipe characters
72+
values = [v.strip() for v in re.split(r"\s{2,}|\|", line) if v.strip()]
73+
74+
if len(values) < len(headers):
75+
continue
76+
77+
# First column should be the username
78+
username = values[0]
79+
stats = {}
80+
81+
for i, header in enumerate(headers[1:], start=1):
82+
if i < len(values):
83+
value = values[i]
84+
# Try to convert to number
85+
try:
86+
if "." in value:
87+
stats[header] = float(value)
88+
else:
89+
stats[header] = int(value)
90+
except ValueError:
91+
stats[header] = value
92+
93+
if stats:
94+
user_stats[username] = stats
95+
96+
return user_stats
97+
98+
99+
def save_output_files(
100+
local_output: str, released_output: str, output_dir: Path,
101+
) -> tuple[Path, Path]:
102+
"""
103+
Save outputs to timestamped files.
104+
105+
Args:
106+
local_output: Output from local version
107+
released_output: Output from released version
108+
output_dir: Directory to save files in
109+
110+
Returns:
111+
Tuple of (local_file_path, released_file_path)
112+
113+
"""
114+
output_dir.mkdir(parents=True, exist_ok=True)
115+
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
116+
117+
local_file = output_dir / f"local_output_{timestamp}.txt"
118+
released_file = output_dir / f"released_output_{timestamp}.txt"
119+
120+
local_file.write_text(local_output)
121+
released_file.write_text(released_output)
122+
123+
return local_file, released_file
124+
125+
126+
def compare_outputs(
127+
local_stats: dict[str, dict[str, Any]],
128+
released_stats: dict[str, dict[str, Any]],
129+
) -> tuple[bool, str]:
130+
"""
131+
Semantically compare two sets of user statistics.
132+
133+
Args:
134+
local_stats: Parsed stats from local version
135+
released_stats: Parsed stats from released version
136+
137+
Returns:
138+
Tuple of (are_equal, difference_message)
139+
140+
"""
141+
differences = []
142+
143+
# Check for users present in one but not the other
144+
local_users = set(local_stats.keys())
145+
released_users = set(released_stats.keys())
146+
147+
missing_in_released = local_users - released_users
148+
missing_in_local = released_users - local_users
149+
150+
if missing_in_released:
151+
differences.append(
152+
f"Users in local but not in released: {missing_in_released}",
153+
)
154+
155+
if missing_in_local:
156+
differences.append(
157+
f"Users in released but not in local: {missing_in_local}",
158+
)
159+
160+
# Compare stats for common users
161+
common_users = local_users & released_users
162+
163+
for user in sorted(common_users):
164+
local_user_stats = local_stats[user]
165+
released_user_stats = released_stats[user]
166+
167+
# Check for metric differences
168+
all_metrics = set(local_user_stats.keys()) | set(
169+
released_user_stats.keys(),
170+
)
171+
172+
for metric in sorted(all_metrics):
173+
local_value = local_user_stats.get(metric)
174+
released_value = released_user_stats.get(metric)
175+
176+
if local_value != released_value:
177+
# For floating point numbers, allow small differences
178+
if (
179+
isinstance(local_value, float)
180+
and isinstance(released_value, float)
181+
and abs(local_value - released_value) < FLOAT_TOLERANCE
182+
):
183+
continue
184+
185+
differences.append(
186+
f"User '{user}', metric '{metric}': "
187+
f"local={local_value}, released={released_value}",
188+
)
189+
190+
if differences:
191+
return False, "\n".join(differences)
192+
193+
return True, ""
194+
195+
196+
@pytest.mark.integration
197+
def test_local_vs_released_version() -> None:
198+
"""
199+
Test that local version produces same output as released version.
200+
201+
This integration test runs review-tally against the expressjs
202+
organization for March 2025 using both the local development version
203+
and the installed released version, then compares the outputs.
204+
205+
Requires:
206+
- GITHUB_TOKEN environment variable
207+
- review-tally command installed (released version)
208+
"""
209+
# Check for required environment variable
210+
if "GITHUB_TOKEN" not in os.environ:
211+
pytest.fail(
212+
"GITHUB_TOKEN environment variable is required for "
213+
"integration tests",
214+
)
215+
216+
# Test parameters
217+
org = "expressjs"
218+
start_date = "2025-11-01"
219+
end_date = "2025-11-05"
220+
timeout = 600 * 3 # 10 minutes
221+
222+
# Prepare output directory
223+
output_dir = Path(__file__).parent / "outputs"
224+
225+
# Run local version
226+
local_cmd = [
227+
"poetry",
228+
"run",
229+
"python",
230+
"-m",
231+
"reviewtally.main",
232+
"-o",
233+
org,
234+
"-s",
235+
start_date,
236+
"-e",
237+
end_date,
238+
"--no-cache",
239+
]
240+
241+
try:
242+
print(f"\nRunning local version command: {' '.join(local_cmd)}")
243+
local_result = subprocess.run(
244+
local_cmd,
245+
capture_output=True,
246+
text=True,
247+
timeout=timeout,
248+
check=True,
249+
)
250+
local_output = local_result.stdout
251+
print(f"\nLocal version output:\n{local_output}")
252+
except subprocess.CalledProcessError as e:
253+
pytest.fail(
254+
f"Local version failed with exit code {e.returncode}:\n"
255+
f"stdout: {e.stdout}\n"
256+
f"stderr: {e.stderr}",
257+
)
258+
except subprocess.TimeoutExpired:
259+
pytest.fail(f"Local version timed out after {timeout} seconds")
260+
261+
# Run released version
262+
released_cmd = [
263+
"review-tally",
264+
"-o",
265+
org,
266+
"-s",
267+
start_date,
268+
"-e",
269+
end_date,
270+
"--no-cache",
271+
]
272+
273+
try:
274+
print(f"\nRunning released version command: {' '.join(released_cmd)}")
275+
released_result = subprocess.run(
276+
released_cmd,
277+
capture_output=True,
278+
text=True,
279+
timeout=timeout,
280+
check=True,
281+
)
282+
released_output = released_result.stdout
283+
print(f"\nReleased version output:\n{released_output}")
284+
except FileNotFoundError:
285+
pytest.fail(
286+
"Released version not found. Please install review-tally:\n"
287+
" pip install review-tally\n"
288+
"or:\n"
289+
" poetry add --group dev review-tally",
290+
)
291+
except subprocess.CalledProcessError as e:
292+
pytest.fail(
293+
f"Released version failed with exit code {e.returncode}:\n"
294+
f"stdout: {e.stdout}\n"
295+
f"stderr: {e.stderr}",
296+
)
297+
except subprocess.TimeoutExpired:
298+
pytest.fail(f"Released version timed out after {timeout} seconds")
299+
300+
# Save outputs to files
301+
local_file, released_file = save_output_files(
302+
local_output, released_output, output_dir,
303+
)
304+
305+
print("\nOutputs saved to:")
306+
print(f" Local: {local_file}")
307+
print(f" Released: {released_file}")
308+
309+
# Parse outputs
310+
local_stats = parse_tabulated_output(local_output)
311+
released_stats = parse_tabulated_output(released_output)
312+
313+
# Compare semantically
314+
are_equal, diff_message = compare_outputs(local_stats, released_stats)
315+
316+
if not are_equal:
317+
pytest.fail(
318+
f"Outputs differ between local and released versions:\n\n"
319+
f"{diff_message}\n\n"
320+
f"Full outputs saved to:\n"
321+
f" Local: {local_file}\n"
322+
f" Released: {released_file}",
323+
)
324+
325+
print(
326+
"\nSuccess! Local and released versions produced identical results.",
327+
)
328+
print(f"Compared {len(local_stats)} users across {org} organization.")

0 commit comments

Comments
 (0)