Skip to content

Commit 3b00113

Browse files
committed
test: implement py testing good practices
1 parent 3cc19d6 commit 3b00113

20 files changed

Lines changed: 1176 additions & 583 deletions

‎tests/benchmarks/test_bench_check.py‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
"""Benchmark tests for LanguageTool grammar checking performance.
22
33
Run with: pytest tests/benchmarks/ -v
4+
5+
A JVM is required to run these benchmarks. If the local LanguageTool JAR cache is
6+
empty, the first ``LanguageTool(...)`` call in this module triggers a real download
7+
of the LanguageTool archive over the network.
48
"""
59

610
from __future__ import annotations
@@ -23,18 +27,36 @@
2327

2428
@pytest.fixture(scope="module")
2529
def tool() -> Generator[language_tool_python.LanguageTool, None, None]:
26-
"""Provide a LanguageTool instance shared across benchmarks in this module."""
30+
"""Provide a LanguageTool instance shared across benchmarks in this module.
31+
32+
Performs one warm-up ``check()`` call before yielding, since pytest.ini does
33+
not configure ``--benchmark-warmup``: without it, whichever benchmark happens
34+
to run first in this module would absorb the server's cold-start JIT cost.
35+
"""
2736
with language_tool_python.LanguageTool("en-US") as t:
37+
t.check("warm-up")
2838
yield t
2939

3040

3141
@pytest.fixture(scope="module")
3242
def cached_tool() -> Generator[language_tool_python.LanguageTool, None, None]:
33-
"""Provide a pipeline-caching LanguageTool instance for cache benchmarks."""
43+
"""Provide a pipeline-caching LanguageTool instance for cache benchmarks.
44+
45+
``cacheSize=1000`` sets the maximum number of previously checked sentences the
46+
server keeps in memory, ``pipelineCaching=True`` additionally caches the
47+
internal per-language analysis pipeline (tokenizer, tagger, etc.) so it is not
48+
rebuilt on every request. Together they let repeated checks of the same
49+
sentence skip most of the analysis work.
50+
51+
Performs one warm-up ``check()`` call (on text distinct from the benchmarked
52+
sentence, so it does not itself pre-populate the cache for that sentence)
53+
before yielding, for the same cold-start reason as the ``tool`` fixture above.
54+
"""
3455
with language_tool_python.LanguageTool(
3556
"en-US",
3657
config={"cacheSize": 1000, "pipelineCaching": True},
3758
) as t:
59+
t.check("warm-up, unrelated to any benchmarked sentence")
3860
yield t
3961

4062

@@ -76,6 +98,10 @@ def test_bench_check_with_pipeline_cache(
7698
) -> None:
7799
"""Benchmark grammar checking with pipeline caching enabled.
78100
79-
Compare with test_bench_check_short_text to measure cache speedup.
101+
Every round checks the same ``_SHORT_TEXT`` on the same server instance, so
102+
(after the fixture's warm-up call) all rounds but the very first are cache
103+
hits. Compare the resulting numbers with ``test_bench_check_short_text``
104+
(same text, no caching configured) to estimate the cache's speedup, this
105+
test alone does not exercise a cache miss/hit contrast within itself.
80106
"""
81107
benchmark(cached_tool.check, _SHORT_TEXT)
Lines changed: 50 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,65 @@
11
"""Integration tests for the public API functionality."""
22

3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
37
import pytest
48

59
import language_tool_python
610
from language_tool_python.exceptions import RateLimitError
711

12+
if TYPE_CHECKING:
13+
from language_tool_python.match import Match
14+
15+
_ES_TEXT = (
16+
"Escriba un texto aquí. LanguageTool le ayudará a afrentar "
17+
"algunas dificultades propias de la escritura. Se a hecho un esfuerzo "
18+
"para detectar errores tipográficos, ortograficos y incluso "
19+
"gramaticales. También algunos errores de estilo, a grosso modo."
20+
)
821

