Close remaining test-coverage gaps in sob
Status: Implemented (2026-08-01). Overall coverage 84%→87%; most
modules hit or exceeded target (see plan's Task 15 for final numbers).
abc.py (74%, all remaining lines are non-goal abstract stubs),
model.py (85%, target 90%+), and thesaurus.py (82%, target 85%+)
fell short of their per-module targets, and overall coverage (87%)
fell short of the ≥95% target — the remaining gaps are deep/low-value
branches (see plan for details). Four real bugs found and fixed along
the way: two hidden by a no-op doctest assertion, indent()'s
negative-stop sign error, Properties.__eq__ comparing the wrong
operand, and two related polymorphism-merge bugs in thesaurus.py.
Scope: src/sob/** (all 16 modules), tests/**
1. Goal
Bring every module in src/sob up from its current measured coverage to
as close to 100% as is meaningful, using real integration tests —
constructing actual sob model/property/meta/hooks objects and exercising
them end-to-end — rather than mocks or patched internals. This matches the
existing test suite's own convention: grep -rl "mock\|Mock\|monkeypatch"
tests/ src/ returns nothing today, and every existing test builds real
sob.Object/sob.Array/sob.Dictionary subclasses (see
tests/test_model.py, tests/test_version.py, tests/test_utilities.py).
This spec preserves that convention; no new test dependency (e.g.
pytest-mock, unittest.mock) should be introduced.
2. Current state (baseline)
Measured via hatch test --cover on 2026-08-01 (Python 3.10 leg):
Name Stmts Miss Cover
-------------------------------------------
src/sob/__init__.py 11 0 100%
src/sob/_datetime.py 28 2 93%
src/sob/_inspect.py 11 0 100%
src/sob/_io.py 19 4 79%
src/sob/_types.py 57 4 93%
src/sob/_utilities.py 28 3 89%
src/sob/abc.py 529 13 98%
src/sob/errors.py 59 7 88%
src/sob/hooks.py 110 60 45%
src/sob/meta.py 463 98 79%
src/sob/model.py 1308 176 87%
src/sob/properties.py 228 10 96%
src/sob/thesaurus.py 465 168 64%
src/sob/types.py 70 13 81%
src/sob/utilities.py 315 46 85%
src/sob/version.py 96 19 80%
-------------------------------------------
TOTAL 3797 623 84%
pyproject.toml sets fail_under = 70 — already passing overall, but
hooks.py (45%) and thesaurus.py (64%) are both individually below that
bar, and several modules have entire classes of behavior (error branches,
direct-construction paths, mapping-protocol methods) with zero exercise.
Target: every module ≥ 90%, hooks.py and thesaurus.py ≥ 85%,
overall ≥ 95%. Some lines (documented in §4) are genuinely low-value or
unreachable and are called out as explicit non-goals rather than chased.
3. Important finding: the doctest harness doesn't fail on doctest errors
Every test_doctest() function across the suite (e.g.
tests/test_utilities.py:13, tests/test_datetime.py:11) does:
def test_doctest() -> None:
doctest.testmod(utilities)
doctest.testmod() returns a TestResults(failed, attempted) tuple and
never raises on failure — it just prints a report to stdout. Since
nothing asserts on the return value, these test functions always pass
regardless of whether the docstring examples in the module actually work.
Confirmed by running hatch test --cover (33 passed, no failures reported)
versus running pytest directly with --doctest-modules (the flag already
configured in pyproject.toml under [tool.hatch.envs.hatch-test]
extra-args, which collects each doctest as its own pytest item):
src/sob/utilities.py::sob.utilities.suffix_long_lines FAILED
src/sob/_datetime.py::sob._datetime.str2datetime FAILED
2 failed, 49 passed
Two real, currently-failing doctests exist in the docstrings today:
sob.utilities.suffix_long_lines— the example output doesn't include the# noqa: E501suffix the function actually appends; looks like a stale docstring after a recent edit (see commits89e906a,c51259b,e3d58f0— all docstring-wrap fixes).sob._datetime.str2datetime—str2datetime("2023-10-01T12:00:00Z")is documented to return a UTC-awaredatetime, but on Python 3.10 it returns a naivedatetime(theZ-suffix branch differs becausedatetime.fromisoformatonly gainedZ-parsing in Python 3.11; on 3.10 it falls through to theiso8601.parse_datefallback, which the Python-3.10-only "incorrect UTC offset" correction then strips to naive). Since the test matrix covers 3.10–3.13, this doctest's expected output is only accurate on 3.11+.
Decision needed from user before implementation: should the plan (a)
fix test_doctest() in every test file to assert failed == 0 (e.g.
assert doctest.testmod(module).failed == 0), which will immediately turn
these two into real failures that must be fixed as a prerequisite, or (b)
leave the harness as-is and just note the two bugs separately? Recommended:
(a) — it's a one-line change per file, it's the highest-leverage single
fix in this whole effort, and leaving it disabled defeats the purpose of
raising coverage elsewhere. The two underlying docstring/behavior bugs it
exposes should be fixed as their own small prerequisite fixes (not silently
adjusted expectations) before the coverage-gap tests are added, so the
gap-filling tests start from a green baseline.
4. Non-goals
Do not chase coverage on:
abc.pyabstract-method stub bodies (passunder@abstractmethod, e.g. lines 470, 757, 824, 966, 993, 1018, 1048, 1070, 1106, 1175, 1188, 1195) — every concrete subclass overrides these; the stub body itself never executes in real usage and testing it would require calling the abstract method through a deliberately broken subclass, which tests nothing meaningful.TYPE_CHECKING-onlyassertstatements (e.g.model.py:1812) — dead at runtime by construction.model.py_marshal/_validateabstractpassbodies (148, 162, 166 area) — same reasoning asabc.py.- A handful of deeply-defensive
TypeError/exec-failure guards inmodel.py'sget_models_source(3412, 3482, 3491) that would require contriving malformed metadata objects bypassing all public constructors to trigger — flagged as low-priority, skip unless trivial.
5. Per-module test plan
Each subsection lists concrete, mock-free scenarios. Where a module already
has a test file, new tests extend it; naming follows the existing
test_<behavior> convention (see tests/test_model.py,
tests/test_types.py).
5.1 _datetime.py (93% → ~100%)
File: tests/test_datetime.py
- str2datetime raises TypeError for a non-str argument (parallel to
the existing test_raise_str2date_type_error) — covers line 59.
- str2date raises TypeError for a non-str argument — covers line 88.
- (Prerequisite, see §3) fix the str2datetime("...Z") docstring/behavior
mismatch on Python 3.10 before/alongside this work.
5.2 _io.py (79% → 100%)
File: tests/test_utilities.py (or a new tests/test__io.py)
- A file-like proxy whose read() raises io.UnsupportedOperation (and
has no readall) to hit the except UnsupportedOperation: pass branch,
followed by exhausting both method names — e.g. a class with read that
always raises, and no readall, confirming the final TypeError is
raised (f"{file!r} is not a file-like object") — covers lines 39-42.
- A plain object() (no seek/read/readall) passed to sob._io.read
to hit the TypeError path directly too.
5.3 _types.py (93% → 100%)
File: tests/test_types.py
- hash(sob.UNDEFINED) == 0 — covers line 41.
- hash(sob.NULL) == 0, str(sob.NULL) == "null",
sob.Null._marshal() is None — covers lines 102, 105, 109.
5.4 _utilities.py (89% → 100%)
File: tests/test_utilities.py
- Decorate a real function with sob._utilities.deprecated("message"),
call it, and assert (via pytest.warns(DeprecationWarning, match=...))
that the warning fires and the wrapped function's return value passes
through — covers line 34. (This one line also silently backs every
deprecated-wrapped alias across abc.py, properties.py, meta.py,
hooks.py, model.py, thesaurus.py, and utilities.py — e.g.
sob.hooks.Object, sob.meta.read, sob.properties.String — none of
which is currently invoked anywhere in the test suite. Once this line is
covered directly, calling a representative one or two of those aliases
under pytest.warns(DeprecationWarning) in their respective module's
test file is a cheap way to also confirm the aliases themselves are
wired correctly, though it isn't required to close this specific gap.)
- A proxy object with a non-str .url attribute →
get_readable_url raises TypeError — covers line 50.
- A proxy object with none of geturl/url/name → returns None —
covers line 61.
5.5 abc.py (98% → ~99%, remainder is §4 non-goals)
No new tests needed beyond what naturally falls out of exercising the
concrete classes elsewhere in this plan (abc.py's only non-non-goal gaps
are abstract stubs).
5.6 errors.py (88% → 100%)
File: tests/test_utilities.py or a new tests/test_errors.py
- Construct sob.errors.DeserializeError(data="bad", message="oops")
directly; assert .data, .message, repr(error) (message + "Could
not parse:\n..."), and str(error) == repr(error) — covers 57-59, 62,
68.
- append_exception_text: build a real exception with a strerror
attribute (e.g. OSError(1, "boom"), which sets .strerror) and confirm
the message is appended to .strerror — covers line 161.
- append_exception_text on an exception with empty .args (e.g.
Exception()) to hit the "not found" else branch — covers line 174.
5.7 types.py (81% → 100%)
File: tests/test_types.py
- sob.Types(str) (bare type, not wrapped in a sequence) — covers line 43.
- copy.copy(sob.Types([int, str])) — covers line 49.
- On a sob.MutableTypes instance: types[0] = float (__setitem__),
types.extend([bool]), del types[0] (__delitem__), types += [str]
(__iadd__), and new_types = types + [int] (__add__, returning a
fresh MutableTypes) — covers 123-124, 130, 133, 139-140, 145-149.
5.8 properties.py (96% → 100%)
File: tests/test_model.py or a new tests/test_properties.py
- has_mutable_types with a plain (non-Property) argument that is not a
type[abc.Property] subclass → TypeError — covers 66-72 (also hits
both branches of the isinstance(property_, abc.Property) check with a
Property instance vs. a Property subclass passed as a bare type).
- Attempt to reassign .types on a Property instance whose class defines
_types at the class level (e.g. sob.StringProperty().types = [int])
→ TypeError ("... .typesis immutable") — covers line 163.
- Assign an invalid (non-type, non-Property, non-None) value to.typeson a property whose_typesis *not* class-level-fixed (a baresob.Property().types = "not-a-type") →TypeError— covers line 172.
- Assign an invalid.versionsvalue (e.g.sob.Property().versions =
123) →TypeError` — covers lines 192, 196.
5.9 version.py (80% → 100%)
File: tests/test_version.py
sob.Version is currently only exercised indirectly through
version_model/property versions= args — no test constructs or compares
sob.Version instances directly. Add:
- sob.version._are_versions_compatible/_are_versions_equal via
sob.Version(equals="1.2") == "1.2.0" and == "1.2" (differing
precision) to cover the truncation/length-mismatch branches (lines 24,
31, 37-43).
- sob.version._version_string_as_tuple("not-a-version") (or
constructing sob.Version("not-a-version")) → ValueError — line 81.
- sob.Version(compatible_with=1.2) and sob.Version(compatible_with=
(1, 2)) (a bare float and a bare sequence, exercising the numeric and
sequence branches of _version_as_tuple) — lines 88, 94, 116-119.
- sob.version._version_as_tuple(object()) → TypeError — line 113.
- sob.Version(123) (non-str version_string) → TypeError — line 226.
- sob.Version("a==1,b==2") (two different specification prefixes in one
string) → ValueError — line 249.
- specification defaults to "" (verified: a default-built sob.Version
never leaves it None), so the RuntimeError at line 301 can't be
triggered through the constructor alone. specification is a plain
public attribute (no property/validation), so directly assigning
version.specification = None after construction and then calling
str(version) is normal use of the public interface, not a mock or
monkeypatch — use that to cover line 301.
5.10 utilities.py (85% → ~97%)
File: tests/test_utilities.py
- URL helpers: call the internal URL-splitting helper with a malformed URL
to hit its ValueError, and get_relative_url("https://a.com/x/y",
"https://a.com/x/z") plus a case with no shared prefix between
absolute and base URL — covers 389-393, 402-416.
- _align_indent("no-leading-space") — covers line 427.
- _split_long_comment_line with a short line (no wrap needed) — covers
line 473.
- split_long_docstring_lines: an already-uniformly-indented docstring
(covers 505, 510-511) and a docstring containing a """/''' literal
that closes on the same or later line, needing the suffix re-appended —
covers 540-548 (suffix_long_lines's quote-tracking loop).
- get_qualified_name: pass an unsupported type → TypeError (line 657);
pass a generic alias like list[int] to hit the __origin__/repr()
fallback (line 675); construct an object whose type truly can't resolve
a qualified name to hit the final TypeError (line 682).
- get_calling_module_name/get_calling_function_qualified_name: call
with depth deep enough to exceed the real stack to hit the
IndexError/short-stack None returns (745-746, 772, 775-776), and call
with a non-int depth → TypeError (line 772's guard).
- _repr_list([]), _repr_set(set()), _repr_dict({}) — the empty-
collection short-circuit branches ("[]", "set()", "{}") — covers
818-819, 842, 852.
- represent(SomeClass) (representing a bare type, not an instance) —
covers line 870.
- get_method: an object missing the requested method with no
default (so default stays sob.UNDEFINED) → re-raises AttributeError
(910); an object where the attribute exists but isn't callable, with and
without a default → returns default or raises — covers 914-920.
5.11 hooks.py (45% → ~90%+) — highest-value gap
File: tests/test_model.py or a new tests/test_hooks.py. Currently
zero test in the suite constructs ObjectHooks/ArrayHooks/
DictionaryHooks or calls any *_hooks function — model.py calls the
read-side internally on every operation (which is why 45% is already
covered passively), but nothing ever registers a hook, so the entire
write/get-writable/dispatch surface is dark.
- ObjectHooks(before_setattr=fn, after_setattr=fn) assigned via
sob.write_model_hooks(ObjectA, hooks); set an attribute on an instance
and assert both real (non-mock) callables fired with expected arguments
— covers ObjectHooks.__init__ (244-257) and the write_model_hooks
body (835-849).
- ArrayHooks(before_append=fn, after_append=fn) on sob.ArrayA;
.append(...) and assert hooks fired — covers 382-395.
- DictionaryHooks(before_setitem=fn, after_setitem=fn) on a Dictionary
subclass; d["k"] = v — covers 505-516.
- sob.read_model_hooks("not-a-model") → TypeError — covers 549-560.
- sob.get_writable_model_hooks(ObjectA) on a class with no hooks yet
assigned → returns a fresh ObjectHooks, confirmed idempotent via a
second sob.read_model_hooks(ObjectA) call returning the same object;
repeat on an instance; and sob.get_writable_model_hooks(42) →
TypeError — covers 626, 633, 639, 645, 665-704.
- sob.get_writable_object_hooks/get_writable_array_hooks/
get_writable_dictionary_hooks called directly on each container type —
covers 730, 756, 785.
- sob.get_model_hooks_type(ObjectA) → ObjectHooks (and same for
Array/Dictionary, both class and instance); sob.get_model_hooks_type
(str) → TypeError — covers 800-820.
- sob.write_model_hooks(ObjectA, sob.ArrayHooks()) (wrong hooks type) →
ValueError; sob.write_model_hooks("not-a-model", hooks) → TypeError
— rounds out 835-849.
5.12 meta.py (79% → ~93%)
File: tests/test_model.py / tests/test_version.py
- Assign a bare (non-iterable) type/Property to DictionaryProperty(
value_types=sob.StringProperty()) and ArrayProperty(item_types=str)
(not wrapped in a list) — covers 217-219, 271-273.
- Exercise sob.Properties mapping protocol directly on
sob.get_writable_object_meta(ObjectA).properties: .pop("string"),
.popitem(), .setdefault("x", sob.StringProperty()),
.get("missing"), .clear(), copy.copy(properties), repr(...),
equality between two independently-built Properties with identical
items, properties["bad"] = "not-a-property" → TypeError,
.setdefault("x", 5) → TypeError, .update({...}) with a plain
dict — covers 304-318, 370, 376, 385, 396, 403, 406, 427, 430, 443-445,
450, 453-455.
- Define a brand-new sob.Object subclass with no metadata ever assigned
and call sob.read_model_meta(NewClass) → None — covers 488-491.
- sob.get_writable_object_meta("not-a-model") and sob.
get_writable_object_meta(int) → TypeError — covers 610-619.
- sob.write_model_meta(ObjectA, sob.ArrayMeta()) → ValueError;
sob.write_model_meta("not-a-model", None) → TypeError — covers 714,
716-717, 750, 754-755.
- sob.meta._read_object_properties/_read_object_property_names on a
class with no metadata → None returns — covers 784, 793, 802.
- sob.meta.pointer(instance, "/foo/bar") then re-call with
pointer_=None to hit only the getter; sob.meta.pointer(123) →
TypeError; sob.meta.url(instance, "https://example.com") — covers
883-886, 962-963.
- Extend test_version.py's pattern to a versioned Array/
Dictionary (not just Object): give ArrayProperty(item_types=...)
or DictionaryProperty(value_types=...) a nested Property with
versions=[...], call sob.meta.version_model(container_instance,
"test-specification", "1.0") directly, and assert the container's
types were filtered — covers 1001, 1022-1097, 1114-1149. Also
deliberately trigger sob.errors.VersionError by setting a
to-be-removed versioned property's value before calling version_model
with an incompatible version. sob.meta.version_model(42, "spec", "1.0")
→ TypeError — covers 1182, 1185-1187.
- sob.meta._copy_model_meta_to with a non-abc.Model source (module-
internal, acceptable to import directly since it's the unit under test)
— covers 1248, 1274.
5.13 model.py (87% → ~95%)
File: tests/test_model.py. This is the largest module; group tests by
the affected class/function.
- Model/format guard: sob.Array(123), sob.Dictionary(123) →
TypeError (line 148).
- Array protocol: build arr = ArrayA([ObjectA(string="a")]) and
exercise arr.append(...), arr[0] = ObjectA(string="b"), del
arr[0], arr.sort(), arr.extend([...]), reversed(arr), arr.pop(),
arr.remove(...), copy.copy(arr), repr(arr), str(arr), arr ==
ArrayA() (type and length mismatch), plus real ArrayHooks
(before_setitem/after_setitem/before_marshal/after_marshal/
before_validate) assigned via sob.write_model_hooks and a
sob.validate(arr) call against an invalid item to hit the
ValidationError raise (lines 391, 414, 436-597, 652-673).
- Dictionary protocol: build d = DictionaryA({"a": ObjectA(...)})
(mirroring MemberDictionaryA from test_version.py) and exercise
d.update({...}, [("c", ...)], kw=...), d.setdefault(...), d.pop(...),
d.popitem(), "a" in d, list(reversed(d)), copy.copy(d)/
copy.deepcopy(d), d == DictionaryA(), construct from a tuple-iterable
instead of a dict, and real DictionaryHooks on __setitem__/
_marshal (lines 935-1343).
- Object extras/copy-init: construct ObjectA(other_object_with_a_
type-incompatible_property) to hit _copy_init's exception-
augmentation path; obj["extra_key"] = "value", read it back, and
del obj["extra_key"] to hit the _extra (non-metadata-attribute)
branches of __setitem__/__getitem__/__delitem__; real
ObjectHooks(before_setitem=fn) on obj["string"] = "x" (lines
1600-1994).
- Module-level marshal(): call directly (not just via Object.
_marshal) on a raw dict, list, set, Decimal, datetime, date,
bytes, and an unsupported object() (→ ValueError), plus marshal(1,
types=(str,)) (→ TypeError) (lines 2013-2129).
- unmarshal(): sob.unmarshal({"string": "a"}, types=ObjectA)
(single type, not iterable); sob.unmarshal((x for x in [1, 2]))
(generator input); sob.unmarshal(None); a real before_unmarshal hook
registered on ObjectA (lines 2171-2381).
- serialize/deserialize: real before_serialize/after_serialize
hooks on ObjectA; sob.deserialize(b'{"a": 1}') (bytes path);
sob.deserialize(123) → TypeError (lines 2502-2594).
- validate(): sob.validate(ObjectA(), types=(ObjectA,)) (bare type,
not Property) (line 2635).
- replace_model_nulls: arr = ArrayA([sob.NULL]);
sob.replace_model_nulls(arr); assert arr[0] is None — Array-item NULL
replacement is untested today (only Object-property NULLs are) (line
3012).
- get_model_from_meta/get_models_source: extend
test_get_model_from_meta_regression with a DictionaryMeta-based
class and docstring=/pre_init_source= arguments, and include it in
the combined get_models_source(...) call (lines 3061-3520 area).
5.14 thesaurus.py (64% → ~88%) — second-highest-value gap
File: tests/test_thesaurus.py (currently only 28 lines: one regression
test that round-trips tests/static-data/thesaurus.json through
Thesaurus(...).get_module_source() against a golden file). Thesaurus
infers an sob data model from example JSON data (rather than a
schema); Synonyms is the set-like collection of interchangeable values
used to infer one property's type, Thesaurus is the dict-like collection
of named Synonyms keyed by pointer/identifier. The single flat fixture
(bools/strings/one array-of-arrays-of-int) never exercises polymorphism-
merging, date/base64/bytes detection, error paths, or most of the
Synonyms/Thesaurus mapping/set protocol — hence the large gap. Add a
second, deliberately richer static fixture
(tests/static-data/thesaurus_polymorphic.json, or extend the existing
one) containing: a base64 string, a plain date string, a datetime string,
a key that is sometimes an object and sometimes an array (to trigger the
type-conflict error path), a key whose synonym values include an empty
object {} alongside populated ones, and a key that is always null.
Then:
- Synonyms construction/inference (462, 540, 549, 691-698, 800):
Synonyms().add(object()) → TypeError (line 540); Synonyms([1.5,
2]) keeps the inferred type as float, not int (line 549); a
Synonyms built from base64/date/datetime strings infers bytes/
date/datetime respectively (691-698, 462); a shared key whose values
are always None falls back to an untyped Property(name=key) (800).
- Synonyms file-like input (71-76): add a real io.StringIO/
io.BytesIO containing JSON as a Synonyms/Thesaurus value and
confirm it decodes via both the str and bytes branches of _read.
- Synonyms mutation/set-algebra (540-579, 630-681): build two real
Synonyms from different fixture groups and exercise .discard(item),
.remove(item), .pop(), &, ^, -, <=, <, >, >=, ==,
in, .isdisjoint(...), copy.copy(...), copy.deepcopy(...).
- Synonyms.get_models guards (908, 913-914, 922): synonyms.
get_models(pointer, name=123) (non-callable name) → TypeError;
Synonyms([None]).get_models(pointer) (nothing inferable) →
RuntimeError.
- Metadata-merge chain (_update_types, _update_array_meta,
_update_dictionary_meta, _update_object_meta, the three
_update_*_class_from_meta functions, and _get_models_from_meta's
memo-hit branches — lines 93-111, 129-143, 161-175, 194-232, 254-269,
286-291, 311-318, 340-358, 412-413, 417): these only run when the same
JSON pointer produces metadata twice in one .get_models() call.
- The error branch is reachable through the public API: the mixed
object/array fixture key above should make .get_models()/
.get_module_source() raise TypeError when the same pointer
resolves to incompatible container kinds — assert with
pytest.raises(TypeError).
- The success merge path (two ObjectMeta/ArrayMeta/
DictionaryMeta at the same pointer with the same container kind
but different properties/types) isn't reachable through nested
fixture data without contrived recursive pointer collisions; test it
by calling the module-internal _update_object_meta/
_update_object_class_from_meta (and _array/_dictionary
counterparts) directly with two real, hand-built sob.meta instances
and two real classes from sob.model.get_model_from_meta, then assert
the merged metadata/class reflects the union.
- get_class_meta_attribute_assignment_source (989-996): call
directly with a real sob.meta.ArrayMeta/ObjectMeta that sets a
non-default attribute (e.g. item_types) and assert the generated
source string.
- Thesaurus mapping/set protocol (1124, 1146, 1160-1211): build a
real Thesaurus from fixture data and exercise .popitem(),
.update(new=[...]), .setdefault("k", [...]), thesaurus["new"]
(auto-vivifying __getitem__) vs. thesaurus["existing"], "k" in
thesaurus, .keys(), .values(), t1 == t2, copy.copy(t),
reversed(t), copy.deepcopy(t), and t1 += t2 / t1 + t2 (two real
Thesaurus built from disjoint fixture subsets).
- Thesaurus.get_module/save_module (1275-1276, 1301-1306):
thesaurus.get_module() returns an executed ModuleType — assert the
generated classes are real, importable attributes on it. The existing
regression test's save_module call never exercises the actual file-
write branch because the golden file already exists on disk; add a
tmp_path-based test that calls thesaurus.save_module(tmp_path /
"model.py") against a path guaranteed not to exist, and confirm the
file is written and re-importable.
6. Testing conventions to follow
- No mocks. Every new test builds real
sobobjects (subclasses ofsob.Object/sob.Array/sob.Dictionary, realProperty/Meta/Hooksinstances) and calls real functions — matching 100% of existing tests. - Real callables for hooks, not
unittest.mock.Mock— e.g. a closure that appends to a list the test asserts against afterward, as already done implicitly elsewhere in the codebase's style (plain functions/ classes as fixtures, e.g.HTTPResponseProxy1intest_utilities.py). - Follow the file's existing structure:
from __future__ import annotations, atest_doctest()per module (once fixed per §3), plaintest_*functions (no test classes), and theif __name__ == "__main__": pytest.main([__file__, "-s", "-vv"])trailer. - Where a module has no test file yet (none currently — every
src/sob/*module maps to atests/test_*.pyexcept the underscore-prefixed internals, which are tested from withintests/test_utilities.py/tests/test_types.py), keep using that existing file rather than fragmenting into more files, matching current 1:1-ish structure. - Regression-style tests (
tests/regression-data/) are the established pattern for "build once, compare generated output" cases (seetest_thesaurus,test_get_model_from_meta_regression,test_serialization_regression) — reuse this pattern for newthesaurus.pyandget_models_sourcecoverage rather than inventing a new fixture mechanism. - Run
hatch test -- tests/test_<module>.pyper file during development;hatch test --cover(orhatch run hatch-test.py3.10:coverage report -m) to confirm line-level coverage before/after.
7. Acceptance criteria
hatch test -cpasses on all four Python versions (3.10–3.13) with thetest_doctest()fix from §3 applied (i.e. it actually fails if a docstring example breaks).coverage report -mshows every module ≥ 90%, withhooks.pyandthesaurus.py≥ 85%, and overall ≥ 95%.- No new test uses
unittest.mock,pytest-mock, or monkeypatches anysobinternals — all assertions exercise real, constructed objects. - Lines listed under §4 (Non-goals) remain uncovered by design and are not flagged as regressions in future coverage diffs.