Skip to content

Commit 3ec27f2

Browse files
committed
test: split tests in categs, add bench tests
1 parent c13dbb5 commit 3ec27f2

25 files changed

Lines changed: 650 additions & 236 deletions

‎pyproject.toml‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ changelog = "https://github.com/jxmorris12/language_tool_python/blob/master/CHAN
4242
[dependency-groups]
4343
tests = [
4444
"pytest",
45+
"pytest-benchmark",
4546
"pytest-cov",
47+
"hypothesis",
4648
]
4749

4850
docs = [
@@ -141,9 +143,10 @@ ignore = [
141143
]
142144

143145
[tool.ruff.lint.per-file-ignores]
144-
"tests/*.py" = [
145-
"S101", # Need to use assert statements in tests
146-
"SLF001" # Need to use private members of the library for testing
146+
"tests/**/*.py" = [
147+
"S101", # Need to use assert statements in tests
148+
"SLF001", # Need to use private members of the library for testing
149+
"RUF001", # LanguageTool output contains typographic quotes (‘’“”)
147150
]
148151
"src/language_tool_python/__main__.py" = ["T201"] # Allow usage of print in the CLI entry point
149152

@@ -170,3 +173,19 @@ warn_return_any = true
170173
warn_unreachable = true
171174
warn_unused_configs = true
172175
warn_unused_ignores = true
176+
177+
[[tool.mypy.overrides]]
178+
module = ["tests.benchmarks.*"]
179+
# pytest-benchmark is untyped; relax Any restrictions for benchmark files only
180+
disallow_any_unimported = false
181+
disallow_any_expr = false
182+
disallow_any_explicit = false
183+
disallow_any_decorated = false
184+
185+
[[tool.mypy.overrides]]
186+
module = ["tests.property.*"]
187+
# hypothesis is untyped; relax Any restrictions for property test files only
188+
disallow_any_unimported = false
189+
disallow_any_expr = false
190+
disallow_any_explicit = false
191+
disallow_any_decorated = false

‎pytest.ini‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
[pytest]
22
addopts = -vra --cov=src --cov-report=html --cov-report=xml
33
testpaths = tests
4+
markers =
5+
unit: fast, isolated tests with no external dependencies
6+
integration: tests that require a live LanguageTool server or network
7+
property: property-based tests using Hypothesis
8+
perf: performance benchmark tests using pytest-benchmark
49

510
[coverage:run]
611
source = src

‎tests/benchmarks/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Benchmark tests for the language_tool_python library."""

‎tests/benchmarks/conftest.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Configuration for the benchmark test suite."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
10+
def pytest_collection_modifyitems(
11+
items: list[pytest.Item],
12+
) -> None:
13+
"""Apply the 'perf' marker to all tests collected from this directory."""
14+
benchmarks_dir = Path(__file__).parent
15+
for item in items:
16+
if item.path.is_relative_to(benchmarks_dir):
17+
item.add_marker(pytest.mark.perf)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Benchmark tests for LanguageTool grammar checking performance.
2+
3+
Run with: pytest tests/benchmarks/ -v
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from typing import TYPE_CHECKING
9+
10+
import pytest
11+
12+
import language_tool_python
13+
14+
if TYPE_CHECKING:
15+
from collections.abc import Generator
16+
17+
from pytest_benchmark.fixture import BenchmarkFixture
18+
19+
_SHORT_TEXT = "This is a sentence with some erors in it. "
20+
_MEDIUM_TEXT = (_SHORT_TEXT * 20).strip()
21+
_LONG_TEXT = (_SHORT_TEXT * 100).strip()
22+
23+
24+
@pytest.fixture(scope="module")
25+
def tool() -> Generator[language_tool_python.LanguageTool, None, None]:
26+
"""Provide a LanguageTool instance shared across benchmarks in this module."""
27+
with language_tool_python.LanguageTool("en-US") as t:
28+
yield t
29+
30+
31+
@pytest.fixture(scope="module")
32+
def cached_tool() -> Generator[language_tool_python.LanguageTool, None, None]:
33+
"""Provide a pipeline-caching LanguageTool instance for cache benchmarks."""
34+
with language_tool_python.LanguageTool(
35+
"en-US",
36+
config={"cacheSize": 1000, "pipelineCaching": True},
37+
) as t:
38+
yield t
39+
40+
41+
def test_bench_check_short_text(
42+
benchmark: BenchmarkFixture,
43+
tool: language_tool_python.LanguageTool,
44+
) -> None:
45+
"""Benchmark grammar checking on a short sentence (~38 characters)."""
46+
benchmark(tool.check, _SHORT_TEXT)
47+
48+
49+
def test_bench_check_medium_text(
50+
benchmark: BenchmarkFixture,
51+
tool: language_tool_python.LanguageTool,
52+
) -> None:
53+
"""Benchmark grammar checking on medium-length text (~840 characters)."""
54+
benchmark(tool.check, _MEDIUM_TEXT)
55+
56+
57+
def test_bench_check_long_text(
58+
benchmark: BenchmarkFixture,
59+
tool: language_tool_python.LanguageTool,
60+
) -> None:
61+
"""Benchmark grammar checking on long text (~4200 characters)."""
62+
benchmark(tool.check, _LONG_TEXT)
63+
64+
65+
def test_bench_correct_short_text(
66+
benchmark: BenchmarkFixture,
67+
tool: language_tool_python.LanguageTool,
68+
) -> None:
69+
"""Benchmark automatic text correction on a short sentence."""
70+
benchmark(tool.correct, _SHORT_TEXT)
71+
72+
73+
def test_bench_check_with_pipeline_cache(
74+
benchmark: BenchmarkFixture,
75+
cached_tool: language_tool_python.LanguageTool,
76+
) -> None:
77+
"""Benchmark grammar checking with pipeline caching enabled.
78+
79+
Compare with test_bench_check_short_text to measure cache speedup.
80+
"""
81+
benchmark(cached_tool.check, _SHORT_TEXT)

‎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 the language_tool_python library."""

‎tests/integration/conftest.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Configuration for the integration test suite."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
10+
def pytest_collection_modifyitems(
11+
items: list[pytest.Item],
12+
) -> None:
13+
"""Apply the 'integration' marker to all tests collected from this directory."""
14+
integration_dir = Path(__file__).parent
15+
for item in items:
16+
if item.path.is_relative_to(integration_dir):
17+
item.add_marker(pytest.mark.integration)
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Tests for the public API functionality."""
1+
"""Integration tests for the public API functionality."""
22

33
import pytest
44

@@ -34,7 +34,7 @@ def test_remote_es() -> None:
3434
'INCORRECT_EXPRESSIONS', 'rule_issue_type': 'grammar', 'sentence':
3535
'LanguageTool le ayudará a afrentar algunas dificultades propias de
3636
la escritura.'}), Match({'rule_id': 'PRON_HABER_PARTICIPIO',
37-
'message': 'El v. \u2018haber\u2019 se escribe con hache.',
37+
'message': 'El v. ‘haber’ se escribe con hache.',
3838
'replacements': ['ha'], 'offset_in_context': 43, 'context':
3939
'...ificultades propias de la escritura. Se a hecho un esfuerzo para
4040
detectar errores...', 'offset': 107, 'error_length': 1, 'category':
@@ -50,8 +50,8 @@ def test_remote_es() -> None:
5050
'misspelling', 'sentence': 'Se a hecho un esfuerzo para detectar
5151
errores tipográficos, ortograficos y incluso gramaticales.'}),
5252
Match({'rule_id': 'Y_E_O_U', 'message': 'Cuando precede a palabras
53-
que comienzan por \u2018i\u2019, la conjunción \u2018y\u2019 se
54-
transforma en \u2018e\u2019.', 'replacements': ['e'],
53+
que comienzan por ‘i’, la conjunción ‘y’ se
54+
transforma en ‘e’.', 'replacements': ['e'],
5555
'offset_in_context': 43, 'context': '...ctar errores tipográficos,
5656
ortograficos y incluso gramaticales. También algunos e...', 'offset':
5757
176, 'error_length': 1, 'category': 'GRAMMAR', 'rule_issue_type':
Lines changed: 34 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Tests for the command-line interface (CLI) functionality."""
1+
"""Integration tests for the CLI using real LanguageTool server instances."""
22

33
import io
44
import sys
@@ -7,7 +7,39 @@
77
import pytest
88

99
import language_tool_python
10-
from language_tool_python.__main__ import main, parse_args
10+
from language_tool_python.__main__ import main
11+
12+
13+
def main_with_stdin(argv: list[str], stdin: str) -> int:
14+
"""Execute the main CLI with simulated stdin input.
15+
16+
:param argv: Command-line arguments to pass to the main function.
17+
:param stdin: Input text to simulate as stdin.
18+
:return: Exit code returned by the main function.
19+
:rtype: int
20+
"""
21+
old_stdin = sys.stdin
22+
sys.stdin = io.StringIO(stdin)
23+
try:
24+
return main(argv)
25+
finally:
26+
sys.stdin = old_stdin
27+
28+
29+
@pytest.fixture(scope="module")
30+
def remote_server() -> Generator[tuple[str, int], None, None]:
31+
"""Fixture that provides a remote LanguageTool server for testing.
32+
33+
This fixture initializes a LanguageTool instance and yields its host and port,
34+
ensuring proper cleanup after all tests in the module complete.
35+
36+
:return: A tuple containing the server host and port (host, port).
37+
:rtype: Generator[Tuple[str, int], None, None]
38+
"""
39+
with language_tool_python.LanguageTool("en-US") as tool:
40+
host = tool._host
41+
port = tool._port
42+
yield host, port
1143

1244

1345
@pytest.mark.parametrize(
@@ -89,22 +121,6 @@ def test_cli_exit_codes(
89121
assert code != 0
90122

91123

92-
@pytest.fixture(scope="module")
93-
def remote_server() -> Generator[tuple[str, int], None, None]:
94-
"""Fixture that provides a remote LanguageTool server for testing.
95-
96-
This fixture initializes a LanguageTool instance and yields its host and port,
97-
ensuring proper cleanup after all tests in the module complete.
98-
99-
:return: A tuple containing the server host and port (host, port).
100-
:rtype: Generator[Tuple[str, int], None, None]
101-
"""
102-
with language_tool_python.LanguageTool("en-US") as tool:
103-
host = tool._host
104-
port = tool._port
105-
yield host, port
106-
107-
108124
def test_cli_remote_ok(remote_server: tuple[str, int]) -> None:
109125
"""Test the CLI with a remote server using valid input text.
110126
@@ -155,78 +171,3 @@ def test_cli_remote_error(remote_server: tuple[str, int]) -> None:
155171
"This is noot okay.\n",
156172
)
157173
assert code != 0
158-
159-
160-
def test_parse_args_enabled_only_with_enable_categories() -> None:
161-
"""Test that --enabled-only is accepted when only --enable-categories is provided.
162-
163-
:raises AssertionError: If parse_args raises an error for this valid combination.
164-
"""
165-
args = parse_args(["-l", "en-US", "--enabled-only", "-E", "TYPOS", "file.txt"])
166-
assert args.enabled_only is True
167-
assert args.enable_categories == {"TYPOS"}
168-
169-
170-
def test_parse_args_enabled_only_rejects_disable_categories() -> None:
171-
"""Test that --enabled-only cannot be combined with --disable-categories.
172-
173-
:raises SystemExit: Expected, as argparse calls sys.exit on error.
174-
"""
175-
with pytest.raises(SystemExit):
176-
parse_args(
177-
["-l", "en-US", "--enabled-only", "-e", "RULE", "-D", "TYPOS", "file.txt"]
178-
)
179-
180-
181-
def test_parse_args_enabled_only_requires_enable_or_enable_categories() -> None:
182-
"""Test that --enabled-only requires at least --enable or --enable-categories.
183-
184-
:raises SystemExit: Expected, as argparse calls sys.exit on error.
185-
"""
186-
with pytest.raises(SystemExit):
187-
parse_args(["-l", "en-US", "--enabled-only", "file.txt"])
188-
189-
190-
def test_parse_args_categories() -> None:
191-
"""Test that --disable-categories and --enable-categories are parsed correctly.
192-
193-
:raises AssertionError: If the parsed category sets do not match the expected
194-
values.
195-
"""
196-
args = parse_args(
197-
["-l", "en-US", "-D", "TYPOS,GRAMMAR", "-E", "PUNCTUATION", "file.txt"]
198-
)
199-
assert args.disable_categories == {"TYPOS", "GRAMMAR"}
200-
assert args.enable_categories == {"PUNCTUATION"}
201-
202-
203-
def test_parse_args_categories_multiple_flags() -> None:
204-
"""Test that repeated -D/-E flags accumulate into the same set.
205-
206-
:raises AssertionError: If the category sets do not accumulate correctly.
207-
"""
208-
args = parse_args(
209-
["-l", "en-US", "-D", "TYPOS", "-D", "GRAMMAR", "-E", "PUNCTUATION", "file.txt"]
210-
)
211-
assert args.disable_categories == {"TYPOS", "GRAMMAR"}
212-
assert args.enable_categories == {"PUNCTUATION"}
213-
214-
215-
def main_with_stdin(argv: list[str], stdin: str) -> int:
216-
"""Execute the main CLI with simulated stdin input.
217-
218-
This utility function temporarily replaces sys.stdin with a StringIO object
219-
containing the provided input, executes the main CLI function, and then restores the
220-
original stdin.
221-
222-
:param argv: Command-line arguments to pass to the main function.
223-
:param stdin: Input text to simulate as stdin.
224-
:return: Exit code returned by the main function.
225-
:rtype: int
226-
"""
227-
old_stdin = sys.stdin
228-
sys.stdin = io.StringIO(stdin)
229-
try:
230-
return main(argv)
231-
finally:
232-
sys.stdin = old_stdin

0 commit comments

Comments
 (0)