Unit Testing Guide for easyjson

easyjson project
Rev 1.1
2026 Sep 29

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.

Requirements

The overriding requirement is that every public function of easyjson.core 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.

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.

Test Layout

The test suite is a single file, with one class per tested function.
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 core
The 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()

Running the Tests

Both runners work, since the test file uses only unittest constructs.
uv run pytest               /* recommended */
python -m pytest -v        /* verbose, test names shown */
python -m unittest discover -s tests -v
Coverage is optional and requires pytest-cov.
uv run pytest --cov=src/easyjson --cov-report=term-missing
term-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.

Test Anatomy

Every tested function gets its own class, inheriting from unittest.TestCase.
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.

Assertions

The assertion methods used by the suite are the following.
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.

Isolation

setUp runs automatically before each test, tearDown after.  That is what gives every test an identical starting state.  The base class creates one temp folder and one file path per test.
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)

Verifying Errors

The pattern is always a with block around the call that is expected to fail.
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:
CallConditionException
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
json.JSONDecodeError inherits from ValueError.  The except blocks of write, append, read_chaine and delete catch ValueError, so they also catch a decoding error.  The except block of write_add catches FileNotFoundError and json.JSONDecodeError and re-raises both unchanged.

Behaviour, Not Code

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.

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.

pytest

pytest is a layer on top of unittest, everything that works here works there too.  The assertions are written with a bare assert, with no method call.
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.

Known Bugs

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.

Risks

There are several holes in this design.  It is important to document them clearly.

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.