Mutation testing¶
Coverage says a line ran; mutation testing says a test would notice if it were wrong. mutmut
rewrites src/ one small change at a time (a flipped comparison, a dropped argument, a changed string) and runs the
suite against each rewrite, a mutant. A mutant the suite fails on is killed; one it passes survives, and is either a
missing test or a change no input can tell apart from the original (an equivalent mutant).
Running it¶
The configuration is [tool.mutmut] in pyproject.toml. Mutation runs leave out the tests of the source's own shape
(the API-surface snapshot, the import boundary, the no-logging and no-environment contracts), which read the rewritten
files.
uv run mutmut run # every mutant, about 15 minutes on 10 workers
uv run mutmut run "headgate._failures*" # one module's mutants, while working on it
uv run mutmut results # the survivors
uv run mutmut show <name> # one mutant's diff
Delete mutants/ if a run gets confused after files move. --max-children sets the number of workers.
On macOS, mutmut reports some mutants as segfault: the mutant made a test take a path through the system TLS stack
(an SSL context built without verification, say) in a forked worker, where Security.framework aborts the process. The
same tests fail on those mutants when run in a plain process, so they are counted as killed.
Latest score¶
A full run on 2026-10-26 (mutmut 3.8.0, 10 workers, about 14 minutes) generated 6,099 mutants. It ran against the
source of headgate 0.7.0 (d3663d3), with the
tests written for that run's survivors:
| Outcome | Mutants |
|---|---|
| killed by a failing test | 5,775 |
killed by a crash in the forked worker (segfault, see above) |
134 |
| killed by a timeout | 59 |
| survived, every one equivalent (listed below) | 131 |
That is 5,968 of 6,099 detected (97.9%), and every mutant that changes behavior is detected. The same run before those tests left 184 survivors; the 53 that changed behavior (a reader's empty-body and refusal options, codecs built at run time, the conformance check's messages, a degraded cache fetch with a streamed body, a hedge refused by the rate budget, among others) each now fail a test.
Equivalent mutants¶
Every survivor was checked, and each one left is equivalent: no input distinguishes them. Most fall in a few families:
- header-name case: httpx2 headers are case-insensitive, so
headers.get("ETag")andheaders.get("etag")read the same field; - codec, distribution and method names:
"ascii"/"ASCII","latin-1"/"LATIN-1","utf-8"/"UTF-8",metadata.version("HEADGATE")andMethodFilter({"get"})(names are normalized); - falsy stand-ins:
NoneforFalseor""where the value is only tested for truth; - defaults spelt out: an argument equal to the callee's default (
to_bytes(8, "big"),zip(..., strict=False),secrets.token_bytes(32),copy_with(userinfo=None), which clears userinfo exactly asb""does); - unreachable fallbacks: a default for a value the code never sees missing (a header read with
""or"XXXX"when absent, both of which parse to nothing); - type arguments only the type checker reads:
typing.cast's first argument, and the type expression msgspec's encoder is handed.
| Module | Mutant | Why it is equivalent |
|---|---|---|
headgate.health |
breaker_states #12 |
two snapshots of equal rank are equal (state and refusing are all a snapshot holds) |
headgate._responses |
accepts #12 |
header-name case |
headgate._responses |
accepts #13 |
a missing content-type read as XXXX is no media type, like "" |
headgate._responses |
event_stream #2 |
the status of the response built only to frame the body is never read |
headgate._responses |
event_stream #10 |
the status of the response built only to frame the body is never read |
headgate._responses |
event_stream #12 |
header-name case |
headgate._responses |
event_stream #14 |
httpx2's EventSource compares the media type case-insensitively |
headgate._responses |
event_stream #19 |
the method of the request built only to frame the body is never read |
headgate._responses |
event_stream #20 |
the method of the request built only to frame the body is never read |
headgate._deprecation |
deprecation_of #4 |
header-name case |
headgate._deprecation |
deprecation_of #5 |
header-name case |
headgate._deprecation |
deprecation_of #9 |
header-name case |
headgate._deprecation |
deprecation_of #10 |
header-name case |
headgate._user_agent |
headgate_version #3 |
distribution names are normalized, so HEADGATE finds headgate |
headgate.pydantic |
_adapter #1 |
typing.cast ignores its type argument at runtime |
headgate.pydantic |
_adapter #5 |
typing.cast ignores its type argument at runtime |
headgate.pydantic |
_adapter #6 |
typing.cast ignores its type argument at runtime |
headgate.pydantic |
_adapter #7 |
typing.cast ignores its type argument at runtime |
headgate.pydantic |
PydanticCodec.describe #2 |
typing.cast ignores its type argument at runtime |
headgate.pydantic |
PydanticCodec.describe #6 |
typing.cast ignores its type argument at runtime |
headgate.pydantic |
PydanticCodec.describe #7 |
typing.cast ignores its type argument at runtime |
headgate.pydantic |
PydanticCodec.describe #8 |
typing.cast ignores its type argument at runtime |
headgate.pydantic |
PydanticCodec.describe #13 |
the message reads only each error's loc and msg, which these flags do not change |
headgate.pydantic |
PydanticCodec.describe #14 |
the message reads only each error's loc and msg, which these flags do not change |
headgate.pydantic |
PydanticCodec.describe #15 |
the message reads only each error's loc and msg, which these flags do not change |
headgate.pydantic |
PydanticCodec.describe #16 |
the message reads only each error's loc and msg, which these flags do not change |
headgate.pydantic |
PydanticCodec.describe #17 |
the message reads only each error's loc and msg, which these flags do not change |
headgate.pydantic |
PydanticCodec.describe #18 |
the message reads only each error's loc and msg, which these flags do not change |
headgate.pydantic |
to_builtins #6 |
pydantic-core's to_jsonable_python serializes a model by alias already |
headgate.msgspec |
encoder #3 |
MsgspecCodec.encoder drops the type expression: msgspec encodes a value by its own type |
headgate._client |
AsyncBaseClient.with_options #17 |
_owns_pool is only tested for truth |
headgate._client |
AsyncBaseClient.stream #16 |
header-name case |
headgate._client |
AsyncBaseClient.stream #17 |
header-name case |
headgate._client |
AsyncBaseClient._dispatch #30 |
header-name case |
headgate._client |
_query_value #2 |
httpx2 sends any other value as its str(), exactly what the function returns; it exists for the static type |
headgate._client |
_stamp_accept #9 |
header-name case |
headgate._client |
_stamp_accept #10 |
header-name case |
headgate._failures |
CallSite._request_id #3 |
a missing header read as None is as falsy as "" |
headgate._failures |
CallSite._request_id #5 |
a missing header read as None is as falsy as "" |
headgate._failures |
body_snippet #7 |
httpx2's Response.encoding is never falsy (it falls back to utf-8 itself) |
headgate._failures |
body_snippet #8 |
httpx2's Response.encoding is never falsy (it falls back to utf-8 itself) |
headgate._failures |
problem_of #10 |
header-name case |
headgate._failures |
problem_of #11 |
a missing content-type read as xxxx is not a problem document, like "" |
headgate._failures |
parse_error #15 |
header-name case |
headgate._failures |
mint_error #10 |
an empty body snippet is treated as absent, exactly like None |
headgate._curl |
_httpx_response #6 |
httpx2 headers' in is case-insensitive |
headgate._curl |
_httpx_response #31 |
codec names are case-insensitive |
headgate._curl |
_httpx_response #44 |
httpx2 attaches the request to an error raised while it reads a body |
headgate._curl |
_CurlByteStream.__init__ #1 |
httpx2 attaches the request to an error raised while it reads a body |
headgate._redirects |
refuse_unreplayable_redirect #15 |
header-name case |
headgate._redirects |
refuse_unreplayable_redirect #16 |
header-name case |
headgate.errors |
Problem.__setstate__ #3 |
strict=None is as false as strict=False, the default |
headgate.errors |
Problem.__setstate__ #6 |
strict=False is zip's default |
headgate._body |
read_capped #4 |
header-name case |
headgate.otel |
OpenTelemetryObserver.__init__ #26 |
the OpenTelemetry SDK lower-cases instrument names |
headgate._auth |
_read_hop #13 |
the status of the response built only to read the hop's body is never read |
headgate._auth |
_read_hop #18 |
the status of the response built only to decode the hop's body is never read |
headgate._auth |
BearerAuth.__init__ #3 |
header-name case |
headgate._auth |
BearerAuth.__init__ #4 |
header-name case |
headgate._auth |
CachedTokenAuth.__init__ #3 |
token_bytes(None) draws the default 32 bytes |
headgate._auth |
CachedTokenAuth.__init__ #4 |
a 33-byte random identity is as unique as a 32-byte one; it is only compared for equality |
headgate._auth |
CachedTokenAuth.attach #3 |
header-name case |
headgate._auth |
CachedTokenAuth.attach #4 |
header-name case |
headgate.pagination._link |
parse_link_header #4 |
at i == end the skip that follows breaks the loop at once |
headgate.pagination._link |
next_link #3 |
header-name case |
headgate.breaker._breaker |
CircuitBreaker._settle_probe #2 |
_probe_in_flight is only tested for truth |
headgate.cache._credentials |
CredentialScope.view #7 |
codec names are case-insensitive |
headgate.cache._credentials |
CredentialScope.view #12 |
the part tags stay distinct from one another, so the length-prefixed MAC input still separates every source |
headgate.cache._credentials |
CredentialScope.view #13 |
the part tags stay distinct from one another, so the length-prefixed MAC input still separates every source |
headgate.cache._credentials |
CredentialScope.view #16 |
codec names are case-insensitive |
headgate.cache._credentials |
CredentialScope.view #27 |
the part tags stay distinct from one another, so the length-prefixed MAC input still separates every source |
headgate.cache._credentials |
CredentialScope.view #28 |
the part tags stay distinct from one another, so the length-prefixed MAC input still separates every source |
headgate.cache._credentials |
CredentialScope.view #30 |
the part tags stay distinct from one another, so the length-prefixed MAC input still separates every source |
headgate.cache._credentials |
CredentialScope.view #31 |
the part tags stay distinct from one another, so the length-prefixed MAC input still separates every source |
headgate.cache._credentials |
CredentialScope.view #33 |
copy_with(userinfo=None) clears userinfo exactly as b"" |
headgate.cache._credentials |
CredentialScope._label #8 |
the part tags stay distinct from one another, so the length-prefixed MAC input still separates every source |
headgate.cache._credentials |
CredentialScope._label #9 |
the part tags stay distinct from one another, so the length-prefixed MAC input still separates every source |
headgate.cache._credentials |
CredentialScope._label #22 |
int.to_bytes defaults to big-endian |
headgate.cache._credentials |
CredentialScope._label #23 |
a 9-byte length prefix is as unambiguous as an 8-byte one |
headgate.cache._transport |
_guarded_chunks #1 |
the message never surfaces: the error becomes CacheStorageError("TypeError") and the transport falls back |
headgate.cache._transport |
_guarded_chunks #2 |
the message never surfaces: the error becomes CacheStorageError("TypeError") and the transport falls back |
headgate.cache._transport |
_guarded_chunks #3 |
the message never surfaces: the error becomes CacheStorageError("TypeError") and the transport falls back |
headgate.cache._transport |
_guarded_chunks #4 |
the message never surfaces: the error becomes CacheStorageError("TypeError") and the transport falls back |
headgate.cache._transport |
_Store.__init__ #5 |
_written is only tested for truth |
headgate.cache._transport |
_Store.__call__ #14 |
the id is random either way: hishel asks for none, so the deferred entry's is a fresh UUID, as the storage draws without one, and nothing reads it |
headgate.cache._transport |
_Store.__call__ #18 |
the id is random either way: hishel asks for none, so the deferred entry's is a fresh UUID, as the storage draws without one, and nothing reads it |
headgate.cache._transport |
ResilientCacheTransport._fetch #13 |
copy_with(userinfo=None) clears userinfo exactly as b"" |
headgate.cache._transport |
ResilientCacheTransport._fetch #24 |
httpx2's aiter_raw closes the response on every exit, so the explicit close finds nothing open |
headgate.cache._transport |
ResilientCacheTransport._fetch_cached #17 |
header-name case |
headgate.cache._transport |
ResilientCacheTransport._fetch_cached #27 |
once hishel returns, only reading its response can raise, and httpx2's aiter_raw closes the response on every exit |
headgate.cache._transport |
ResilientCacheTransport._fetch_cached #28 |
once hishel returns, only reading its response can raise, and httpx2's aiter_raw closes the response on every exit |
headgate.cache._policy |
filter_policy_of #6 |
MethodFilter upper-cases the methods it is given |
headgate.cache._conditional |
Validator.of #4 |
header-name case |
headgate.cache._conditional |
Validator.of #5 |
header-name case |
headgate.cache._conditional |
Validator.of #9 |
header-name case |
headgate.cache._conditional |
Validator.of #10 |
header-name case |
headgate.retry._transport |
_Sequence._hedged #10 |
keep is compared by identity, and no attempt is "" any more than None |
headgate.retry._transport |
_first_answer #5 |
a finished send the break skips stays in pending, so the next asyncio.wait hands it back at once |
headgate.retry._classify |
_should_retry #9 |
header-name case |
headgate.retry._classify |
_should_retry #10 |
a missing header read as xxxx is neither true nor false, like "" |
headgate._core._paths |
path_has_fragment #13 |
the extra start positions give only slices shorter than the fragment, which never equal it |
headgate._core._paths |
path_has_fragment #14 |
the extra start position gives only a slice shorter than the fragment, which never equals it |
headgate._core._events |
Emitter.emit #1 |
delivered is only tested for truth |
headgate._core._retry_after |
retry_after_of #9 |
header-name case |
headgate._core._retry_after |
retry_after_of #10 |
a missing header read as XXXX is not a number, like "" |
headgate._core._retry_after |
retry_after_of #16 |
header-name case |
headgate.security._transport |
address_hook #5 |
codec names are case-insensitive |
headgate.security._addresses |
resolve_with_loop #3 |
the port only fills the socket address's port, which is dropped |
headgate.security._addresses |
resolve_with_loop #7 |
the extra socket types repeat addresses, which are de-duplicated |
headgate.security._downgrade |
refuse_https_downgrade #12 |
header-name case |
headgate.security._downgrade |
refuse_https_downgrade #13 |
header-name case |
headgate.security._downgrade |
refuse_https_downgrade #18 |
header-name case |
headgate.security._downgrade |
refuse_https_downgrade #19 |
header-name case |
headgate.streaming._stream |
OpenStream.aiter_events #10 |
header-name case |
headgate.testing._transport |
_path #7 |
codec names are case-insensitive |
headgate.testing._codec |
check_codec #65 |
the added field only has to be one the record does not declare, which the renamed one still is |
headgate.testing._codec |
check_codec #67 |
the added field's value is never read |
headgate.testing._codec |
check_codec #68 |
the added field's value is never read |
headgate.testing.faults |
_read_timeout #12 |
the only non-number a read timeout can be is None, which the fallback also returns |
headgate.testing.faults |
_write #49 |
each fault call is written once, so = and += agree |
headgate.ratelimit._feedback |
_ietf_quota #9 |
header-name case |
headgate.ratelimit._feedback |
_ietf_quota #10 |
header-name case |
headgate.ratelimit._feedback |
_policy_units #9 |
header-name case |
headgate.ratelimit._feedback |
_policy_units #10 |
header-name case |
headgate.ratelimit._feedback |
_policy_units #11 |
a missing field read as the token XXXX names a policy no item refers to |
headgate.ratelimit._feedback |
_de_facto_quota #8 |
header-name case |
headgate.ratelimit._feedback |
_de_facto_quota #9 |
header-name case |
headgate.ratelimit._feedback |
_de_facto_quota #10 |
a missing header read as XXXX holds no number, like "" |
headgate.ratelimit._feedback |
_de_facto_quota #24 |
header-name case |
headgate.ratelimit._feedback |
_de_facto_quota #25 |
header-name case |
headgate.ratelimit._feedback |
_de_facto_quota #26 |
a missing header read as XXXX holds no number, like "" |