Turn some parametrized unit tests into nominal property-based tests
Marco Ricci

Marco Ricci commited on 2026-07-03 22:21:41
Zeige 10 geänderte Dateien mit 318 Einfügungen und 144 Löschungen.


Introduce a converter function for `pytest.mark.parametrize` calls to
`hypothesis.example` calls.  Further use this converter function to turn
some parametrized unit tests into nominal property-based hypothesis
tests.  We use the same call signature as `pytest.mark.parametrize` for
our converter function so that transition to hypothesis-managed test
functions is very easy to write (just change the decorator name).  We
also stick to the obvious cases only—things that should be
property-based tests but are currently parametrized tests, likely
because we haven't yet made the effort or don't yet know how to write
suitable hypothesis strategies.

(Our implementation explicitly avoids a caveat with the hypothesis
strategy `st.nothing`.  Background at
https://github.com/HypothesisWorks/hypothesis/issues/4774 .  Because we
rely on certain machinery from hypothesis, we adjust our minimum
hypothesis version.  Since we're here, we also adjust the pytest
version, reflecting our actual use.)

We also add TODOs for non-trivial future work, such as consolidating
work across modules or adding new testing machinery.
... ...
@@ -70,11 +70,15 @@ dev = [
70 70
     # the extra, and accept that for PyPy environments, `hatch test -p`
71 71
     # will not be able to distinguish logical and physical CPU core
72 72
     # counts.
73
+    #
74
+    # We use `hypothesis.is_hypothesis_test`, which was introduced in
75
+    # 6.131.0, and `exc_type` for `pytest.importorskip`, which was
76
+    # introduced in 8.2.0.
73 77
     "coverage[toml] >= 7.4",
74 78
     "coverage-enable-subprocess >= 1.0",
75
-    "hypothesis >= 6.0",
79
+    "hypothesis >= 6.131.0",
76 80
     "packaging",
77
-    "pytest >= 8.1",
81
+    "pytest >= 8.2",
78 82
     "pytest-randomly >= 3.15",
79 83
     "pytest-xdist >= 3.6.0",
80 84
     'pytest-xdist[psutil] >= 3.6.0; platform_python_implementation != "PyPy"',
... ...
@@ -378,9 +382,9 @@ default-args = ['src', 'tests']
378 382
 dependencies = [
379 383
     "coverage[toml] >= 7.4",
380 384
     "coverage-enable-subprocess >= 1.0",
381
-    "hypothesis >= 6.0",
385
+    "hypothesis >= 6.131.0",
382 386
     "packaging",
383
-    "pytest >= 8.1",
387
+    "pytest >= 8.2",
384 388
     "pytest-randomly >= 3.15",
385 389
     "pytest-xdist >= 3.6.0",
386 390
     'pytest-xdist[psutil] >= 3.6.0; platform_python_implementation != "PyPy"',
... ...
@@ -21,11 +21,14 @@ import importlib
21 21
 import importlib.util
22 22
 import math
23 23
 import sys
24
-from typing import TYPE_CHECKING
24
+from collections.abc import Sequence
25
+from typing import TYPE_CHECKING, Callable
25 26
 
26 27
 import hypothesis
27 28
 import hypothesis.errors
29
+from _pytest import mark as pytest_mark
28 30
 from hypothesis import strategies
31
+from typing_extensions import assert_type
29 32
 
30 33
 from derivepassphrase import _types
31 34
 from tests import data, machinery
... ...
@@ -33,7 +36,7 @@ from tests import data, machinery
33 36
 __all__ = ()
34 37
 
35 38
 if TYPE_CHECKING:
36
-    from typing_extensions import Any
39
+    from typing_extensions import Any, TypeIs
37 40
 
38 41
 
39 42
 # Hypothesis settings management
... ...
@@ -325,3 +328,191 @@ def smudged_vault_test_config(
325 328
                 service[key] = draw(strategies.sampled_from(falsy_no_zero))
326 329
     hypothesis.assume(obj != conf.config)
327 330
     return data.VaultTestConfig(obj, conf.comment, conf.validation_settings)
331
+
332
+
333
+# Hypothesis decorators
334
+# =====================
335
+
336
+
337
+def _is_paramset(
338
+    seq: Sequence[pytest_mark.ParameterSet] | Sequence[object], /
339
+) -> TypeIs[Sequence[pytest_mark.ParameterSet]]:
340
+    return all(
341
+        isinstance(argvalue, pytest_mark.ParameterSet) for argvalue in seq
342
+    )
343
+
344
+
345
+def _get_id(
346
+    argvalue: object,
347
+    ids: Sequence[str | None] | Callable[[Any], str | None] | None,
348
+    i: int,
349
+) -> str | None:
350
+    if (
351
+        isinstance(argvalue, pytest_mark.ParameterSet)
352
+        and argvalue.id is not None
353
+    ):
354
+        return argvalue.id
355
+    if callable(ids):
356
+        result = ids(argvalue)
357
+        return result if result is not None else None
358
+    if isinstance(ids, Sequence):
359
+        result = ids[i]
360
+        return result if result is not None else None
361
+    assert_type(ids, None)
362
+    return None
363
+
364
+
365
+def _get_examples_data_and_ids(
366
+    *,
367
+    argnames: str | Sequence[str],
368
+    argvalues: Sequence[pytest_mark.ParameterSet] | Sequence[object],
369
+    ids: Sequence[str | None] | Callable[[Any], str | None] | None,
370
+) -> tuple[list[dict[str, Any]], list[str | None]]:
371
+    names = (
372
+        tuple(argnames.split(","))
373
+        if isinstance(argnames, str)
374
+        else tuple(argnames)
375
+    )
376
+    examples_data: list[dict[str, Any]] = []
377
+    final_ids: list[str | None] = []
378
+    k = len(names)
379
+
380
+    if _is_paramset(argvalues):
381
+        for i, argvalue in enumerate(argvalues):
382
+            if len(argvalue.values) != k:  # pragma: no cover [failsafe]
383
+                msg = f"not a {k}-tuple: {argvalue!r}"
384
+                raise ValueError(msg)
385
+            examples_data.append(dict(zip(names, argvalue.values)))
386
+            final_ids.append(_get_id(argvalue, ids, i))
387
+    else:
388
+        for i, argvalue in enumerate(argvalues):
389
+            if k > 1:
390
+                if (
391
+                    not isinstance(argvalue, Sequence) or len(argvalue) != k
392
+                ):  # pragma: no cover [failsafe]
393
+                    msg = f"not a {k}-tuple: {argvalue!r}"
394
+                    raise ValueError(msg)
395
+                examples_data.append(dict(zip(names, argvalue)))
396
+            else:
397
+                examples_data.append({names[0]: argvalue})
398
+            final_ids.append(_get_id(argvalue, ids, i))
399
+
400
+    return examples_data, final_ids
401
+
402
+
403
+def explicit_examples(
404
+    argnames: str | Sequence[str],
405
+    argvalues: Sequence[pytest_mark.ParameterSet] | Sequence[object],
406
+    ids: Sequence[str | None] | Callable[[Any], str | None] | None = None,
407
+    *,
408
+    settings: hypothesis.settings | None = None,
409
+) -> Callable[[Callable[..., None]], Callable[..., None]]:
410
+    """Decorate a function to take explicit (hypothesis) examples.
411
+
412
+    Using the same signature as [`pytest.mark.parametrize`][], we
413
+    replicate the effect of decorating the function with multiple
414
+    [`hypothesis.example`][] calls.  This is useful in particular if the
415
+    exact number of examples isn't known statically.  If the decorated
416
+    function is not already a hypothesis test, we also decorate the
417
+    function with [`hypothesis.given`][] and [`hypothesis.settings`][],
418
+    in a manner that only ever runs the explicit examples.  One obvious
419
+    way of writing the (sentinel) strategy to be passed to
420
+    [`hypothesis.given`][] actually triggers
421
+    [`hypothesis.errors.Unsatisfiable`][], so we are careful to avoid
422
+    that ([Hypothesis bug #4774][H4774]).
423
+
424
+    [H4774]: https://github.com/HypothesisWorks/hypothesis/issues/4774
425
+
426
+    Args:
427
+        argnames:
428
+            A comma-separated list of argument names, or a sequence of
429
+            argument name strings.
430
+        argvalues:
431
+            A sequence of realizations for the named arguments.  If
432
+            only one argument name is given, then each realization must
433
+            be a single value.  Otherwise, each realization must be a
434
+            (correctly sized) tuple of argument values.
435
+        ids:
436
+            An optional sequence of id strings to use for the
437
+            respective realization, or a callable that takes the
438
+            realization and returns an id string or `None`, or `None`.
439
+            If `None` or if the callable returns `None`, and if
440
+            additionally the argvalue is a pytest parameter set that
441
+            has its `id` set, then use that `id`; else fall back to
442
+            auto-generated IDs.
443
+
444
+    Returns:
445
+        A decorator that does the equivalent of
446
+
447
+            @hypothesis.example(
448
+                argnames[0]=argvalues[0][0],
449
+                argnames[1]=argvalues[0][1],
450
+                ...
451
+            )
452
+            @hypothesis.example(
453
+                argnames[1]=argvalues[1][0],
454
+                argnames[1]=argvalues[1][1],
455
+                ...
456
+            )
457
+            ...
458
+            @hypothesis.example(
459
+                argnames[0]=argvalues[-1][0],
460
+                argnames[1]=argvalues[-1][1],
461
+                ...
462
+            )
463
+            def f(...) -> ...: ...
464
+
465
+        and, if not already a hypothesis test, prepends
466
+
467
+            @hypothesis.given(
468
+                argnames[0]=strategies.just(argvalues[0][0]),
469
+                argnames[1]=strategies.just(argvalues[0][1]),
470
+                ...
471
+            )
472
+
473
+    Warning:
474
+        Because of the way [`pytest.mark.parametrize`][] and
475
+        [`hypothesis.example`][] work, having multiple
476
+        `explicit_examples` decorators behaves differently from
477
+        multiple [`pytest.mark.parametrize`][] decorators, and
478
+        is not drop-in compatible.  `explicit_examples` adds
479
+        *more examples on the same argument names*, whereas
480
+        [`pytest.mark.parametrize`][] adds *more argument names
481
+        and values to the same set of existing examples*!
482
+
483
+    """
484
+    if not argnames or not argvalues:  # pragma: no cover [failsafe]
485
+        msg = "no explicit examples given"
486
+        raise ValueError(msg)
487
+
488
+    examples_data, final_ids = _get_examples_data_and_ids(
489
+        argnames=argnames, argvalues=argvalues, ids=ids
490
+    )
491
+
492
+    def decorator(f: Callable[..., None], /) -> Callable[..., None]:
493
+        ret = f
494
+        for i, arg in reversed(list(enumerate(examples_data))):
495
+            ex_id = final_ids[i]
496
+            ex = hypothesis.example(**arg)
497
+            ex = (
498
+                ex.via("id={!r}".format(str(ex_id)))  # noqa: UP032
499
+                if ex_id is not None
500
+                else ex
501
+            )
502
+            ret = ex(ret)
503
+        if not hypothesis.is_hypothesis_test(
504
+            f
505
+        ):  # pragma: no branch [external]
506
+            kwargs = {
507
+                k: strategies.just(v) for k, v in examples_data[0].items()
508
+            }
509
+            given = hypothesis.given(**kwargs)
510
+            ret = given(ret)
511
+            new_settings = hypothesis.settings(
512
+                parent=settings,
513
+                phases=[hypothesis.Phase.explicit, hypothesis.Phase.reuse],
514
+            )
515
+            ret = new_settings(ret)
516
+        return ret
517
+
518
+    return decorator
... ...
@@ -389,6 +389,9 @@ class TestUserConfigurationFileOther:
389 389
 class TestSSHAgentAvailability:
390 390
     """Tests concerning the availability of the SSH agent."""
391 391
 
392
+    # TODO(the-13th-letter): Decouple tests from exact knowledge of how
393
+    # to unsupport a certain socket provider.  Coordinate with
394
+    # tests.test_derivepassphrase_ssh_agent.test_000_basic.TestConstructorFailures.
392 395
     def _unsupport_provider_and_force_use(
393 396
         self,
394 397
         provider: _types.BuiltinSSHAgentSocketProvider,
... ...
@@ -12,6 +12,7 @@ import types
12 12
 from typing import TYPE_CHECKING
13 13
 
14 14
 import click.shell_completion
15
+import hypothesis
15 16
 import pytest
16 17
 from typing_extensions import Any
17 18
 
... ...
@@ -21,6 +22,7 @@ from derivepassphrase._internals import (
21 22
     cli_machinery as cli_machinery,  # noqa: PLC0414
22 23
 )
23 24
 from tests import data, machinery
25
+from tests.machinery import hypothesis as hypothesis_machinery
24 26
 from tests.machinery import pytest as pytest_machinery
25 27
 
26 28
 if TYPE_CHECKING:
... ...
@@ -232,7 +234,7 @@ class Parametrize(types.SimpleNamespace):
232 234
             ),
233 235
         ],
234 236
     )
235
-    COMPLETABLE_SUBCOMMANDS = pytest.mark.parametrize(
237
+    COMPLETABLE_SUBCOMMANDS = hypothesis_machinery.explicit_examples(
236 238
         ["command_prefix", "incomplete", "completions"],
237 239
         [
238 240
             pytest.param(
... ...
@@ -249,7 +251,7 @@ class Parametrize(types.SimpleNamespace):
249 251
             ),
250 252
         ],
251 253
     )
252
-    COMPLETION_FUNCTION_INPUTS = pytest.mark.parametrize(
254
+    COMPLETION_FUNCTION_INPUTS = hypothesis_machinery.explicit_examples(
253 255
         ["config", "comp_func", "args", "incomplete", "results"],
254 256
         [
255 257
             pytest.param(
... ...
@@ -377,7 +379,7 @@ class Parametrize(types.SimpleNamespace):
377 379
             ),
378 380
         ],
379 381
     )
380
-    SERVICE_NAME_COMPLETION_INPUTS = pytest.mark.parametrize(
382
+    SERVICE_NAME_COMPLETION_INPUTS = hypothesis_machinery.explicit_examples(
381 383
         ["config", "key", "incomplete", "completions"],
382 384
         [
383 385
             pytest.param(
... ...
@@ -525,13 +527,19 @@ class Parametrize(types.SimpleNamespace):
525 527
                 id="del_partial_specific",
526 528
             ),
527 529
         ],
530
+        settings=hypothesis.settings(
531
+            parent=hypothesis.settings(),
532
+            suppress_health_check=[
533
+                hypothesis.HealthCheck.function_scoped_fixture
534
+            ],
535
+        ),
528 536
     )
529 537
     SERVICE_NAME_EXCEPTIONS = pytest.mark.parametrize(
530 538
         "exc_type", [RuntimeError, KeyError, ValueError]
531 539
     )
532 540
     INCOMPLETE = pytest.mark.parametrize("incomplete", ["", "partial"])
533 541
     CONFIG_SETTING_MODE = pytest.mark.parametrize("mode", ["config", "import"])
534
-    COMPLETABLE_ITEMS = pytest.mark.parametrize(
542
+    COMPLETABLE_ITEMS = hypothesis_machinery.explicit_examples(
535 543
         ["partial", "is_completable"],
536 544
         [
537 545
             ("", True),
... ...
@@ -756,6 +764,7 @@ class TestCLICompletabilityHandling(TestShellCompletion):
756 764
         completions: AbstractSet[str],
757 765
     ) -> None:
758 766
         """Completion skips incompletable items."""
767
+        caplog.clear()
759 768
         vault_config = config if mode == "config" else {"services": {}}
760 769
         runner = machinery.CliRunner(mix_stderr=False)
761 770
         # TODO(the-13th-letter): Rewrite using parenthesized
... ...
@@ -822,6 +831,8 @@ class TestCLICompletabilityHandling(TestShellCompletion):
822 831
                 "",
823 832
             )
824 833
 
834
+    # TODO(the-13th-letter): Reevaluate whether to keep this as a
835
+    # non-hypothesis test.
825 836
     @Parametrize.SERVICE_NAME_EXCEPTIONS
826 837
     def test_handling_unexpected_exceptions(
827 838
         self,
... ...
@@ -706,6 +706,8 @@ def provider_registry() -> dict[
706 706
     }
707 707
 
708 708
 
709
+# TODO(the-13th-letter): Rewrite the parametrization to be somewhat more
710
+# transparent, but keep it this high-level if possible.
709 711
 class TestKeyExplicitSSHAgentSocketProvider(TestKeyBasic):
710 712
     """Tests for SSH key configuration: explicit SSH agent socket providers."""
711 713
 
... ...
@@ -178,7 +178,7 @@ class Parametrize(types.SimpleNamespace):
178 178
             ),
179 179
         ],
180 180
     )
181
-    CONFIG_EDITING_VIA_CONFIG_FLAG = pytest.mark.parametrize(
181
+    CONFIG_EDITING_VIA_CONFIG_FLAG = hypothesis_machinery.explicit_examples(
182 182
         ["command_line", "input", "starting_config", "result_config"],
183 183
         [
184 184
             pytest.param(
... ...
@@ -19,6 +19,7 @@ import pytest
19 19
 from hypothesis import strategies
20 20
 
21 21
 from derivepassphrase import sequin
22
+from tests.machinery import hypothesis as hypothesis_machinery
22 23
 
23 24
 if TYPE_CHECKING:
24 25
     from collections.abc import Sequence
... ...
@@ -56,7 +57,7 @@ def bitseq(string: str) -> list[int]:
56 57
 
57 58
 
58 59
 class Parametrize(types.SimpleNamespace):
59
-    BIG_ENDIAN_NUMBER_EXCEPTIONS = pytest.mark.parametrize(
60
+    BIG_ENDIAN_NUMBER_EXCEPTIONS = hypothesis_machinery.explicit_examples(
60 61
         ["exc_type", "exc_pattern", "sequence", "base"],
61 62
         [
62 63
             (ValueError, "invalid base 3 digit:", [-1], 3),
... ...
@@ -64,7 +65,7 @@ class Parametrize(types.SimpleNamespace):
64 65
             (TypeError, "not an integer:", [0.0, 1.0, 0.0, 1.0], 2),
65 66
         ],
66 67
     )
67
-    INVALID_SEQUIN_INPUTS = pytest.mark.parametrize(
68
+    INVALID_SEQUIN_INPUTS = hypothesis_machinery.explicit_examples(
68 69
         ["sequence", "is_bitstring", "exc_type", "exc_pattern"],
69 70
         [
70 71
             (
... ...
@@ -27,6 +27,7 @@ from derivepassphrase._internals import cli_helpers, cli_messages
27 27
 from derivepassphrase.ssh_agent import socketprovider
28 28
 from tests import data, machinery
29 29
 from tests.data import callables
30
+from tests.machinery import hypothesis as hypothesis_machinery
30 31
 from tests.machinery import pytest as pytest_machinery
31 32
 
32 33
 if TYPE_CHECKING:
... ...
@@ -135,7 +136,7 @@ class Parametrize(types.SimpleNamespace):
135 136
         ],
136 137
         ids=str,
137 138
     )
138
-    SSH_STRING_EXCEPTIONS = pytest.mark.parametrize(
139
+    SSH_STRING_EXCEPTIONS = hypothesis_machinery.explicit_examples(
139 140
         ["input", "exc_type", "exc_pattern"],
140 141
         [
141 142
             pytest.param(
... ...
@@ -143,7 +144,7 @@ class Parametrize(types.SimpleNamespace):
143 144
             ),
144 145
         ],
145 146
     )
146
-    UINT32_EXCEPTIONS = pytest.mark.parametrize(
147
+    UINT32_EXCEPTIONS = hypothesis_machinery.explicit_examples(
147 148
         ["input", "exc_type", "exc_pattern"],
148 149
         [
149 150
             pytest.param(
... ...
@@ -160,7 +161,7 @@ class Parametrize(types.SimpleNamespace):
160 161
             ),
161 162
         ],
162 163
     )
163
-    SSH_UNSTRING_EXCEPTIONS = pytest.mark.parametrize(
164
+    SSH_UNSTRING_EXCEPTIONS = hypothesis_machinery.explicit_examples(
164 165
         ["input", "exc_type", "exc_pattern", "parts"],
165 166
         [
166 167
             pytest.param(
... ...
@@ -186,7 +187,7 @@ class Parametrize(types.SimpleNamespace):
186 187
             ),
187 188
         ],
188 189
     )
189
-    SSH_STRING_INPUT = pytest.mark.parametrize(
190
+    SSH_STRING_INPUT = hypothesis_machinery.explicit_examples(
190 191
         ["input", "expected"],
191 192
         [
192 193
             pytest.param(
... ...
@@ -206,7 +207,7 @@ class Parametrize(types.SimpleNamespace):
206 207
             ),
207 208
         ],
208 209
     )
209
-    SSH_UNSTRING_INPUT = pytest.mark.parametrize(
210
+    SSH_UNSTRING_INPUT = hypothesis_machinery.explicit_examples(
210 211
         ["input", "expected"],
211 212
         [
212 213
             pytest.param(
... ...
@@ -221,21 +222,13 @@ class Parametrize(types.SimpleNamespace):
221 222
             ),
222 223
         ],
223 224
     )
224
-    UINT32_INPUT = pytest.mark.parametrize(
225
+    UINT32_INPUT = hypothesis_machinery.explicit_examples(
225 226
         ["input", "expected"],
226 227
         [
227 228
             pytest.param(16777216, b"\x01\x00\x00\x00", id="16777216"),
228 229
         ],
229 230
     )
230
-    IO_OPERATIONS_ON_HANDLES = pytest.mark.parametrize(
231
-        "io_operation",
232
-        [
233
-            lambda handle: handle.recv(1),
234
-            lambda handle: handle.sendall(b"Hello, world!"),
235
-        ],
236
-        ids=["recv", "sendall"],
237
-    )
238
-    SIGN_ERROR_RESPONSES = pytest.mark.parametrize(
231
+    SIGN_ERROR_RESPONSES = hypothesis_machinery.explicit_examples(
239 232
         [
240 233
             "key",
241 234
             "check",
... ...
@@ -274,7 +267,7 @@ class Parametrize(types.SimpleNamespace):
274 267
         + [(callables.list_keys_singleton()[0].key, True)],
275 268
         ids=[*data.SUPPORTED_KEYS.keys(), "singleton"],
276 269
     )
277
-    SH_EXPORT_LINES = pytest.mark.parametrize(
270
+    SH_EXPORT_LINES = hypothesis_machinery.explicit_examples(
278 271
         ["line", "env_name", "value"],
279 272
         [
280 273
             pytest.param(
... ...
@@ -375,7 +368,7 @@ class Parametrize(types.SimpleNamespace):
375 368
         list(data.SUPPORTED_KEYS.values()),
376 369
         ids=list(data.SUPPORTED_KEYS.keys()),
377 370
     )
378
-    REQUEST_ERROR_RESPONSES = pytest.mark.parametrize(
371
+    REQUEST_ERROR_RESPONSES = hypothesis_machinery.explicit_examples(
379 372
         ["request_code", "response_code", "exc_type", "exc_pattern"],
380 373
         [
381 374
             pytest.param(
... ...
@@ -391,7 +384,7 @@ class Parametrize(types.SimpleNamespace):
391 384
             ),
392 385
         ],
393 386
     )
394
-    TRUNCATED_AGENT_RESPONSES = pytest.mark.parametrize(
387
+    TRUNCATED_AGENT_RESPONSES = hypothesis_machinery.explicit_examples(
395 388
         "response",
396 389
         [
397 390
             b"\x00\x00",
... ...
@@ -399,7 +392,7 @@ class Parametrize(types.SimpleNamespace):
399 392
         ],
400 393
         ids=["in-header", "in-body"],
401 394
     )
402
-    LIST_KEYS_ERROR_RESPONSES = pytest.mark.parametrize(
395
+    LIST_KEYS_ERROR_RESPONSES = hypothesis_machinery.explicit_examples(
403 396
         ["response_code", "response", "exc_type", "exc_pattern"],
404 397
         [
405 398
             pytest.param(
... ...
@@ -425,7 +418,7 @@ class Parametrize(types.SimpleNamespace):
425 418
             ),
426 419
         ],
427 420
     )
428
-    QUERY_EXTENSIONS_MALFORMED_RESPONSES = pytest.mark.parametrize(
421
+    QUERY_EXTENSIONS_MALFORMED_RESPONSES = hypothesis_machinery.explicit_examples(
429 422
         "response_data",
430 423
         [
431 424
             pytest.param(b"\xde\xad\xbe\xef", id="truncated"),
... ...
@@ -446,7 +439,7 @@ class Parametrize(types.SimpleNamespace):
446 439
         list(data.ALL_KEYS.items()),
447 440
         ids=data.ALL_KEYS.keys(),
448 441
     )
449
-    RESOLVE_CHAINS = pytest.mark.parametrize(
442
+    RESOLVE_CHAINS = hypothesis_machinery.explicit_examples(
450 443
         ["terminal", "chain"],
451 444
         [
452 445
             pytest.param("callable", ["a"], id="callable-1"),
... ...
@@ -483,10 +476,29 @@ class Strategies:
483 476
             alphabet=strategies.characters(min_codepoint=1), min_size=1
484 477
         ).filter(cls.select_invalid_pipe_names)
485 478
 
479
+    @staticmethod
480
+    def io_operations_on_handles() -> strategies.SearchStrategy[
481
+        Callable[[_types.SSHAgentSocket], Any]
482
+    ]:
483
+        """Return an I/O operation on an SSH agent socket."""
484
+        return strategies.one_of(
485
+            strategies.integers(min_value=0).map(
486
+                lambda i: lambda handle: handle.recv(i)
487
+            ),
488
+            strategies.binary().map(
489
+                lambda bs: lambda handle: handle.sendall(bs)
490
+            ),
491
+        )
492
+
486 493
 
487 494
 class TestStaticFunctionality:
488 495
     """Test the static functionality of the `ssh_agent` module."""
489 496
 
497
+    # TODO(the-13th-letter): Put this functionality into the
498
+    # tests.data.callables module. It is nice to have for other tests as
499
+    # well: `vault` tests, stubbed SSH agent tests, agent protocol
500
+    # response queue tests, etc. Also add an unstring and a u32
501
+    # function.
490 502
     @staticmethod
491 503
     def as_ssh_string(bytestring: bytes) -> bytes:
492 504
         """Return an encoded SSH string from a bytestring.
... ...
@@ -667,6 +679,7 @@ class TestSSHProtocolDatatypes(TestStaticFunctionality):
667 679
             for canon2 in canonical_functions:
668 680
                 assert canon1(canon2(encoded)) == canon1(encoded)
669 681
 
682
+    # TODO(the-13th-letter): Write a hypothesis strategy.
670 683
     @Parametrize.UINT32_EXCEPTIONS
671 684
     def test_uint32_exceptions(
672 685
         self, input: int, exc_type: type[Exception], exc_pattern: str
... ...
@@ -676,6 +689,7 @@ class TestSSHProtocolDatatypes(TestStaticFunctionality):
676 689
         with pytest.raises(exc_type, match=exc_pattern):
677 690
             uint32(input)
678 691
 
692
+    # TODO(the-13th-letter): Write a hypothesis strategy.
679 693
     @Parametrize.SSH_STRING_EXCEPTIONS
680 694
     def test_string_exceptions(
681 695
         self, input: Any, exc_type: type[Exception], exc_pattern: str
... ...
@@ -685,6 +699,7 @@ class TestSSHProtocolDatatypes(TestStaticFunctionality):
685 699
         with pytest.raises(exc_type, match=exc_pattern):
686 700
             string(input)
687 701
 
702
+    # TODO(the-13th-letter): Write a hypothesis strategy.
688 703
     @Parametrize.SSH_UNSTRING_EXCEPTIONS
689 704
     def test_unstring_exceptions(
690 705
         self,
... ...
@@ -705,6 +720,10 @@ class TestSSHProtocolDatatypes(TestStaticFunctionality):
705 720
                 unstring_prefix(input)
706 721
 
707 722
 
723
+# TODO(the-13th-letter): Introduce a registry analyzer that decomposes
724
+# the registry into chains.  On this decomposed representation, provide
725
+# strategies to draw chains, existing entries, non-existing entries,
726
+# leaves, aliases, etc.
708 727
 class TestSSHAgentSocketProviderRegistry:
709 728
     """Tests for the SSH agent socket provider registry."""
710 729
 
... ...
@@ -732,44 +751,6 @@ class TestSSHAgentSocketProviderRegistry:
732 751
                 resolve(_types.BuiltinSSHAgentSocketProvider.STUB_AGENT)
733 752
 
734 753
     @Parametrize.RESOLVE_CHAINS
735
-    def test_resolve_chains(
736
-        self,
737
-        terminal: Literal["unimplemented", "alias", "callable"],
738
-        chain: list[str],
739
-    ) -> None:
740
-        """Resolving a chain of providers works."""
741
-        registry = socketprovider.SocketProvider.registry
742
-        resolve = socketprovider.SocketProvider.resolve
743
-        lookup = socketprovider.SocketProvider.lookup
744
-        try:
745
-            implementation = resolve(
746
-                _types.BuiltinSSHAgentSocketProvider.NATIVE
747
-            )
748
-        except NotImplementedError:  # pragma: no cover
749
-            pytest.fail("Native SSH agent socket provider is unavailable?!")
750
-        # TODO(the-13th-letter): Rewrite using structural pattern matching.
751
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
752
-        target: _types.SSHAgentSocketProvider | str | None = (
753
-            None
754
-            if terminal == "unimplemented"
755
-            else _types.BuiltinSSHAgentSocketProvider.NATIVE
756
-            if terminal == "alias"
757
-            else implementation
758
-        )
759
-        with pytest.MonkeyPatch.context() as monkeypatch:
760
-            for link in chain:
761
-                monkeypatch.setitem(registry, link, target)
762
-                target = link
763
-            for link in chain:
764
-                assert lookup(link) == (
765
-                    implementation if terminal != "unimplemented" else None
766
-                )
767
-                if terminal == "unimplemented":
768
-                    with pytest.raises(NotImplementedError):
769
-                        resolve(link)
770
-                else:
771
-                    assert resolve(link) == implementation
772
-
773 754
     @hypothesis.given(
774 755
         terminal=strategies.sampled_from([
775 756
             "unimplemented",
... ...
@@ -793,7 +774,7 @@ class TestSSHAgentSocketProviderRegistry:
793 774
             unique=True,
794 775
         ),
795 776
     )
796
-    def test_resolve_chains2(
777
+    def test_resolve_chains(
797 778
         self,
798 779
         terminal: Literal["unimplemented", "alias", "callable"],
799 780
         chain: list[str],
... ...
@@ -832,6 +813,8 @@ class TestSSHAgentSocketProviderRegistry:
832 813
                 else:
833 814
                     assert resolve(link) == implementation
834 815
 
816
+    # TODO(the-13th-letter): Reevaluate whether this makes sense as a
817
+    # property-based test.
835 818
     @Parametrize.GOOD_ENTRY_POINTS
836 819
     def test_find_all_socket_providers(
837 820
         self,
... ...
@@ -851,6 +834,8 @@ class TestSSHAgentSocketProviderRegistry:
851 834
                     *old_registry.values(),
852 835
                 }
853 836
 
837
+    # TODO(the-13th-letter): Reevaluate whether this makes sense as a
838
+    # property-based test.
854 839
     @Parametrize.BAD_ENTRY_POINTS
855 840
     def test_find_all_socket_providers_errors(
856 841
         self,
... ...
@@ -866,6 +851,8 @@ class TestSSHAgentSocketProviderRegistry:
866 851
             stack.enter_context(pytest.raises(AssertionError))
867 852
             socketprovider.SocketProvider._find_all_ssh_agent_socket_providers()
868 853
 
854
+    # TODO(the-13th-letter): Convert this to a property-based test once
855
+    # the registry analyzer is ready.
869 856
     def test_overwriting_existing_entries(
870 857
         self,
871 858
     ) -> None:
... ...
@@ -911,6 +898,8 @@ class TestSSHAgentSocketProviderRegistry:
911 898
             with pytest.raises(ValueError, match="already registered"):
912 899
                 register(names[1][0], names[0][1])(bases[1])
913 900
 
901
+    # TODO(the-13th-letter): Convert this to a property-based test once
902
+    # the registry analyzer is ready.
914 903
     def test_resolve_non_existant_entries(
915 904
         self,
916 905
     ) -> None:
... ...
@@ -932,6 +921,8 @@ class TestSSHAgentSocketProviderRegistry:
932 921
                     _types.BuiltinSSHAgentSocketProvider.NATIVE
933 922
                 )
934 923
 
924
+    # TODO(the-13th-letter): Convert this to a property-based test once
925
+    # the registry analyzer is ready.
935 926
     def test_register_new_entry(
936 927
         self,
937 928
     ) -> None:
... ...
@@ -968,6 +959,8 @@ class TestSSHAgentSocketProviderRegistry:
968 959
                 for n in names
969 960
             ])
970 961
 
962
+    # TODO(the-13th-letter): Convert this to a property-based test once
963
+    # the registry analyzer is ready.
971 964
     @Parametrize.EXISTING_REGISTRY_ENTRIES
972 965
     def test_register_old_entry(
973 966
         self,
... ...
@@ -1163,6 +1156,9 @@ class TestSuitableKeys:
1163 1156
 class TestConstructorFailures:
1164 1157
     """Test actually talking to the SSH agent: constructor failures."""
1165 1158
 
1159
+    # TODO(the-13th-letter): Decouple tests from exact knowledge of how
1160
+    # to unsupport a certain socket provider.  Coordinate with
1161
+    # tests.test_derivepassphrase_cli.test_000_basic.TestSSHAgentAvailability.
1166 1162
     def _unsupport_provider_and_force_use(
1167 1163
         self,
1168 1164
         provider: _types.BuiltinSSHAgentSocketProvider,
... ...
@@ -1450,6 +1446,8 @@ class TestAgentErrorResponses:
1450 1446
         _types.SSH_AGENT.SUCCESS, b""
1451 1447
     )
1452 1448
 
1449
+    # TODO(the-13th-letter): Reconsider whether to parametrize or
1450
+    # hypothesize this test.
1453 1451
     @pytest.mark.parametrize(
1454 1452
         ["success_after_query", "extensions"],
1455 1453
         [
... ...
@@ -1620,7 +1618,7 @@ class TestWindowsNamedPipeHandle:
1620 1618
             monkeypatch.setattr(socketprovider, "CloseHandle", close_handle)
1621 1619
             yield
1622 1620
 
1623
-    @Parametrize.IO_OPERATIONS_ON_HANDLES
1621
+    @hypothesis.given(io_operation=Strategies.io_operations_on_handles())
1624 1622
     def test_closed_handle_cannot_be_operated_on(
1625 1623
         self,
1626 1624
         io_operation: Callable[[Any], Any],
... ...
@@ -1635,7 +1633,7 @@ class TestWindowsNamedPipeHandle:
1635 1633
             ):
1636 1634
                 io_operation(handle)
1637 1635
 
1638
-    @Parametrize.IO_OPERATIONS_ON_HANDLES
1636
+    @hypothesis.given(io_operation=Strategies.io_operations_on_handles())
1639 1637
     def test_handle_is_non_reentrant(
1640 1638
         self,
1641 1639
         io_operation: Callable[[Any], Any],
... ...
@@ -83,12 +83,7 @@ class Parametrize(types.SimpleNamespace):
83 83
             (1, {"upper": 0, "lower": 0, "number": 0, "symbol": 0}, 0.0),
84 84
         ],
85 85
     )
86
-    MASTER_PASSPHRASE_TYPES = pytest.mark.parametrize(
87
-        ["phrase1", "phrase2"],
88
-        [(PHRASE.decode("UTF-8"), f(PHRASE)) for f in buffer_types.values()],
89
-        ids=buffer_types.keys(),
90
-    )
91
-    BINARY_STRINGS = pytest.mark.parametrize(
86
+    BINARY_STRINGS = hypothesis_machinery.explicit_examples(
92 87
         "s",
93 88
         [
94 89
             "ñ",
... ...
@@ -108,11 +103,6 @@ class Parametrize(types.SimpleNamespace):
108 103
         ],
109 104
         ids=["google", "twitter"],
110 105
     )
111
-    SERVICE_NAME_TYPES = pytest.mark.parametrize(
112
-        ["sv1", "sv2"],
113
-        [("email", f(b"email")) for f in buffer_types.values()],
114
-        ids=buffer_types.keys(),
115
-    )
116 106
 
117 107
 
118 108
 def phrases_are_interchangable(
... ...
@@ -458,27 +448,14 @@ class TestStringAndBinaryExchangability(TestVault):
458 448
 
459 449
     """
460 450
 
461
-    @Parametrize.SAMPLE_SERVICES_AND_PHRASES
462
-    @Parametrize.MASTER_PASSPHRASE_TYPES
463
-    def test_binary_phrases(
464
-        self,
465
-        phrase1: str,
466
-        phrase2: Buffer,
467
-        service: bytes | str,
468
-        expected: bytes,
469
-    ) -> None:
470
-        """Binary and text master passphrases generate the same passphrases."""
471
-        v1 = vault.Vault(phrase=phrase1)
472
-        v2 = vault.Vault(phrase=phrase2)
473
-        assert v1.generate(service) == expected
474
-        assert v2.generate(service) == expected
475
-
476
-    @Parametrize.SERVICE_NAME_TYPES
477
-    def test_binary_service_name(self, sv1: str, sv2: Buffer) -> None:
478
-        """Binary and text service names generate the same passphrases."""
479
-        v = vault.Vault(phrase=self.phrase)
480
-        assert v.generate(sv1) == v.generate(sv2)
481
-
451
+    @hypothesis_machinery.explicit_examples(
452
+        ["phrase", "service"],
453
+        [
454
+            pytest.param(PHRASE.decode("UTF-8"), "google", id="google"),
455
+            pytest.param(PHRASE.decode("UTF-8"), "twitter", id="twitter"),
456
+            pytest.param(PHRASE.decode("UTF-8"), "email", id="email"),
457
+        ],
458
+    )
482 459
     @hypothesis.given(
483 460
         phrase=Strategies.text_strategy(),
484 461
         service=Strategies.text_strategy(),
... ...
@@ -807,6 +784,18 @@ class TestUtilities(TestVault):
807 784
         """Removing allowed characters internally works."""
808 785
         assert vault.Vault._subtract(b"be", b"abcdef") == bytearray(b"acdf")
809 786
 
787
+    # TODO(the-13th-letter): Strongly consider removing this test, and
788
+    # the underlying functionality `Vault._entropy` and
789
+    # `Vault._estimate_sufficient_hash_length`, in favor of a heuristic
790
+    # estimate that works in "most cases".  This test strongly suggests
791
+    # that we know how to correctly calculate both the entropy of
792
+    # a passphrase derivation template and a bound on how many bits of
793
+    # entropy the template will actually need.  We don't, neither the
794
+    # template entropy (because the passphrase characters are not
795
+    # stochastically independent of each other, thanks to mandatory
796
+    # characters and repetition constraints) nor the actual used entropy
797
+    # (because we still use some form of rejection sampling with
798
+    # worst-case infinite retry chains).
810 799
     @Parametrize.ENTROPY_RESULTS
811 800
     def test_entropy(
812 801
         self, length: int, settings: dict[str, int], entropy: int
... ...
@@ -821,44 +810,16 @@ class TestUtilities(TestVault):
821 810
             )
822 811
         assert v._estimate_sufficient_hash_length(8.0) >= entropy
823 812
 
824
-    # TODO(the-13th-letter): Either fold this version into test_entropy,
825
-    # or remove degenerate cases from Parametrize.ENTROPY_RESULTS.
826
-    def test_hash_length_estimation(self) -> None:
827
-        """
828
-        Estimating the entropy and hash length for degenerate cases works.
829
-        """
830
-        v = vault.Vault(
831
-            phrase=self.phrase,
832
-            lower=0,
833
-            upper=0,
834
-            number=0,
835
-            symbol=0,
836
-            space=1,
837
-            length=1,
813
+    @Parametrize.BINARY_STRINGS
814
+    @hypothesis.given(
815
+        s=strategies.text().flatmap(
816
+            lambda s: strategies.sampled_from([
817
+                s,
818
+                s.encode("UTF-8"),
819
+                bytearray(s.encode("UTF-8")),
820
+            ])
838 821
         )
839
-        assert v._entropy() == 0.0  # noqa: RUF069
840
-        assert v._estimate_sufficient_hash_length() > 0
841
-
842
-    @Parametrize.SAMPLE_SERVICES_AND_PHRASES
843
-    def test_hash_length_expansion(
844
-        self,
845
-        monkeypatch: pytest.MonkeyPatch,
846
-        service: str | bytes,
847
-        expected: bytes,
848
-    ) -> None:
849
-        """
850
-        Estimating the entropy and hash length for the degenerate case works.
851
-        """
852
-        v = vault.Vault(phrase=self.phrase)
853
-        monkeypatch.setattr(
854
-            v,
855
-            "_estimate_sufficient_hash_length",
856
-            lambda *_args, **_kwargs: 1,
857 822
     )
858
-        assert v._estimate_sufficient_hash_length() < len(self.phrase)
859
-        assert v.generate(service) == expected
860
-
861
-    @Parametrize.BINARY_STRINGS
862 823
     def test_binary_strings(self, s: str | bytes | bytearray) -> None:
863 824
         """Byte string conversion is idempotent."""
864 825
         binstr = vault.Vault._get_binary_string
... ...
@@ -19,6 +19,7 @@ import pytest
19 19
 from hypothesis import strategies
20 20
 
21 21
 from derivepassphrase._internals import cli_messages as msg
22
+from tests.machinery import hypothesis as hypothesis_machinery
22 23
 
23 24
 if TYPE_CHECKING:
24 25
     from collections.abc import Generator
... ...
@@ -26,7 +27,7 @@ if TYPE_CHECKING:
26 27
 
27 28
 
28 29
 class Parametrize(types.SimpleNamespace):
29
-    MAYBE_FORMAT_STRINGS = pytest.mark.parametrize(
30
+    MAYBE_FORMAT_STRINGS = hypothesis_machinery.explicit_examples(
30 31
         "s", ["{spam}", "{spam}abc", "{", "}", "{{{"]
31 32
     )
32 33
 
... ...
@@ -281,6 +282,8 @@ class TestTranslatedStrings:
281 282
 class TestSuppressedInterpolations:
282 283
     """Test the interpolation suppression behavior of translated strings."""
283 284
 
285
+    # TODO(the-13th-letter): Revise this test, in particular, the error
286
+    # assertions.
284 287
     @Parametrize.MAYBE_FORMAT_STRINGS
285 288
     def test_missing_fields(self, s: str) -> None:
286 289
         """TranslatableStrings require fixed replacement fields.
287 290