Repository navigation
Expand file tree
/
Copy pathindex.html
More file actions
355 lines (341 loc) · 15.9 KB
/
Copy pathindex.html
File metadata and controls
355 lines (341 loc) · 15.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
<!doctype html public "-//W3C//DTD HTML 4.0 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
<meta name="GENERATOR" content="opencode">
<title>Unit Testing Guide for easyjson</title>
</head>
<body>
<center>
<h1>
Unit Testing Guide for easyjson</h1></center>
<center><i>easyjson project</i>
<br><i>Rev 1.1</i>
<br><i>2026 Sep 29</i></center>
<p>This document describes how the <i>easyjson</i> package is tested. It covers the
test layout, the conventions used by <i>unittest</i>, the way errors are verified, and
the <i>pytest</i> equivalents. In this document, the term <i>test</i> means a
single verifiable behaviour of a single function.
<h3>
Requirements</h3>
The overriding requirement is that every public function of <i>easyjson.core</i> must
be covered by at least one test per behaviour, and that no test may depend on another
test. Tests must never write inside the repository, they must never depend on
the current working directory, and they must be runnable from a clean checkout with
a single command.
<p>Falling out from this work is a fast feedback loop: a test that fails must point at a
single behaviour, so the failure has to be readable from the test name alone.
<p>The test dependencies are <i>pytest</i>, <i>ruff</i> and <i>mypy</i>, declared in
the <i>dev</i> group of <i>pyproject.toml</i>. The test file itself imports
nothing but the standard library and <i>easyjson</i>, so <i>unittest</i> remains
usable without <i>pytest</i> installed.
<p><i>pyproject.toml</i> sets <i>testpaths = ["tests"]</i>, therefore no path argument
is needed when running the tests.
<p><i>.github/workflows/ci.yml</i> runs the suite on Python 3.9 to 3.13, and runs
<i>ruff check</i> and <i>mypy</i> on Python 3.13 only, because <i>mypy</i> requires
3.10 or higher.
<h2>
<a NAME="Test Layout"></a>Test Layout</h2>
The test suite is a single file, with one class per tested function.
<pre>
tests/
test_core.py
src/
easyjson/
__init__.py
core.py /* write, append, write_add, read_chaine, delete, base_json */
docs/
index.html /* This document */
</pre>
The class names are the function names suffixed with <i>Tests</i>.
<pre>WriteTests /* easyjson.write() */
AppendTests /* easyjson.append() */
WriteAddTests /* easyjson.write_add() */
ReadChaineTests /* easyjson.read_chaine() */
DeleteTests /* easyjson.delete() */
BaseJsonTests /* easyjson.base_json() */</pre>
<i>read_chaine</i> and <i>delete</i> are not exported by
<i>easyjson/__init__.py</i>, so the test file imports them through the module:
<pre>import easyjson
from easyjson import core</pre>
The public signatures under test are the following. Note that <i>write_add</i>,
<i>read_chaine</i> and <i>delete</i> take the file path first and the key second.
<pre>write(file, contenue)
append(file, contenue)
write_add(file, chaine, contenue)
read_chaine(file, chaine)
delete(file, chaine)
base_json()</pre>
<h2>
<a NAME="Running the Tests"></a>Running the Tests</h2>
Both runners work, since the test file uses only <i>unittest</i> constructs.
<pre>uv run pytest /* recommended */
python -m pytest -v /* verbose, test names shown */
python -m unittest discover -s tests -v</pre>
Coverage is optional and requires <i>pytest-cov</i>.
<pre>uv run pytest --cov=src/easyjson --cov-report=term-missing</pre>
<i>term-missing</i> lists the lines that are not covered. It is useful to find
dead code, it is not useful to chase a 100 % rate: a rarely reached <i>except</i>
branch can legitimately stay uncovered.
<h2>
<a NAME="Test Anatomy"></a>Test Anatomy</h2>
Every tested function gets its own class, inheriting from <i>unittest.TestCase</i>.
<pre>class WriteAddTests(TempFileTestCase):
"""Tests for easyjson.write_add()."""
def test_write_add_creates_key(self):
"""write_add() must add the key with the given value."""
self.write_json({})
core.write_add(self.path, "city", "Nimes")
self.assertEqual(self.read_json(), {"city": "Nimes"})</pre>
Three conventions structure the whole file. The class name is the tested
function name followed by <i>Tests</i>. Every method name starts with
<i>test_</i>, otherwise <i>unittest</i> skips it silently. Every method
receives <i>self</i>, which gives access to the assertions.
<p>The documentation string of a test states the behaviour in the third person, in
the present tense, prefixed by the name of the function under test.
<h2>
<a NAME="Assertions"></a>Assertions</h2>
The assertion methods used by the suite are the following.
<pre>self.assertEqual(a, b) /* a == b */
self.assertNotEqual(a, b) /* a != b */
self.assertTrue(x) /* bool(x) is true */
self.assertFalse(x) /* bool(x) is false */
self.assertIn(x, collection) /* x in collection */
self.assertIsNone(x) /* x is None */
self.assertTrue(os.path.exists(p))</pre>
The expected value always comes first, the actual value second. The reverse
order makes a test that should fail pass.
<h2>
<a NAME="Isolation"></a>Isolation</h2>
<i>setUp</i> runs automatically before each test, <i>tearDown</i> after. That
is what gives every test an identical starting state. The base class creates
one temp folder and one file path per test.
<pre>class TempFileTestCase(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.TemporaryDirectory()
self.addCleanup(self.tmpdir.cleanup)
self.path = os.path.join(self.tmpdir.name, "data.json")</pre>
<i>addCleanup</i> registers an action to run at the end of the test, even if the
test fails. It is preferred over <i>tearDown</i> for the cleanup because it
cannot be forgotten and it always runs. Two helpers read and write the JSON
file under test.
<pre>def read_json(self, path=None):
with open(path or self.path, encoding="utf-8") as f:
return json.load(f)
def write_json(self, data, path=None):
with open(path or self.path, "w", encoding="utf-8") as f:
json.dump(data, f)</pre>
<i>base_json</i> writes <i>data.json</i> in the current directory, so
<i>BaseJsonTests</i> changes the working directory to the temp folder and restores
it with a cleanup.
<pre>def setUp(self):
super().setUp()
previous = os.getcwd()
self.addCleanup(os.chdir, previous)
os.chdir(self.tmpdir.name)</pre>
<h2>
<a NAME="Errors"></a>Verifying Errors</h2>
The pattern is always a <i>with</i> block around the call that is expected to fail.
<pre>def test_write_raises_oserror_if_directory_missing(self):
missing = os.path.join(self.tmpdir.name, "absent", "data.json")
with self.assertRaises(OSError):
easyjson.write(missing, "content")</pre>
If no exception is raised, the test fails. <i>assertRaisesRegex</i> also
checks the error message.
<pre>with self.assertRaisesRegex(ValueError, "Invalid control character"):
easyjson.write(self.path, "contenu\0")</pre>
The exceptions actually produced by the package are:
<table border="1">
<tr><th>Call</th><th>Condition</th><th>Exception</th></tr>
<tr>
<td><i>write</i></td>
<td>folder does not exist</td>
<td>OSError</td>
</tr>
<tr>
<td><i>append</i></td>
<td>file does not exist</td>
<td>OSError</td>
</tr>
<tr>
<td><i>append</i></td>
<td>file is not valid JSON</td>
<td>json.JSONDecodeError</td>
</tr>
<tr>
<td><i>append</i></td>
<td>JSON is not a list</td>
<td>AttributeError</td>
</tr>
<tr>
<td><i>write_add</i></td>
<td>folder does not exist</td>
<td>OSError</td>
</tr>
<tr>
<td><i>write_add</i></td>
<td>file is not valid JSON</td>
<td>json.JSONDecodeError</td>
</tr>
<tr>
<td><i>write_add</i></td>
<td>JSON is not an object</td>
<td>TypeError</td>
</tr>
<tr>
<td><i>write_add</i></td>
<td>key is empty</td>
<td>none, it is a no-op</td>
</tr>
<tr>
<td><i>read_chaine</i></td>
<td>folder does not exist</td>
<td>OSError</td>
</tr>
<tr>
<td><i>read_chaine</i></td>
<td>file is not valid JSON</td>
<td>json.JSONDecodeError</td>
</tr>
<tr>
<td><i>read_chaine</i></td>
<td>key is absent</td>
<td>KeyError</td>
</tr>
<tr>
<td><i>read_chaine</i></td>
<td>JSON is not an object</td>
<td>TypeError</td>
</tr>
<tr>
<td><i>delete</i></td>
<td>folder does not exist</td>
<td>OSError</td>
</tr>
<tr>
<td><i>delete</i></td>
<td>file is not valid JSON</td>
<td>json.JSONDecodeError</td>
</tr>
<tr>
<td><i>delete</i></td>
<td>key is absent</td>
<td>KeyError</td>
</tr>
<tr>
<td><i>delete</i></td>
<td>JSON is not an object</td>
<td>TypeError</td>
</tr>
<tr>
<td><i>base_json</i></td>
<td>folder is not writable</td>
<td>OSError</td>
</tr>
</table>
<i>json.JSONDecodeError</i> inherits from <i>ValueError</i>. The <i>except</i>
blocks of <i>write</i>, <i>append</i>, <i>read_chaine</i> and <i>delete</i> catch
<i>ValueError</i>, so they also catch a decoding error. The <i>except</i> block
of <i>write_add</i> catches <i>FileNotFoundError</i> and <i>json.JSONDecodeError</i>
and re-raises both unchanged.
<h2>
<a NAME="Behaviour"></a>Behaviour, Not Code</h2>
A test verifies a behaviour, it does not track a line. A function is a single
unit, there is no reason to split its tests into one class per branch.
<ul>
<li>
<i>test_write_add_does_not_replace_existing_key</i> exists because refusing to
overwrite a key is part of the public contract, not because a particular line ran.</li>
<li>
<i>test_write_add_converts_content_to_string</i> exists because the value goes
through an f-string, and that is part of the public contract.</li>
<li>
<i>test_append_indents_file_with_four_spaces</i> exists because the indentation of
the rewritten file is visible to whoever opens the file.</li>
<li>
<i>test_read_chaine_prints_value</i> exists because the <i>print</i> inside
<i>read_chaine</i> is visible to the caller even though the function also returns the
value.</li>
</ul>
<p>Each test must be able to fail on its own. If
<i>test_write_add_creates_key</i> breaks, the diagnosis must come from that test,
not from a previous test having left a corrupted file. That is what the per
test temp folders are for.
<p>The test name must be enough to understand the failure without opening the
file. <i>test_append_raises_jsondecodeerror_on_invalid_json</i> is better
than <i>test_append_2</i>.
<h2>
<a NAME="pytest"></a>pytest</h2>
<i>pytest</i> is a layer on top of <i>unittest</i>, everything that works here works
there too. The assertions are written with a bare <i>assert</i>, with no
method call.
<pre>def test_write_returns_path_and_content():
result = easyjson.write(chemin, '{"b": 2}')
assert result == (chemin, '{"b": 2}')</pre>
Repeated tests collapse into a single test with <i>parametrize</i>.
<pre>@pytest.mark.parametrize("value, expected", [(1, "1"), (True, "True"), (None, "None")])
def test_write_add_stores_text(tmp_path, value, expected):
path = tmp_path / "data.json"
path.write_text("{}", encoding="utf-8")
core.write_add(str(path), "key", value)
assert json.loads(path.read_text(encoding="utf-8")) == {"key": expected}</pre>
<i>setUp</i> becomes a fixture, injected by argument name. <i>tmp_path</i>
replaces <i>tempfile.TemporaryDirectory</i> and provides a <i>pathlib.Path</i>.
<h2>
<a NAME="Known Bugs"></a>Known Bugs</h2>
<p>No test currently documents a broken behaviour. Both known bugs have been
fixed, and the two tests that pinned them were inverted into the specification of the
fixed behaviour.
<ul>
<li>
<i>test_base_json_writes_valid_json_with_indent_four</i> replaces the former
<i>test_base_json_raises_typeerror_writing_dict</i>. <i>base_json</i> used to
pass a <i>dict</i> to <i>f.write</i>, which expects a string, so it raised a
<i>TypeError</i> on every call and left <i>data.json</i> empty. It now calls
<i>json.dump(data, f, indent=4)</i>.</li>
<li>
<i>test_write_add_with_empty_key_does_nothing</i> replaces the former
<i>test_write_add_with_empty_key_raises_typeerror</i>. An empty key is now a
documented no-op instead of an error.</li>
</ul>
<p>A test that pins a broken behaviour passes while the bug exists and fails once the
code is fixed, which is the signal to invert it. Neither of the two tests above
used an "expected value" assertion, so neither could mask a regression elsewhere.
<p>Changing a signature also breaks the tests. Reordering the parameters of a
function is a breaking change for every caller, so the call sites inside
<i>tests/test_core.py</i> and this document must be updated together.
<h2>
Risks</h2>
There are several holes in this design. It is important to document them
clearly.
<p>The first is that the suite only checks the behaviour reachable through the public
functions. The <i>encoding</i> argument is now explicit in
<i>write</i>, <i>append</i>, <i>write_add</i>, <i>read_chaine</i> and <i>delete</i>,
so they are all UTF-8 regardless of the platform default.
<p>The second is that <i>base_json</i> writes to the process working directory, so
any future version of that function must keep a test isolating the working
directory, otherwise it will write <i>data.json</i> into the repository.
<p>The third is that <i>write_add</i> does not return the same shape on every path.
The two success paths return <i>(file, bool, contenu)</i>, while the empty key path
falls through to <i>(file, chaine, contenu)</i>, where the second element is the key
instead of a bool. The annotation <i>tuple[str, bool, float]</i> therefore
describes the two success paths only.
<i>test_write_add_with_empty_key_does_nothing</i> pins the current shape, so it must
change if the path is normalised.
<p>The fourth is that <i>write_add</i> has an <i>except</i> block that catches
<i>FileNotFoundError</i> and <i>json.JSONDecodeError</i> and then re-raises them
unchanged. It has no observable effect and can be removed.
<p>The fifth is that <i>read_chaine</i> prints the value and returns it, so the value
reaches the caller twice. <i>test_read_chaine_prints_value</i> pins the
<i>print</i>, which must be removed from the test if the <i>print</i> is ever removed
from the function.
<p>The sixth is that <i>read_chaine</i> and <i>delete</i> are not exported by
<i>easyjson/__init__.py</i>, so they are reachable only through
<i>easyjson.core</i>. <i>write</i>, <i>append</i>, <i>write_add</i> and
<i>base_json</i> are exported, so the module is inconsistent until they are added.
<p>The seventh is that <i>read_chaine</i> and <i>delete</i> have no return
annotation at all. They are covered by
<i>check_untyped_defs = true</i> in <i>pyproject.toml</i>, so <i>mypy</i> checks
their bodies, but their callers still see an <i>Any</i> return.
</body>
</html>