Deterministic tests, and a pytest plugin to write them as effects.
A TestClock only moves when the test says so, with adjust or
set_time; each move lets ready fibers run, then wakes sleepers in order
and waits for each woken fiber to suspend, so timing tests are exact and
take no real time::
@testing.effect
def test_timeout():
fiber = yield from fx.sleep(10).timeout(5).exit().fork()
yield from testing.adjust(5)
assert isinstance((yield from fiber.join()), fx.Failure)effect/live: pytest tests written as generators or effects, with aTestClockandTestConsole(or the live ones) and aScope, a real-time timeout that interrupts them and runs their finalizers, andasyncio=Truefor effects that need an event loop.layer_fixture: build a layer once per pytest scope; tests that take the fixture asServices[...]get its services.effect_fixture: run a scoped effect once per pytest scope; tests get its value, and its finalizers run when the scope ends.flaky: retry a test effect that sometimes fails.run_test/run_test_async: run one effect with aTestClock, synchronously or on the running asyncio loop.with_live,TestClock.layer(): the live clock inside a test, and a test clock inside any program.TestConsole: a console recording every call;effecttests run with a fresh one, read back withlog_lines/error_lines.assert_exit_successand friends: failures printCause.pretty().
If every fiber ends up waiting with nothing to wake them, synchronous runs
raise StalledError instead of hanging. A woken fiber that never suspends
makes adjust warn with BusyFiberWarning and move on.
Classes#
Functions#
assert_exit_failure#
assert_exit_failure(ex, expected) -> Noneex is a Failure whose cause equals expected.
| Parameter | Type | Description |
|---|---|---|
ex | Exit[Any, E] | |
expected | Cause[E] |
def assert_exit_failure[E](ex: Exit[Any, E], expected: Cause[E]) -> None:
"""``ex`` is a ``Failure`` whose cause equals ``expected``."""
if isinstance(ex, Failure) and ex.cause == expected:
return
raise AssertionError(
f"expected Failure with cause:\n{_pretty(expected)}\ngot {describe_exit(ex)}"
)assert_exit_success#
assert_exit_success(ex, expected) -> Noneex is Success(expected).
| Parameter | Type | Description |
|---|---|---|
ex | Exit[A, Any] | |
expected | A |
def assert_exit_success[A](ex: Exit[A, Any], expected: A) -> None:
"""``ex`` is ``Success(expected)``."""
if isinstance(ex, Failure):
raise AssertionError(f"expected Success({expected!r}), got {describe_exit(ex)}")
if ex.value != expected:
raise AssertionError(f"expected Success({expected!r}), got Success({ex.value!r})")assert_failure#
assert_failure(result, expected) -> Noneresult is Err(expected).
| Parameter | Type | Description |
|---|---|---|
result | Result[Any, E] | |
expected | E |
def assert_failure[E](result: Result[Any, E], expected: E) -> None:
"""``result`` is ``Err(expected)``."""
if result != Err(expected):
raise AssertionError(f"expected Err({expected!r}), got {result!r}")assert_success#
assert_success(result, expected) -> Noneresult is Ok(expected).
| Parameter | Type | Description |
|---|---|---|
result | Result[A, Any] | |
expected | A |
def assert_success[A](result: Result[A, Any], expected: A) -> None:
"""``result`` is ``Ok(expected)``."""
if result != Ok(expected):
raise AssertionError(f"expected Ok({expected!r}), got {result!r}")adjust#
adjust(duration) -> Effect[None]Move the test clock forward by duration.
| Parameter | Type | Description |
|---|---|---|
duration | Duration |
Returns Effect[None]
def adjust(duration: Duration) -> Effect[None]:
"""Move the test clock forward by ``duration``."""
return test_clock().flat_map(lambda c: c.adjust(duration))set_time#
set_time(at) -> Effect[None]Set the test clock's wall time to at (seconds since the epoch).
| Parameter | Type | Description |
|---|---|---|
at | float |
Returns Effect[None]
def set_time(at: float) -> Effect[None]:
"""Set the test clock's wall time to ``at`` (seconds since the epoch)."""
return test_clock().flat_map(lambda c: c.set_time(at))test_clock#
test_clock() -> Effect[TestClock]The TestClock the effect runs with; dies if the clock is live.
Returns Effect[TestClock]
def test_clock() -> Effect[TestClock]:
"""The ``TestClock`` the effect runs with; dies if the clock is live."""
def check(clock: ClockService) -> Effect[TestClock]:
if not isinstance(clock, TestClock):
raise TypeError(
"no TestClock in context; run the effect with a pyfect.testing runner "
"or provide TestClock.layer()"
)
return _Succeed(clock)
return Clock().flat_map(check)with_live#
with_live(eff) -> Effect[A, E, R]Run eff with the live clock. Under a TestClock that is the
clock it replaced; otherwise the clock is already live.
| Parameter | Type | Description |
|---|---|---|
eff | Effect[A, E, R] |
Returns Effect[A, E, R]
def with_live[A, E, R](eff: Effect[A, E, R]) -> Effect[A, E, R]:
"""Run ``eff`` with the live clock. Under a ``TestClock`` that is the
clock it replaced; otherwise the clock is already live."""
require_effect(eff, "eff")
def run(clock: ClockService) -> Effect[A, E, R]:
return clock.with_live(eff) if isinstance(clock, TestClock) else eff
return Clock().flat_map(run)test_console#
test_console() -> Effect[TestConsole]The TestConsole the effect runs with; dies if another console
is current.
Returns Effect[TestConsole]
def test_console() -> Effect[TestConsole]:
"""The ``TestConsole`` the effect runs with; dies if another console
is current."""
return CurrentConsole().flat_map(_check)effect#
effect(f=None, /, *, timeout=None, asyncio=False, warning_delay=1.0) -> AnyA pytest test written as an effect, run with a TestClock, a
TestConsole and a Scope (closed when the test ends)::
@effect
def test_retries():
fiber = yield from flaky_call.retry(spaced(1)).fork()
yield from adjust(3)
assert (yield from fiber.join()) == "ok"timeout (real time; default from the pyfect_timeout ini option)
interrupts the test and runs its finalizers. asyncio runs it on an
event loop, for from_async. A first parameter annotated
Services[...] (a layer_fixture; in a test class, the parameter
after self) provides those services.
| Parameter | Type | Description |
|---|---|---|
f | Callable[..., Any] | None = None | |
timeout | Duration | None = None | |
asyncio | bool = False | |
warning_delay | Duration = 1.0 |
Returns Any
def effect(
f: Callable[..., Any] | None = None,
/,
*,
timeout: Duration | None = None,
asyncio: bool = False,
warning_delay: Duration = 1.0,
) -> Any:
"""A pytest test written as an effect, run with a ``TestClock``, a
``TestConsole`` and a ``Scope`` (closed when the test ends)::
@effect
def test_retries():
fiber = yield from flaky_call.retry(spaced(1)).fork()
yield from adjust(3)
assert (yield from fiber.join()) == "ok"
``timeout`` (real time; default from the ``pyfect_timeout`` ini option)
interrupts the test and runs its finalizers. ``asyncio`` runs it on an
event loop, for ``from_async``. A first parameter annotated
``Services[...]`` (a ``layer_fixture``; in a test class, the parameter
after ``self``) provides those services."""
decorator = TestDecorator(_Options("effect", timeout, asyncio, warning_delay))
return decorator if f is None else decorator(f)live#
live(f=None, /, *, timeout=None, asyncio=False) -> AnyLike effect, but with the live clock and console: sleeps take
real time and output is written.
| Parameter | Type | Description |
|---|---|---|
f | Callable[..., Any] | None = None | |
timeout | Duration | None = None | |
asyncio | bool = False |
Returns Any
def live(
f: Callable[..., Any] | None = None,
/,
*,
timeout: Duration | None = None,
asyncio: bool = False,
) -> Any:
"""Like ``effect``, but with the live clock and console: sleeps take
real time and output is written."""
decorator = TestDecorator(_Options("live", timeout, asyncio, 1.0))
return decorator if f is None else decorator(f)flaky#
flaky(eff, timeout=30.0) -> Effect[Any, Any, Any]Retry eff (in a fresh scope each time) after any failure, defects
included, up to 10 times while less than timeout has passed. Like
Effect's flakyTest, the last failure dies: its typed errors become
defects, kept with their tracebacks.
| Parameter | Type | Description |
|---|---|---|
eff | Effect[Any, Any, Any] | |
timeout | Duration = 30.0 |
Returns Effect[Any, Any, Any]
def flaky(eff: Effect[Any, Any, Any], timeout: Duration = 30.0) -> Effect[Any, Any, Any]:
"""Retry ``eff`` (in a fresh scope each time) after any failure, defects
included, up to 10 times while less than ``timeout`` has passed. Like
Effect's ``flakyTest``, the last failure dies: its typed errors become
defects, kept with their tracebacks."""
require_effect(eff, "eff")
policy = recurs(10).up_to(duration=timeout)
attempts: Effect[Any, Any, Any] = scoped(eff).sandbox().retry(policy)
return attempts.catch_all(lambda cause: fail_cause(_all_defects(cause)))effect_fixture#
effect_fixture(eff, *, scope='function', live=False, asyncio=False, warning_delay=1.0, timeout=None) -> FixtureFunctionDefinitionA pytest fixture whose value is what eff succeeds with, run once
per scope. Finalizers eff adds to its Scope run when the
scope ends; a failure is raised as the fixture's error.
The options are layer_fixture's. The value is a plain Python value,
so any test can take it; to share services or forked fibers with
effect tests, use layer_fixture.
| Parameter | Type | Description |
|---|---|---|
eff | Effect[A, E, Scope] | |
scope | FixtureScope = 'function' | |
live | bool = False | |
asyncio | bool = False | |
warning_delay | Duration = 1.0 | |
timeout | Duration | None = None |
Returns FixtureFunctionDefinition
def effect_fixture[A, E](
eff: Effect[A, E, Scope],
*,
scope: FixtureScope = "function",
live: bool = False,
asyncio: bool = False,
warning_delay: Duration = 1.0,
timeout: Duration | None = None,
) -> FixtureFunctionDefinition:
"""A pytest fixture whose value is what ``eff`` succeeds with, run once
per ``scope``. Finalizers ``eff`` adds to its ``Scope`` run when the
scope ends; a failure is raised as the fixture's error.
The options are ``layer_fixture``'s. The value is a plain Python value,
so any test can take it; to share services or forked fibers with
``effect`` tests, use ``layer_fixture``."""
require_effect(eff, "eff")
def fixture() -> Generator[A]:
with _held(
lambda resources: eff.pipe(provide_service(Scope, resources)),
live=live,
asyncio=asyncio,
warning_delay=warning_delay,
timeout=timeout,
) as (value, _context, _runtime):
yield value
return _fixture(fixture, scope)layer_fixture#
layer_fixture(layer, *, scope='module', live=False, asyncio=False, warning_delay=1.0, timeout=None) -> FixtureFunctionDefinitionA pytest fixture that builds layer once per scope and
releases its resources when the scope ends.
The layer is built with a TestClock and a TestConsole (shared
by every test that uses the fixture), unless live: then it and its
effect tests use the live clock and console. Tests using the fixture run on the
fixture's runtime, so fibers the layer forks keep running; asyncio
picks an event loop for it, needed by from_async. timeout
bounds building and releasing, in real time.
| Parameter | Type | Description |
|---|---|---|
layer | Layer[RO, E] | |
scope | FixtureScope = 'module' | |
live | bool = False | |
asyncio | bool = False | |
warning_delay | Duration = 1.0 | |
timeout | Duration | None = None |
Returns FixtureFunctionDefinition
def layer_fixture[RO, E](
layer: Layer[RO, E],
*,
scope: FixtureScope = "module",
live: bool = False,
asyncio: bool = False,
warning_delay: Duration = 1.0,
timeout: Duration | None = None,
) -> FixtureFunctionDefinition:
"""A pytest fixture that builds ``layer`` once per ``scope`` and
releases its resources when the scope ends.
The layer is built with a ``TestClock`` and a ``TestConsole`` (shared
by every test that uses the fixture), unless ``live``: then it and its
``effect`` tests use the live clock and console. Tests using the fixture run on the
fixture's runtime, so fibers the layer forks keep running; ``asyncio``
picks an event loop for it, needed by ``from_async``. ``timeout``
bounds building and releasing, in real time."""
require_layer(layer, "layer")
def fixture() -> Generator[Services[RO]]:
with _held(
layer.build_with_scope,
live=live,
asyncio=asyncio,
warning_delay=warning_delay,
timeout=timeout,
) as (built, context, runtime):
yield Services(context.merge(built), runtime)
return _fixture(fixture, scope)run_test#
run_test(eff, *, stall_timeout=1.0, warning_delay=1.0, timeout=None) -> A| Parameter | Type | Description |
|---|---|---|
eff | Effect[A, Never, Never] | |
stall_timeout | float = 1.0 | |
warning_delay | Duration = 1.0 | |
timeout | Duration | None = None |
Returns A
def run_test[A](
eff: Effect[A, Never, Never],
*,
stall_timeout: float = 1.0,
warning_delay: Duration = 1.0,
timeout: Duration | None = None,
) -> A:
return unwrap(
run_test_exit(
eff, stall_timeout=stall_timeout, warning_delay=warning_delay, timeout=timeout
)
)run_test_async#
run_test_async(eff, *, warning_delay=1.0, timeout=None) -> A| Parameter | Type | Description |
|---|---|---|
eff | Effect[A, Never, Never] | |
warning_delay | Duration = 1.0 | |
timeout | Duration | None = None |
Returns A
async def run_test_async[A](
eff: Effect[A, Never, Never], *, warning_delay: Duration = 1.0, timeout: Duration | None = None
) -> A:
return unwrap(await run_test_async_exit(eff, warning_delay=warning_delay, timeout=timeout))run_test_async_exit#
run_test_async_exit(eff, *, warning_delay=1.0, timeout=None) -> Exit[A, E]Run eff on the running asyncio loop with a TestClock and a
TestConsole, so
from_async works on virtual time. Cancelling the awaiting task
interrupts the effect and waits for its finalizers.
| Parameter | Type | Description |
|---|---|---|
eff | Effect[A, E, Never] | |
warning_delay | Duration = 1.0 | |
timeout | Duration | None = None |
Returns Exit[A, E]
async def run_test_async_exit[A, E](
eff: Effect[A, E, Never], *, warning_delay: Duration = 1.0, timeout: Duration | None = None
) -> Exit[A, E]:
"""Run ``eff`` on the running asyncio loop with a ``TestClock`` and a
``TestConsole``, so
``from_async`` works on virtual time. Cancelling the awaiting task
interrupts the effect and waits for its finalizers."""
require_effect(eff, "eff")
clock = TestClock(warning_delay=warning_delay)
limit = None if timeout is None else to_seconds(timeout)
try:
return await run_on_loop(
AsyncioScheduler.current(),
eff,
Context.empty().add(Clock, clock).add(CurrentConsole, TestConsole()),
limit,
)
finally:
clock.stop_warning()run_test_exit#
run_test_exit(eff, *, stall_timeout=1.0, warning_delay=1.0, timeout=None) -> Exit[A, E]Run eff on a synchronous scheduler with a TestClock and a
TestConsole.
Raises StalledError when every fiber waits with nothing to wake it,
and TestTimeoutError after timeout of real time; either way the
effect is interrupted and its finalizers run first.
| Parameter | Type | Description |
|---|---|---|
eff | Effect[A, E, Never] | |
stall_timeout | float = 1.0 | |
warning_delay | Duration = 1.0 | |
timeout | Duration | None = None |
Returns Exit[A, E]
def run_test_exit[A, E](
eff: Effect[A, E, Never],
*,
stall_timeout: float = 1.0,
warning_delay: Duration = 1.0,
timeout: Duration | None = None,
) -> Exit[A, E]:
"""Run ``eff`` on a synchronous scheduler with a ``TestClock`` and a
``TestConsole``.
Raises ``StalledError`` when every fiber waits with nothing to wake it,
and ``TestTimeoutError`` after ``timeout`` of real time; either way the
effect is interrupted and its finalizers run first."""
require_effect(eff, "eff")
clock = TestClock(warning_delay=warning_delay)
limit = None if timeout is None else to_seconds(timeout)
try:
return SyncRuntime(stall_timeout=stall_timeout).run(
eff, Context.empty().add(Clock, clock).add(CurrentConsole, TestConsole()), timeout=limit
)
finally:
clock.stop_warning()