9-
def test_remote_es() -> None:
10-
"""Test the public API with Spanish language text.
1122

12-
This test verifies that the LanguageToolPublicAPI correctly identifies various
13-
errors in a Spanish text sample.
23+
@pytest.fixture(scope="module")
24+
def es_matches() -> list[Match]:
25+
"""Check ``_ES_TEXT`` once against the public API and share the result.
1426
15-
:raises AssertionError: If the detected matches do not match the expected output.
27+
The check is only performed once (module-scoped) to limit the number of
28+
requests sent to the public API. If the request is rate-limited, every test
29+
depending on this fixture is skipped silently rather than failing.
1630
"""
1731
try:
1832
with language_tool_python.LanguageToolPublicAPI("es") as tool:
19-
es_text = (
20-
"Escriba un texto aquí. LanguageTool le ayudará a afrentar "
21-
"algunas dificultades propias de la escritura. Se a hecho un esfuerzo "
22-
"para detectar errores tipográficos, ortograficos y incluso "
23-
"gramaticales. También algunos errores de estilo, a grosso modo."
24-
)
25-
matches = tool.check(es_text)
26-
assert (
27-
str(matches)
28-
== """[Match({'rule_id': 'AFRENTAR_DIFICULTADES', 'message': 'Confusión
29-
entre «afrontar» y «afrentar».', 'replacements': ['afrontar'],
30-
'offset_in_context': 43, 'context': '...n texto aquí.
31-
32-
LanguageTool le ayudará a afrentar algunas dificultades propias de la
33-
escr...', 'offset': 49, 'error_length': 8, 'category':
34-
'INCORRECT_EXPRESSIONS', 'rule_issue_type': 'grammar', 'sentence':
35-
'LanguageTool le ayudará a afrentar algunas dificultades propias de
36-
la escritura.'}), Match({'rule_id': 'PRON_HABER_PARTICIPIO',
37-
'message': 'El v. ‘haber’ se escribe con hache.',
38-
'replacements': ['ha'], 'offset_in_context': 43, 'context':
39-
'...ificultades propias de la escritura. Se a hecho un esfuerzo para
40-
detectar errores...', 'offset': 107, 'error_length': 1, 'category':
41-
'MISSPELLING', 'rule_issue_type': 'misspelling', 'sentence': 'Se a
42-
hecho un esfuerzo para detectar errores tipográficos, ortograficos y
43-
incluso gramaticales.'}), Match({'rule_id': 'MORFOLOGIK_RULE_ES',
44-
'message': 'Se ha encontrado un posible error ortográfico.',
45-
'replacements': ['ortográficos', 'ortográficas', 'ortográfico',
46-
'orográficos', 'ortografiaos', 'ortografíeos'], 'offset_in_context':
47-
43, 'context': '...rzo para detectar errores tipográficos,
48-
ortograficos y incluso gramaticales. También algunos...', 'offset':
49-
163, 'error_length': 12, 'category': 'TYPOS', 'rule_issue_type':
50-
'misspelling', 'sentence': 'Se a hecho un esfuerzo para detectar
51-
errores tipográficos, ortograficos y incluso gramaticales.'}),
52-
Match({'rule_id': 'Y_E_O_U', 'message': 'Cuando precede a palabras
53-
que comienzan por ‘i’, la conjunción ‘y’ se
54-
transforma en ‘e’.', 'replacements': ['e'],
55-
'offset_in_context': 43, 'context': '...ctar errores tipográficos,
56-
ortograficos y incluso gramaticales. También algunos e...', 'offset':
57-
176, 'error_length': 1, 'category': 'GRAMMAR', 'rule_issue_type':
58-
'grammar', 'sentence': 'Se a hecho un esfuerzo para detectar errores
59-
tipográficos, ortograficos y incluso gramaticales.'}),
60-
Match({'rule_id': 'GROSSO_MODO', 'message': 'Esta expresión latina se
61-
usa sin preposición.', 'replacements': ['grosso modo'],
62-
'offset_in_context': 43, 'context': '...les. También algunos errores
63-
de estilo, a grosso modo.', 'offset': 235, 'error_length': 13,
64-
'category': 'GRAMMAR', 'rule_issue_type': 'grammar', 'sentence':
65-
'También algunos errores de estilo, a grosso modo.'})]
66-
"""
67-
)
33+
return tool.check(_ES_TEXT)
6834
except RateLimitError:
6935
pytest.skip("Rate limit exceeded for public API.")
36+
37+
38+
@pytest.mark.parametrize(
39+
("rule_id", "category", "offset"),
40+
[
41+
("AFRENTAR_DIFICULTADES", "INCORRECT_EXPRESSIONS", 49),
42+
("PRON_HABER_PARTICIPIO", "MISSPELLING", 107),
43+
("MORFOLOGIK_RULE_ES", "TYPOS", 163),
44+
("Y_E_O_U", "GRAMMAR", 176),
45+
("GROSSO_MODO", "GRAMMAR", 235),
46+
],
47+
)
48+
def test_remote_es(
49+
es_matches: list[Match],
50+
rule_id: str,
51+
category: str,
52+
offset: int,
53+
) -> None:
54+
"""Test that the public API detects a specific known error in Spanish text.
55+
56+
LanguageTool rules can change over time, so this asserts on individual match
57+
fields (rule_id, category, offset) rather than requiring the entire response
58+
to match a frozen snapshot exactly.
59+
60+
:raises AssertionError: If no match with the expected fields is found.
61+
"""
62+
assert any(
63+
m.rule_id == rule_id and m.category == category and m.offset == offset
64+
for m in es_matches
65+
)

‎tests/integration/test_cli.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,7 @@ def remote_server() -> Generator[tuple[str, int], None, None]:
3737
:rtype: Generator[Tuple[str, int], None, None]
3838
"""
3939
with language_tool_python.LanguageTool("en-US") as tool:
40-
host = tool._host
41-
port = tool._port
42-
yield host, port
40+
yield tool.host, tool.port
4341

4442

4543
@pytest.mark.parametrize(

‎tests/integration/test_config.py‎

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -135,29 +135,60 @@ def test_config_caching() -> None:
135135
136136
This test verifies that LanguageTool's caching mechanism (cacheSize and
137137
pipelineCaching) significantly improves performance when checking the same text
138-
multiple times. The test measures the time difference between the first and second
139-
checks to ensure caching provides a substantial speedup.
138+
multiple times. The test measures the time difference between an uncached and a
139+
cached check to ensure caching provides a substantial speedup.
140+
141+
This is inherently a timing-sensitive test and could still be flaky under heavy
142+
machine load, so it: (1) performs a warm-up check on unrelated text before
143+
timing, to exclude one-off JIT/connection-setup costs from the measurement
144+
without pre-populating the cache for the text under test, and (2) repeats the
145+
timed comparison up to ``_ATTEMPTS`` times, succeeding as soon as one attempt
146+
shows the expected speedup, instead of requiring every attempt to pass.
140147
141148
:raises AssertionError: If caching does not provide the expected performance
142-
improvement.
149+
improvement in any attempt.
143150
"""
151+
speedup_factor = 5.0
152+
attempts = 3
153+
144154
with language_tool_python.LanguageTool(
145155
"en-US",
146156
config={"cacheSize": 1000, "pipelineCaching": True},
147157
) as tool:
158+
tool.check("warm-up text unrelated to the cached sentence below")
159+
148160
s = "hello darkness my old frend"
149-
t1 = time.time()
150-
tool.check(s)
151-
t2 = time.time()
152-
tool.check(s)
153-
t3 = time.time()
154-
155-
# This is a silly test that says: caching should speed up a grammar-checking
156-
# by a factor of speed_factor when checking the same sentence twice. It
157-
# theoretically could be very flaky.
158-
# But in practice I've observed speedup of around 250x (6.76s to 0.028s).
159-
speedup_factor = 10.0
160-
assert (t2 - t1) / speedup_factor > (t3 - t2)
161+
for _ in range(attempts):
162+
t1 = time.time()
163+
tool.check(s)
164+
t2 = time.time()
165+
tool.check(s)
166+
t3 = time.time()
167+
168+
# In practice, speedups of around 250x (6.76s to 0.028s) have been observed.
169+
if (t2 - t1) / speedup_factor > (t3 - t2):
170+
return
171+
172+
pytest.fail(
173+
f"Caching did not provide the expected speedup in {attempts} attempts."
174+
)
175+
176+
177+
def test_inexistent_language() -> None:
178+
"""Test that creating a LanguageTag with an invalid language code raises an error.
179+
180+
This test verifies that the LanguageTag constructor correctly validates language
181+
codes and raises a ValueError when given a language code that is not supported.
182+
A real server is required here to obtain the list of supported languages via
183+
``tool._get_languages()``.
184+
185+
:raises AssertionError: If ValueError is not raised for an invalid language code.
186+
"""
187+
with (
188+
language_tool_python.LanguageTool("en-US") as tool,
189+
pytest.raises(ValueError, match="unsupported language"),
190+
):
191+
language_tool_python.LanguageTag("xx-XX", tool._get_languages())
161192

162193

163194
def test_disabled_rule_in_config() -> None:

‎tests/integration/test_download.py‎

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,6 @@ def test_install_too_old_version() -> None:
3333
language_tool_python.LanguageTool(language_tool_download_version="3.9")
3434

3535

36-
def test_inexistent_language() -> None:
37-
"""Test that creating a LanguageTag with an invalid language code raises an error.
38-
39-
This test verifies that the LanguageTag constructor correctly validates language
40-
codes and raises a ValueError when given a language code that is not supported.
41-
42-
:raises AssertionError: If ValueError is not raised for an invalid language code.
43-
"""
44-
with (
45-
language_tool_python.LanguageTool("en-US") as tool,
46-
pytest.raises(ValueError, match="unsupported language"),
47-
):
48-
language_tool_python.LanguageTag("xx-XX", tool._get_languages())
49-
50-
5136
def test_install_oldest_supported_version() -> None:
5237
"""Test that downloading the oldest supported LanguageTool version works correctly.
5338

‎tests/integration/test_server_local.py‎

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ def test_process_starts_and_stops_on_close() -> None:
5858
# Make sure process stopped after close() was called.
5959
time.sleep(0.5) # Give some time for process to stop after close() call.
6060
assert proc.poll() is not None, "tool._server should stop running after deletion"
61-
# remember --> if poll is None: # p.subprocess is alive
6261

6362

6463
def test_local_client_server_connection() -> None:
@@ -72,7 +71,7 @@ def test_local_client_server_connection() -> None:
7271
server.
7372
"""
7473
with language_tool_python.LanguageTool("en-US", host="127.0.0.1") as tool1:
75-
url = f"http://{tool1._host}:{tool1._port}/"
74+
url = f"http://{tool1.host}:{tool1.port}/"
7675
with language_tool_python.LanguageTool("en-US", remote_server=url) as tool2:
7776
assert len(tool2.check("helo darknes my old frend"))
7877

@@ -104,28 +103,31 @@ def test_session_only_new_spellings() -> None:
104103
initial_checksum = hashlib.sha256(initial_spelling_file_contents.encode())
105104

106105
new_spellings = ["word1", "word2", "word3"]
107-
with language_tool_python.LanguageTool(
108-
"en-US",
109-
new_spellings=new_spellings,
110-
new_spellings_persist=False,
111-
) as tool:
112-
tool.enabled_rules_only = True
113-
tool.enabled_rules = {"MORFOLOGIK_RULE_EN_US"}
114-
matches = tool.check(" ".join(new_spellings))
106+
try:
107+
with language_tool_python.LanguageTool(
108+
"en-US",
109+
new_spellings=new_spellings,
110+
new_spellings_persist=False,
111+
) as tool:
112+
tool.enabled_rules_only = True
113+
tool.enabled_rules = {"MORFOLOGIK_RULE_EN_US"}
114+
matches = tool.check(" ".join(new_spellings))
115+
116+
with spelling_file_path.open("r", encoding="utf-8") as spelling_file:
117+
subsequent_spelling_file_contents = spelling_file.read()
118+
subsequent_checksum = hashlib.sha256(subsequent_spelling_file_contents.encode())
115119

116-
with spelling_file_path.open("r", encoding="utf-8") as spelling_file:
117-
subsequent_spelling_file_contents = spelling_file.read()
118-
subsequent_checksum = hashlib.sha256(subsequent_spelling_file_contents.encode())
119-
120-
if initial_checksum != subsequent_checksum:
120+
assert not matches
121+
assert initial_checksum.hexdigest() == subsequent_checksum.hexdigest()
122+
finally:
123+
# Restore unconditionally (not just when a diff was detected) so that a
124+
# failed assertion above can never leave the shared spelling file corrupted
125+
# for the rest of the test session.
121126
with spelling_file_path.open(
122127
"w", encoding="utf-8", newline="\n"
123128
) as spelling_file:
124129
spelling_file.write(initial_spelling_file_contents)
125130

126-
assert not matches
127-
assert initial_checksum.hexdigest() == subsequent_checksum.hexdigest()
128-
129131

130132
def test_new_spellins_in_es() -> None:
131133
"""Test that new spellings are recognized in Spanish language.

0 commit comments

Comments
 (0)