This document describes how the easyjson package is tested. It covers the test layout, the conventions used by unittest, the way errors are verified, and the pytest equivalents. In this document, the term test means a single verifiable behaviour of a single function.
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.
The test dependencies are pytest, ruff and mypy, declared in the dev group of pyproject.toml. The test file itself imports nothing but the standard library and easyjson, so unittest remains usable without pytest installed.
pyproject.toml sets testpaths = ["tests"], therefore no path argument is needed when running the tests.
.github/workflows/ci.yml runs the suite on Python 3.9 to 3.13, and runs ruff check and mypy on Python 3.13 only, because mypy requires 3.10 or higher.
tests/ test_core.py src/ easyjson/ __init__.py core.py /* write, append, write_add, read_chaine, delete, base_json */ docs/ index.html /* This document */The class names are the function names suffixed with Tests.
WriteTests /* easyjson.write() */ AppendTests /* easyjson.append() */ WriteAddTests /* easyjson.write_add() */ ReadChaineTests /* easyjson.read_chaine() */ DeleteTests /* easyjson.delete() */ BaseJsonTests /* easyjson.base_json() */read_chaine and delete are not exported by easyjson/__init__.py, so the test file imports them through the module:
import easyjson from easyjson import coreThe public signatures under test are the following. Note that write_add, read_chaine and delete take the file path first and the key second.
write(file, contenue) append(file, contenue) write_add(file, chaine, contenue) read_chaine(file, chaine) delete(file, chaine) base_json()
uv run pytest /* recommended */ python -m pytest -v /* verbose, test names shown */ python -m unittest discover -s tests -vCoverage is optional and requires pytest-cov.
uv run pytest --cov=src/easyjson --cov-report=term-missingterm-missing 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 except branch can legitimately stay uncovered.
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"})
Three conventions structure the whole file. The class name is the tested
function name followed by Tests. Every method name starts with
test_, otherwise unittest skips it silently. Every method
receives self, which gives access to the assertions.
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.
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))The expected value always comes first, the actual value second. The reverse order makes a test that should fail pass.
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")addCleanup registers an action to run at the end of the test, even if the test fails. It is preferred over tearDown for the cleanup because it cannot be forgotten and it always runs. Two helpers read and write the JSON file under test.
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)base_json writes data.json in the current directory, so BaseJsonTests changes the working directory to the temp folder and restores it with a cleanup.
def setUp(self): super().setUp() previous = os.getcwd() self.addCleanup(os.chdir, previous) os.chdir(self.tmpdir.name)
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")If no exception is raised, the test fails. assertRaisesRegex also checks the error message.
with self.assertRaisesRegex(ValueError, "Invalid control character"): easyjson.write(self.path, "contenu\0")The exceptions actually produced by the package are:
| Call | Condition | Exception |
|---|---|---|
| write | folder does not exist | OSError |
| append | file does not exist | OSError |
| append | file is not valid JSON | json.JSONDecodeError |
| append | JSON is not a list | AttributeError |
| write_add | folder does not exist | OSError |
| write_add | file is not valid JSON | json.JSONDecodeError |
| write_add | JSON is not an object | TypeError |
| write_add | key is empty | none, it is a no-op |
| read_chaine | folder does not exist | OSError |
| read_chaine | file is not valid JSON | json.JSONDecodeError |
| read_chaine | key is absent | KeyError |
| read_chaine | JSON is not an object | TypeError |
| delete | folder does not exist | OSError |
| delete | file is not valid JSON | json.JSONDecodeError |
| delete | key is absent | KeyError |
| delete | JSON is not an object | TypeError |
| base_json | folder is not writable | OSError |
Each test must be able to fail on its own. If test_write_add_creates_key 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.
The test name must be enough to understand the failure without opening the file. test_append_raises_jsondecodeerror_on_invalid_json is better than test_append_2.
def test_write_returns_path_and_content():
result = easyjson.write(chemin, '{"b": 2}')
assert result == (chemin, '{"b": 2}')
Repeated tests collapse into a single test with parametrize.
@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}
setUp becomes a fixture, injected by argument name. tmp_path
replaces tempfile.TemporaryDirectory and provides a pathlib.Path.
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.
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.
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 tests/test_core.py and this document must be updated together.
The first is that the suite only checks the behaviour reachable through the public functions. The encoding argument is now explicit in write, append, write_add, read_chaine and delete, so they are all UTF-8 regardless of the platform default.
The second is that base_json 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 data.json into the repository.
The third is that write_add does not return the same shape on every path. The two success paths return (file, bool, contenu), while the empty key path falls through to (file, chaine, contenu), where the second element is the key instead of a bool. The annotation tuple[str, bool, float] therefore describes the two success paths only. test_write_add_with_empty_key_does_nothing pins the current shape, so it must change if the path is normalised.
The fourth is that write_add has an except block that catches FileNotFoundError and json.JSONDecodeError and then re-raises them unchanged. It has no observable effect and can be removed.
The fifth is that read_chaine prints the value and returns it, so the value reaches the caller twice. test_read_chaine_prints_value pins the print, which must be removed from the test if the print is ever removed from the function.
The sixth is that read_chaine and delete are not exported by easyjson/__init__.py, so they are reachable only through easyjson.core. write, append, write_add and base_json are exported, so the module is inconsistent until they are added.
The seventh is that read_chaine and delete have no return annotation at all. They are covered by check_untyped_defs = true in pyproject.toml, so mypy checks their bodies, but their callers still see an Any return.