Audit 007 — Test Behavior Assertions¶
Date: 2026-03-30
Scope: All test files in tests/ — checked against "Assert behavior, not
type" (Rule 2) and "Never assert on private attributes" (Rule 3) from
research-testing-best-practices.md §4.1.
Method: Systematic grep + agent-assisted sampling of all 39 test files
(~1,045 test functions). Cross-referenced against anti-pattern counts in
research-testing-best-practices.md §2.
Summary¶
| Anti-Pattern | Count | Status vs. Research Doc |
|---|---|---|
| No assertions | 0 | Fixed (was ~35) |
Unconstrained MagicMock() |
0 | Fixed (was ~67) |
isinstance as sole assertion |
12 tests | Open |
| Private attribute assertions | ~75 assertions across 22 locations | Open |
Good news: Two previously-identified anti-patterns are now clean:
- No-assertion tests: 0 found (was ~35). Every test has
assertorpytest.raises. - Unconstrained MagicMock: 0 found (was ~67). All 69
MagicMock()calls usespec=.
Category A: isinstance as sole/primary assertion (Rule 2)¶
Tests that verify the type of the return value but never check that it behaves correctly (has the right data, delegates properly, etc.). A refactoring that returns the wrong subclass or a broken instance would pass.
| # | Location | Severity | Summary |
|---|---|---|---|
| A-1 | test_otel.py:82 |
Medium | test_hooks_unpack_into_observe — only asserts isinstance(observed, ObservedStore). Does not verify hooks are wired or any operation works. |
| A-2 | test_otel.py:99-100 |
Medium | test_otel_observe_returns_observed_store — two isinstance checks (ObservedStore, Store), no behavioral assertion. |
| A-3 | test_otel.py:105 |
Medium | test_otel_observe_with_env — same pattern, isinstance only. |
| A-4 | test_arrow.py:105 |
Medium | test_pyarrow_fs_factory — only asserts isinstance(result, pafs.PyFileSystem). Never uses the filesystem object. |
| A-5 | test_proxy.py:228 |
Medium | test_native_path — only asserts isinstance(path, str). Should assert the path value. |
| A-6 | test_proxy.py:246 |
Medium | test_child_returns_proxy — only asserts isinstance(child, _TestProxy). Should also verify the child delegates correctly. |
| A-7 | test_registry.py:50 |
Medium | test_returns_store — only asserts isinstance(reg.get_store("main"), Store). Should verify the store has correct root/backend. |
| A-8 | test_backend_sqlblob.py:645 |
Medium | test_unwrap_engine — only asserts isinstance(engine, sa.Engine). |
| A-9 | test_backend_sqlquery.py:557 |
Medium | test_unwrap_engine — same pattern as A-8. |
| A-10 | test_backend_sqlblob.py:755 |
Medium | test_empty_path_for_folder_op — only asserts isinstance(result, bool). Should assert the value for an empty-path root folder. |
| A-11 | test_batch.py:104 |
Medium | test_signature_returns_type — parametrized but each case only does isinstance(func(store, args), expected_type). Never inspects result content. |
| A-12 | test_cache.py:158 |
Low | test_returns_cache — isinstance(result, CachedStore) + isinstance(result, Store). Type-only, should verify caching behavior. |
Category B: Assertions on private attributes (Rule 3)¶
Tests that will break if internals are renamed/refactored even when behavior is unchanged. ~75 individual assertions across 22 locations.
| # | Location | Severity | Summary |
|---|---|---|---|
| B-1 | test_registry.py:61,63,70,76,78,85,139,156,178 |
Medium | 9 assertions on reg._backends (length checks). Should test via public API (e.g., verify get_store() returns same backend identity, or close() makes stores unusable). |
| B-2 | test_registry.py:94 |
Medium | reg.get_store("main")._owns_backend is False. |
| B-3 | test_store_child.py:57 |
Medium | parent.child(subpath)._root == expected. Could verify via observable behavior (e.g., child reads from correct prefix). |
| B-4 | test_store_child.py:82,90-91 |
Medium | ._backend is backend and ._root/._backend identity checks. |
| B-5 | test_store_child.py:116-117 |
Medium | ._owns_backend checks on parent and child. |
| B-6 | test_cache.py:111 |
Low | len(mcache._data) == 2. Tests internal data structure size instead of observable behavior. |
| B-7 | test_cache.py:163 |
Medium | cache(store)._ttl == 300.0. Tests internal TTL field. |
| B-8 | test_cache.py:168 |
Medium | cache(store, cache_backend=backend)._cache is backend. Tests internal field identity. |
| B-9 | test_cache.py:406 |
Low | child._max_entries == 2. Internal config field. |
| B-10 | test_arrow.py:75,92-93,499-500,509-510,534 |
Medium | 8 assertions on ._store, ._materialization_threshold, ._write_spill_threshold, ._native_fs, ._native_path_fn. |
| B-11 | test_proxy.py:50,53 |
Medium | proxy._backend is inner._backend and proxy._owns_backend is False. |
| B-12 | test_config.py:313,329,698-700,721,729,749 |
Medium | 7 assertions on ._host_key_policy, ._retry across SFTP/S3/S3PyArrow backends. |
| B-13 | test_depth_listing.py:330,338,345,352 |
Low | sftp_stub._sftp.listdir_attr.call_count. Reaches through to mock internals of a private attribute. |
| B-14 | backends/test_sftp.py:508-516,525,723,1189 |
Medium | 8 assertions on ._sftp_path(), ._resolved_host_keys, ._host_key_policy, ._tofu_keys_path. |
| B-15 | backends/test_azure.py:235,239,274,302,456,462 |
Medium | 6 assertions on ._max_concurrency, ._azure_path(), ._hns, ._resolve_credential(). |
| B-16 | backends/test_s3.py:136,170,203,767 |
Medium | 4 assertions on ._endpoint_url, ._tls_ca_bundle, ._digest_from_head_response(). |
| B-17 | backends/test_s3_pyarrow.py:135,169,192 |
Medium | 3 assertions on ._endpoint_url, ._tls_ca_bundle. |
| B-18 | backends/test_http.py:449,749,771 |
Low | ._context.check_hostname, ._head_blocked. Internal state checks. |
| B-19 | test_coverage_gaps.py:102 |
Low | store._root == "a/b/c". Internal root field. |
| B-20 | test_observe.py:423 |
Low | _store.log.name. Semi-internal logger name check. |
| B-21 | test_ping.py:212 |
Low | backend._base_path. Reaches into private attribute inside mock call assertion. |
| B-22 | test_backend_sqlblob.py:884 |
Low | SQLBlobBackend._glob_to_like(pattern). Tests a private static method directly. |
Observations¶
- Mock spec cleanup is complete — all 69
MagicMockcalls usespec=. - No-assertion tests are gone — every test function has at least one
assertorpytest.raises. isinstance-only tests are low-hanging fruit — 12 tests could be strengthened by adding one behavioral assertion each.- Private attribute assertions are the largest remaining debt — ~75
assertions across the codebase. Many test internals that arguably should
have public observables (registry backend count, cache TTL, store root).
Some (
._sftp_path(),._azure_path(),._glob_to_like()) test private helper methods directly — these are the most fragile. - Mutation testing showed no surviving mutants — this suggests the behavioral coverage is decent despite the assertion style issues. The risk is refactoring fragility, not missed bugs.