diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d88eeee..46c1067 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -115,7 +115,7 @@ jobs: env: COVERAGE_FILE: .coverage.core-py${{ matrix.python-version }} run: | - uv run --no-sync pytest tests/formats/ tests/arch/ tests/protocols/ \ + uv run --no-sync pytest tests/test_*.py tests/formats/ tests/arch/ tests/protocols/ \ --cov-fail-under=0 --cov-report= - name: Upload coverage data diff --git a/README.md b/README.md index 7914c65..7848921 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,30 @@ [![Build Status](https://github.com/othercodes/pyssertive/actions/workflows/test.yml/badge.svg)](https://github.com/othercodes/pyssertive/actions/workflows/test.yml) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=othercodes_pyssertive&metric=coverage)](https://sonarcloud.io/summary/new_code?id=othercodes_pyssertive) -Fluent, chainable assertions for Python tests — HTTP, architecture, and database. Inspired by Laravel's elegant testing API. +Fluent, chainable assertions for everything you test in Python. One vocabulary — from a single value to HTTP responses, JSON, HTML, MCP, and architecture. Inspired by Laravel's elegant testing API. + +## Table of Contents + +- [Features](#features) +- [Requirements](#requirements) +- [Installation](#installation) +- [Usage](#usage) + - [Expectations](#expectations) + - [HTTP Status Assertions](#http-status-assertions) + - [JSON Assertions](#json-assertions) + - [JSON Schema Validation](#json-schema-validation) + - [HTML Assertions](#html-assertions) + - [Session and Cookie Assertions](#session-and-cookie-assertions) + - [Template Assertions](#template-assertions) + - [Streaming and Download Assertions](#streaming-and-download-assertions) + - [Debug Helpers](#debug-helpers) + - [Database Assertions](#database-assertions) + - [Architecture Assertions](#architecture-assertions) + - [MCP Assertions](#mcp-assertions) ## Features -- Fluent, chainable API for readable test assertions +- One fluent vocabulary for any value, response, or contract under test - HTTP status code assertions (2xx, 3xx, 4xx, 5xx) - JSON response validation with path navigation - JSON Schema contract testing @@ -37,7 +56,91 @@ pip install pyssertive[httpx] # with httpx adapter (FastAPI, Starlette, Fast ## Usage -### Basic Example +### Expectations + +Pyssertive's foundation is a single fluent vocabulary that speaks to any value +you put under test — a domain object, a primitive, a collection. Every other +assertable in this library (HTTP responses, JSON, HTML, MCP, architecture) +extends this same idiom. + +```python +from pyssertive import expect + +expect(42).equals(42) +expect("alice@example.com").is_instance_of(str).matches(r".+@.+") +expect([1, 2, 3]).has_count(3).contains(2).does_not_contain(99) +expect({"name": "alice", "age": 30}).has_keys("name", "age") +``` + +#### Domain example + +```python +def test_signup_should_create_active_user(): + user = signup_service.register(email="alice@x.com") + + expect(user).is_instance_of(User) + expect(user.id).is_not_none() + expect(user.email).equals("alice@x.com") + expect(user.permissions).contains("read").does_not_contain("admin") + expect(user.is_active).is_true() +``` + +#### Higher-order: `each` and `sequence` + +Apply matchers to every element of an iterable, or match positionally with +optional predicates: + +```python +expect([2, 4, 6]).each().is_greater_than(0).is_instance_of(int) + +expect(orders).sequence( + lambda o: o.has_attribute("status", "paid"), + lambda o: o.has_attribute("status", "shipped"), + lambda o: o.has_attribute("status", "delivered"), +) +``` + +#### Custom expectations + +Subclass `AssertableValue` to add domain-specific matchers — fully typed, +autocompleted by your IDE, checked by mypy: + +```python +from pyssertive import AssertableValue + +class UserExpectation(AssertableValue): + def is_admin(self): + assert "admin" in self._value.permissions, "Expected user to be admin" + return self + + def is_verified(self): + assert self._value.verified_at is not None, "Expected user to be verified" + return self + +def expect_user(u): return UserExpectation(u) + +expect_user(user).is_admin().is_verified().has_attribute("email") +``` + +#### Matchers reference + +| Category | Matchers | +| ------------ | ---------------------------------------------------------------------------------------------- | +| Equality | `equals`, `does_not_equal`, `is_same_as`, `is_not_same_as`, `is_none`, `is_not_none` | +| Truthiness | `is_truthy`, `is_falsy`, `is_true`, `is_false` | +| Types | `is_instance_of`, `is_not_instance_of`, `is_type` | +| Comparison | `is_greater_than`, `is_less_than`, `is_at_least`, `is_at_most`, `is_between` | +| Collections | `has_count`, `is_empty`, `is_not_empty`, `contains`, `does_not_contain` | +| Dict/object | `has_key`, `does_not_have_key`, `has_keys`, `has_attribute`, `does_not_have_attribute` | +| Strings | `matches`, `does_not_match`, `starts_with`, `ends_with` | +| Higher-order | `each`, `sequence` | + +The sections below show how the same vocabulary specializes for HTTP, JSON, +HTML, MCP, and architectural concerns. + +### HTTP Status Assertions + +A complete end-to-end test wires up a `FluentHttpAssertClient` and chains assertions on the response: ```python import pytest @@ -51,13 +154,13 @@ def client(): @pytest.mark.django_db def test_user_api(client): response = client.get("/api/users/") - + response.assert_ok()\ .assert_json_path("count", 10)\ .assert_header("Content-Type", "application/json") ``` -### HTTP Status Assertions +Status-specific shortcuts: ```python response.assert_ok() # 2xx @@ -124,8 +227,6 @@ scoped = response.assert_json("data.users.0") scoped.where("name", "Alice").where_type("id", int) ``` -> **Breaking change:** `assert_json()` now returns an `AssertableJson` instead of `Self`. Code that chains `.assert_json()` in the middle of a response chain (e.g. `response.assert_json().assert_json_path(...)`) should drop the `.assert_json()` call — it was always redundant since each `assert_json_*` method validates internally. - ### JSON Schema Validation Validate entire JSON responses against a [JSON Schema](https://json-schema.org/) for contract testing. Accepts inline dicts, local files, or remote URLs. @@ -299,6 +400,8 @@ response.dd() # Dump and die (raises exception) ### Database Assertions +> Requires the Django adapter (`pip install pyssertive[django]`). + ```python from pyssertive.adapters.django.db import ( assert_model_exists, @@ -436,6 +539,8 @@ Typo-style mistakes raise `ValueError` with a `Did you mean ...?` hint instead o The MCP module speaks **MCP, not JSON-RPC**. Tests read as the protocol does — `called the tool, it succeeded, it returned text` — never as wire-level shape checks. Works against any response object exposing `.content` and `.headers` (httpx, Django, raw `dict`), and unwraps `text/event-stream` (Streamable HTTP transport) automatically. +#### Initialize handshake + ```python from pyssertive.adapters.httpx import FluentHttpAssertClient, HttpxRequestBuilder from pyssertive.http.mcp import MessageBuilder @@ -445,7 +550,6 @@ from fastmcp import FastMCP app = FastMCP("weather").http_app() client = FluentHttpAssertClient(TestClient(app)) -# Initialize handshake init = MessageBuilder(HttpxRequestBuilder()).initialize().build() client.post("/mcp", content=init.content, headers=dict(init.headers))\ .assert_ok()\ @@ -454,8 +558,11 @@ client.post("/mcp", content=init.content, headers=dict(init.headers))\ .server_named("weather") .supports_tools() ) +``` + +#### Tool call — success -# Tool call — success +```python call = MessageBuilder(HttpxRequestBuilder())\ .calling_tool("get_weather", arguments={"location": "Madrid"})\ .build() @@ -465,22 +572,28 @@ client.post("/mcp", content=call.content, headers=dict(call.headers))\ .succeeds() .returns_text_containing("°C") ) +``` + +#### Tool-level error (HTTP 200, isError=true) -# Tool call — tool-level error (HTTP 200, isError=true) +```python response.assert_mcp(lambda m: ( m.tool("get_weather") .reports_tool_error() .with_message_containing("Invalid date") )) +``` -# Tool call — protocol error (-32602, -32601, ...) +#### Protocol error (-32601, -32602, ...) + +```python response.assert_mcp(lambda m: ( m.tool("unknown") .is_rejected_as_unknown_tool() )) ``` -Stand-alone (no HTTP wrapper) is also supported: +#### Stand-alone (no HTTP wrapper) ```python from pyssertive.protocols.mcp import AssertableMCP @@ -492,7 +605,9 @@ AssertableMCP(payload).lists_tools()\ )) ``` -Catalog-wide invariants — apply the same assertion to every tool without enumerating names. Useful when a server rewrites its tool schema per caller (auth scopes, feature flags): +#### Catalog-wide invariants + +Apply the same assertion to every tool without enumerating names. Useful when a server rewrites its tool schema per caller (auth scopes, feature flags): ```python AssertableMCP(payload).lists_tools().every_tool( @@ -500,6 +615,70 @@ AssertableMCP(payload).lists_tools().every_tool( ) ``` +#### Method catalog + +The MCP module exposes five assertable types, each scoped to a different MCP structure. Navigate between them with `lists_tools()`, `tool(name)`, the `contains_tool` / `every_tool` callbacks (yielding `AssertableToolDef`), and the `content` callback (yielding `AssertableContent`). + +**`AssertableMCP`** — top-level envelope (JSON-RPC response): + +| Method | Purpose | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------| +| `negotiated_protocol(version)` | Asserts the negotiated MCP protocol version | +| `server_named(name)` / `server_version(version)` | Asserts the server's advertised identity | +| `supports_tools()` / `supports_resources(*, subscribe=None)` / `supports_prompts()` / `supports_logging()` | Asserts a server capability is advertised | +| `has_instructions()` | Asserts the `instructions` field is present | +| `is_rejected_as_invalid_request()` / `is_rejected_as_method_not_found()` / `is_rejected_with_invalid_params()` / `is_rejected_as_internal_error()` / `is_rejected_as_resource_not_found()` / `is_rejected_as_user_rejected()` | Asserts a specific JSON-RPC error code | +| `because_message_contains(substr)` | Asserts the error message contains a substring | +| `lists_tools()` | Scopes into `AssertableToolList` (tools/list response) | +| `tool(name)` | Scopes into `AssertableToolCall` (tools/call response) | +| `is_success()` / `has_error()` / `result()` / `error()` / `error_code()` / `error_message()` | Read-only accessors for raw envelope inspection (escape hatches) | + +**`AssertableToolList`** — `tools/list` result: + +| Method | Purpose | +|-------------------------------------|--------------------------------------------------------------------------| +| `with_count(n)` | Asserts the list has exactly `n` tools | +| `contains_tool(name, callback=None)`| Asserts a tool exists; optional callback for per-tool drill-in | +| `does_not_contain_tool(name)` | Asserts a tool is absent | +| `every_tool(callback)` | Applies the callback to every tool in the list | +| `has_more_pages()` | Asserts the response advertises a `nextCursor` | + +**`AssertableToolDef`** — a single tool definition (received via `contains_tool` / `every_tool` callbacks): + +| Method | Purpose | +|------------------------------|---------------------------------------------------------------| +| `documented()` | Asserts the tool has a non-empty description | +| `accepts(params)` | Asserts each param is in `inputSchema.required` | +| `accepts_optional(params)` | Asserts each param is in `inputSchema.properties` | +| `does_not_accept(params)` | Asserts each param is NOT in `inputSchema.properties` | +| `has_output_schema()` | Asserts `outputSchema` is present | + +**`AssertableToolCall`** — `tools/call` result: + +| Method | Purpose | +|-----------------------------------------------------|--------------------------------------------------------------------------| +| `succeeds()` | Asserts the call did not return `isError=true` | +| `returns_text(expected)` | Asserts a text content block exactly matches | +| `returns_text_containing(substr)` | Asserts a text content block contains a substring | +| `returns_image(*, mime_type=None)` | Asserts an image content block exists (optionally with mime type) | +| `returns_content_count(n)` | Asserts the number of content blocks | +| `returns_structured(expected)` | Asserts `structuredContent` equals expected | +| `content(index, callback)` | Scopes into `AssertableContent` for a specific block | +| `reports_tool_error()` | Asserts the call returned `isError=true` (tool-level error) | +| `with_message_containing(substr)` | Asserts the error message contains a substring | +| `is_rejected_as_unknown_tool()` | Asserts JSON-RPC `-32601` or `-32602` with "unknown tool" message | +| `is_rejected_with_invalid_params()` | Asserts JSON-RPC `-32602` | + +**`AssertableContent`** — a single content block (received via `content` callback): + +| Method | Purpose | +|---------------------------------------------------------------------------------------------------|------------------------------------------| +| `is_text()` / `is_image()` / `is_audio()` / `is_resource_link()` / `is_resource()` | Asserts the block type | +| `text_equals(expected)` | Asserts text block equals expected | +| `text_contains(substr)` | Asserts text block contains a substring | +| `with_mime_type(expected)` | Asserts the block's mime type | +| `with_uri(expected)` | Asserts the resource URI | + #### Building requests with `MessageBuilder` `MessageBuilder` constructs MCP JSON-RPC messages with native MCP vocabulary on top of any transport-level `RequestBuilder`. Inject `HttpxRequestBuilder` for FastAPI/Starlette/FastMCP testing, or `DjangoRequestBuilder` for Django-hosted MCP servers — the output of `.build()` matches whichever you inject. @@ -511,33 +690,38 @@ from pyssertive.adapters.httpx import HttpxRequestBuilder # Handshake — returns httpx.Request MessageBuilder(HttpxRequestBuilder()).initialize(protocol="2025-11-25").build() -# Tool list / tool call -MessageBuilder(HttpxRequestBuilder()).listing_tools(cursor="abc").build() +# Tool call with arguments MessageBuilder(HttpxRequestBuilder()).calling_tool("get_weather", arguments={"location": "Madrid"}).build() -# Resources / prompts -MessageBuilder(HttpxRequestBuilder()).reading_resource("file:///main.py").build() -MessageBuilder(HttpxRequestBuilder()).getting_prompt("code_review", arguments={"lang": "python"}).build() - -# Notifications (no id field, no params if not provided) -MessageBuilder(HttpxRequestBuilder()).notifying("notifications/initialized").build() - -# Low-level escape hatch -MessageBuilder(HttpxRequestBuilder()).calling("logging/setLevel").with_params({"level": "debug"}).build() - -# Auth / protocol / session headers +# Auth / protocol / session headers chain MessageBuilder(HttpxRequestBuilder())\ .with_auth_token("abc123")\ .with_protocol_version("2025-11-25")\ .with_session_id("sess-xyz")\ .calling_tool("ping")\ .build() - -# Explicit id, custom MCP path -MessageBuilder(HttpxRequestBuilder(), path="/v1/mcp")\ - .calling_tool("ping").with_id("req-uuid").build() ``` +**Method catalog:** + +| Method | Purpose | +|--------------------------------------------------------------|-------------------------------------------------------------------------------| +| `MessageBuilder(request_builder, path="/mcp")` | Constructor — inject any `RequestBuilder`; `path` overrides the MCP endpoint | +| `with_id(msg_id)` | Set an explicit JSON-RPC id (default: auto-generated) | +| `with_auth_token(token)` | Adds `Authorization: Bearer ` header | +| `with_protocol_version(version)` | Adds `MCP-Protocol-Version` header | +| `with_session_id(session_id)` | Adds `Mcp-Session-Id` header | +| `initialize(*, protocol=…, name=…, version=…)` | `initialize` handshake | +| `listing_tools(*, cursor=None)` | `tools/list` | +| `calling_tool(name, *, arguments=None)` | `tools/call` | +| `listing_resources(*, cursor=None)` | `resources/list` | +| `reading_resource(uri)` | `resources/read` | +| `listing_prompts(*, cursor=None)` | `prompts/list` | +| `getting_prompt(name, *, arguments=None)` | `prompts/get` | +| `notifying(method, *, params=None)` | `notifications/*` — fire-and-forget message (no `id` field) | +| `calling(method)` / `with_params(params)` | Low-level escape hatch for arbitrary JSON-RPC methods | +| `build()` | Returns the request object (`httpx.Request`, `HttpRequest`, etc.) | + Pair it naturally with `assert_mcp` for symmetric request/response code: ```python diff --git a/src/pyssertive/__init__.py b/src/pyssertive/__init__.py index de47bef..e26bb58 100644 --- a/src/pyssertive/__init__.py +++ b/src/pyssertive/__init__.py @@ -1 +1,7 @@ from pyssertive._version import __version__ as __version__ +from pyssertive.assertions import AssertableValue, expect + +__all__ = [ + "AssertableValue", + "expect", +] diff --git a/src/pyssertive/assertions.py b/src/pyssertive/assertions.py new file mode 100644 index 0000000..7bf1adb --- /dev/null +++ b/src/pyssertive/assertions.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import re +import sys +from collections.abc import Callable +from typing import Any + +if sys.version_info >= (3, 11): # pragma: no cover + from typing import Self +else: # pragma: no cover + from typing_extensions import Self + +_MISSING: Any = object() + + +class AssertableValue: + """ + Fluent assertions over an arbitrary Python value. + + Created directly or via the :func:`expect` factory. Methods return + ``Self`` to allow chaining. Use :meth:`each` to apply matchers to every + element of an iterable, and :meth:`sequence` to match elements positionally. + """ + + def __init__(self, value: Any) -> None: + self._value = value + + def equals(self, expected: Any) -> Self: + assert self._value == expected, f"Expected value to equal {expected!r}, got {self._value!r}" + return self + + def does_not_equal(self, unexpected: Any) -> Self: + assert self._value != unexpected, f"Expected value to not equal {unexpected!r}" + return self + + def is_same_as(self, expected: Any) -> Self: + assert self._value is expected, f"Expected value to be the same instance as {expected!r}" + return self + + def is_not_same_as(self, unexpected: Any) -> Self: + assert self._value is not unexpected, f"Expected value to not be the same instance as {unexpected!r}" + return self + + def is_none(self) -> Self: + assert self._value is None, f"Expected value to be None, got {self._value!r}" + return self + + def is_not_none(self) -> Self: + assert self._value is not None, "Expected value to not be None" + return self + + def is_truthy(self) -> Self: + assert self._value, f"Expected value to be truthy, got {self._value!r}" + return self + + def is_falsy(self) -> Self: + assert not self._value, f"Expected value to be falsy, got {self._value!r}" + return self + + def is_true(self) -> Self: + assert self._value is True, f"Expected value to be True, got {self._value!r}" + return self + + def is_false(self) -> Self: + assert self._value is False, f"Expected value to be False, got {self._value!r}" + return self + + def is_instance_of(self, cls: type | tuple[type, ...]) -> Self: + type_names = " | ".join(t.__name__ for t in cls) if isinstance(cls, tuple) else cls.__name__ + assert isinstance(self._value, cls), ( + f"Expected value to be instance of {type_names}, got {type(self._value).__name__}" + ) + return self + + def is_not_instance_of(self, cls: type | tuple[type, ...]) -> Self: + type_names = " | ".join(t.__name__ for t in cls) if isinstance(cls, tuple) else cls.__name__ + assert not isinstance(self._value, cls), f"Expected value to not be instance of {type_names}" + return self + + def is_type(self, cls: type) -> Self: + assert type(self._value) is cls, ( + f"Expected value to be of exact type {cls.__name__}, got {type(self._value).__name__}" + ) + return self + + def is_greater_than(self, other: Any) -> Self: + assert self._value > other, f"Expected value to be greater than {other!r}, got {self._value!r}" + return self + + def is_less_than(self, other: Any) -> Self: + assert self._value < other, f"Expected value to be less than {other!r}, got {self._value!r}" + return self + + def is_at_least(self, other: Any) -> Self: + assert self._value >= other, f"Expected value to be at least {other!r}, got {self._value!r}" + return self + + def is_at_most(self, other: Any) -> Self: + assert self._value <= other, f"Expected value to be at most {other!r}, got {self._value!r}" + return self + + def is_between(self, low: Any, high: Any) -> Self: + assert low <= self._value <= high, ( + f"Expected value to be between {low!r} and {high!r} (inclusive), got {self._value!r}" + ) + return self + + def has_count(self, expected: int) -> Self: + actual = len(self._value) + assert actual == expected, f"Expected count of {expected}, got {actual}" + return self + + def is_empty(self) -> Self: + assert len(self._value) == 0, f"Expected value to be empty, got {self._value!r}" + return self + + def is_not_empty(self) -> Self: + assert len(self._value) > 0, "Expected value to not be empty" + return self + + def contains(self, *items: Any) -> Self: + for item in items: + assert item in self._value, f"Expected value to contain {item!r}" + return self + + def does_not_contain(self, *items: Any) -> Self: + for item in items: + assert item not in self._value, f"Expected value to not contain {item!r}" + return self + + def has_key(self, key: str) -> Self: + assert isinstance(self._value, dict), f"Expected a dict, got {type(self._value).__name__}" + assert key in self._value, f"Expected dict to have key {key!r}" + return self + + def does_not_have_key(self, key: str) -> Self: + assert isinstance(self._value, dict), f"Expected a dict, got {type(self._value).__name__}" + assert key not in self._value, f"Expected dict to not have key {key!r}" + return self + + def has_keys(self, *keys: str) -> Self: + assert isinstance(self._value, dict), f"Expected a dict, got {type(self._value).__name__}" + for key in keys: + assert key in self._value, f"Expected dict to have key {key!r}" + return self + + def has_attribute(self, name: str, value: Any = _MISSING) -> Self: + assert hasattr(self._value, name), f"Expected object to have attribute {name!r}" + if value is not _MISSING: + actual = getattr(self._value, name) + assert actual == value, f"Expected attribute {name!r} to equal {value!r}, got {actual!r}" + return self + + def does_not_have_attribute(self, name: str) -> Self: + assert not hasattr(self._value, name), f"Expected object to not have attribute {name!r}" + return self + + def matches(self, pattern: str) -> Self: + assert re.search(pattern, self._value) is not None, f"Expected {self._value!r} to match pattern '{pattern}'" + return self + + def does_not_match(self, pattern: str) -> Self: + assert re.search(pattern, self._value) is None, f"Expected {self._value!r} to not match pattern '{pattern}'" + return self + + def starts_with(self, prefix: str) -> Self: + assert self._value.startswith(prefix), f"Expected {self._value!r} to start with {prefix!r}" + return self + + def ends_with(self, suffix: str) -> Self: + assert self._value.endswith(suffix), f"Expected {self._value!r} to end with {suffix!r}" + return self + + def each(self) -> _Each: + assert hasattr(self._value, "__iter__"), f"Expected an iterable, got {type(self._value).__name__}" + return _Each(self._value) + + def sequence(self, *expected: Any) -> Self: + assert hasattr(self._value, "__iter__"), f"Expected an iterable, got {type(self._value).__name__}" + items = list(self._value) + assert len(items) == len(expected), f"Expected sequence of length {len(expected)}, got {len(items)}" + for item, exp in zip(items, expected, strict=True): + sub = AssertableValue(item) + if callable(exp): + exp(sub) + else: + sub.equals(exp) + return self + + +class _Each: + def __init__(self, items: Any) -> None: + self._items = list(items) + + def __getattr__(self, name: str) -> Callable[..., _Each]: + def apply(*args: Any, **kwargs: Any) -> _Each: + for item in self._items: + getattr(AssertableValue(item), name)(*args, **kwargs) + return self + + return apply + + +def expect(value: Any) -> AssertableValue: + return AssertableValue(value) diff --git a/tests/test_assertions.py b/tests/test_assertions.py new file mode 100644 index 0000000..de0c664 --- /dev/null +++ b/tests/test_assertions.py @@ -0,0 +1,453 @@ +from __future__ import annotations + +import pytest + +from pyssertive import AssertableValue, expect + + +def test_expect_should_return_assertable_value(): + assert isinstance(expect(42), AssertableValue) + + +def test_assertable_value_should_wrap_arbitrary_value(): + assert AssertableValue("hello")._value == "hello" + + +def test_equals_should_pass_when_values_are_equal(): + expect(42).equals(42) + + +def test_equals_should_raise_when_values_differ(): + with pytest.raises(AssertionError, match="Expected value to equal 42, got 7"): + expect(7).equals(42) + + +def test_equals_should_return_self_for_chaining(): + assert expect(42).equals(42) is not None + + +def test_does_not_equal_should_pass_when_values_differ(): + expect(7).does_not_equal(42) + + +def test_does_not_equal_should_raise_when_values_are_equal(): + with pytest.raises(AssertionError, match="Expected value to not equal 42"): + expect(42).does_not_equal(42) + + +def test_is_same_as_should_pass_when_identity_matches(): + sentinel = object() + expect(sentinel).is_same_as(sentinel) + + +def test_is_same_as_should_raise_when_identity_differs(): + with pytest.raises(AssertionError, match="Expected value to be the same instance"): + expect([1]).is_same_as([1]) + + +def test_is_not_same_as_should_pass_when_identity_differs(): + expect([1]).is_not_same_as([1]) + + +def test_is_not_same_as_should_raise_when_identity_matches(): + sentinel = object() + with pytest.raises(AssertionError, match="Expected value to not be the same instance"): + expect(sentinel).is_not_same_as(sentinel) + + +def test_is_none_should_pass_when_value_is_none(): + expect(None).is_none() + + +def test_is_none_should_raise_when_value_is_not_none(): + with pytest.raises(AssertionError, match="Expected value to be None, got 0"): + expect(0).is_none() + + +def test_is_not_none_should_pass_when_value_is_not_none(): + expect(0).is_not_none() + + +def test_is_not_none_should_raise_when_value_is_none(): + with pytest.raises(AssertionError, match="Expected value to not be None"): + expect(None).is_not_none() + + +@pytest.mark.parametrize("value", [1, "x", [0], {"a": 1}, True]) +def test_is_truthy_should_pass_for_truthy_values(value): + expect(value).is_truthy() + + +def test_is_truthy_should_raise_for_falsy_value(): + with pytest.raises(AssertionError, match="Expected value to be truthy"): + expect(0).is_truthy() + + +@pytest.mark.parametrize("value", [0, "", [], {}, None, False]) +def test_is_falsy_should_pass_for_falsy_values(value): + expect(value).is_falsy() + + +def test_is_falsy_should_raise_for_truthy_value(): + with pytest.raises(AssertionError, match="Expected value to be falsy"): + expect(1).is_falsy() + + +def test_is_true_should_pass_when_value_is_true(): + expect(True).is_true() + + +def test_is_true_should_raise_when_value_is_truthy_but_not_true(): + with pytest.raises(AssertionError, match="Expected value to be True, got 1"): + expect(1).is_true() + + +def test_is_false_should_pass_when_value_is_false(): + expect(False).is_false() + + +def test_is_false_should_raise_when_value_is_falsy_but_not_false(): + with pytest.raises(AssertionError, match="Expected value to be False, got 0"): + expect(0).is_false() + + +@pytest.mark.parametrize("value, cls", [("hello", str), (42, (int, str))]) +def test_is_instance_of_should_pass(value, cls): + expect(value).is_instance_of(cls) + + +def test_is_instance_of_should_pass_for_subclass(): + class Animal: + pass + + class Dog(Animal): + pass + + expect(Dog()).is_instance_of(Animal) + + +@pytest.mark.parametrize( + "value, cls, pattern", + [ + ("hello", int, "Expected value to be instance of int, got str"), + ("hello", (int, float), r"Expected value to be instance of int \| float, got str"), + ], +) +def test_is_instance_of_should_raise(value, cls, pattern): + with pytest.raises(AssertionError, match=pattern): + expect(value).is_instance_of(cls) + + +@pytest.mark.parametrize("cls", [int, (int, float)]) +def test_is_not_instance_of_should_pass(cls): + expect("hello").is_not_instance_of(cls) + + +@pytest.mark.parametrize( + "cls, pattern", + [ + (str, "Expected value to not be instance of str"), + ((int, str), r"Expected value to not be instance of int \| str"), + ], +) +def test_is_not_instance_of_should_raise(cls, pattern): + with pytest.raises(AssertionError, match=pattern): + expect("hello").is_not_instance_of(cls) + + +def test_is_type_should_pass_for_exact_type(): + expect(True).is_type(bool) + + +def test_is_type_should_raise_for_subclass(): + class Animal: + pass + + class Dog(Animal): + pass + + with pytest.raises(AssertionError, match="Expected value to be of exact type Animal, got Dog"): + expect(Dog()).is_type(Animal) + + +def test_is_greater_than_should_pass_when_value_is_greater(): + expect(10).is_greater_than(5) + + +def test_is_greater_than_should_raise_when_value_is_equal(): + with pytest.raises(AssertionError, match="Expected value to be greater than 5, got 5"): + expect(5).is_greater_than(5) + + +def test_is_less_than_should_pass_when_value_is_less(): + expect(5).is_less_than(10) + + +def test_is_less_than_should_raise_when_value_is_equal(): + with pytest.raises(AssertionError, match="Expected value to be less than 5, got 5"): + expect(5).is_less_than(5) + + +def test_is_at_least_should_pass_when_value_is_equal(): + expect(5).is_at_least(5) + + +def test_is_at_least_should_pass_when_value_is_greater(): + expect(10).is_at_least(5) + + +def test_is_at_least_should_raise_when_value_is_less(): + with pytest.raises(AssertionError, match="Expected value to be at least 5, got 3"): + expect(3).is_at_least(5) + + +def test_is_at_most_should_pass_when_value_is_equal(): + expect(5).is_at_most(5) + + +def test_is_at_most_should_pass_when_value_is_less(): + expect(3).is_at_most(5) + + +def test_is_at_most_should_raise_when_value_is_greater(): + with pytest.raises(AssertionError, match="Expected value to be at most 5, got 7"): + expect(7).is_at_most(5) + + +def test_is_between_should_pass_for_value_inside_range(): + expect(5).is_between(1, 10) + + +def test_is_between_should_pass_for_boundary_values(): + expect(1).is_between(1, 10) + expect(10).is_between(1, 10) + + +def test_is_between_should_raise_for_value_outside_range(): + with pytest.raises(AssertionError, match=r"Expected value to be between 1 and 10 \(inclusive\), got 15"): + expect(15).is_between(1, 10) + + +def test_has_count_should_pass_when_length_matches(): + expect([1, 2, 3]).has_count(3) + + +def test_has_count_should_work_on_strings(): + expect("hello").has_count(5) + + +def test_has_count_should_work_on_dicts(): + expect({"a": 1, "b": 2}).has_count(2) + + +def test_has_count_should_raise_when_length_differs(): + with pytest.raises(AssertionError, match="Expected count of 3, got 2"): + expect([1, 2]).has_count(3) + + +def test_is_empty_should_pass_for_empty_collection(): + expect([]).is_empty() + expect("").is_empty() + expect({}).is_empty() + + +def test_is_empty_should_raise_for_non_empty(): + with pytest.raises(AssertionError, match="Expected value to be empty"): + expect([1]).is_empty() + + +def test_is_not_empty_should_pass_for_non_empty(): + expect([1]).is_not_empty() + + +def test_is_not_empty_should_raise_for_empty(): + with pytest.raises(AssertionError, match="Expected value to not be empty"): + expect([]).is_not_empty() + + +def test_contains_should_pass_when_item_present(): + expect([1, 2, 3]).contains(2) + + +def test_contains_should_pass_when_all_items_present(): + expect([1, 2, 3]).contains(1, 3) + + +def test_contains_should_work_on_strings(): + expect("hello world").contains("world") + + +def test_contains_should_raise_when_item_absent(): + with pytest.raises(AssertionError, match="Expected value to contain 5"): + expect([1, 2, 3]).contains(5) + + +def test_does_not_contain_should_pass_when_items_absent(): + expect([1, 2, 3]).does_not_contain(5, 6) + + +def test_does_not_contain_should_raise_when_item_present(): + with pytest.raises(AssertionError, match="Expected value to not contain 2"): + expect([1, 2, 3]).does_not_contain(2) + + +def test_has_key_should_pass_when_key_present(): + expect({"a": 1, "b": 2}).has_key("a") + + +def test_has_key_should_raise_when_key_absent(): + with pytest.raises(AssertionError, match="Expected dict to have key 'missing'"): + expect({"a": 1}).has_key("missing") + + +def test_has_key_should_raise_when_value_is_not_a_dict(): + with pytest.raises(AssertionError, match="Expected a dict, got list"): + expect([1, 2, 3]).has_key("a") + + +def test_does_not_have_key_should_pass_when_key_absent(): + expect({"a": 1}).does_not_have_key("missing") + + +def test_does_not_have_key_should_raise_when_key_present(): + with pytest.raises(AssertionError, match="Expected dict to not have key 'a'"): + expect({"a": 1}).does_not_have_key("a") + + +def test_has_keys_should_pass_when_all_keys_present(): + expect({"a": 1, "b": 2, "c": 3}).has_keys("a", "b") + + +def test_has_keys_should_raise_when_any_key_missing(): + with pytest.raises(AssertionError, match="Expected dict to have key 'b'"): + expect({"a": 1}).has_keys("a", "b") + + +def test_has_attribute_should_pass_when_attribute_exists(): + class Obj: + name = "alice" + + expect(Obj()).has_attribute("name") + + +def test_has_attribute_should_pass_when_attribute_matches_expected_value(): + class Obj: + name = "alice" + + expect(Obj()).has_attribute("name", "alice") + + +def test_has_attribute_should_raise_when_attribute_missing(): + class Obj: + pass + + with pytest.raises(AssertionError, match="Expected object to have attribute 'name'"): + expect(Obj()).has_attribute("name") + + +def test_has_attribute_should_raise_when_attribute_value_mismatches(): + class Obj: + name = "bob" + + with pytest.raises(AssertionError, match="Expected attribute 'name' to equal 'alice', got 'bob'"): + expect(Obj()).has_attribute("name", "alice") + + +def test_does_not_have_attribute_should_pass_when_attribute_missing(): + class Obj: + pass + + expect(Obj()).does_not_have_attribute("name") + + +def test_does_not_have_attribute_should_raise_when_attribute_present(): + class Obj: + name = "alice" + + with pytest.raises(AssertionError, match="Expected object to not have attribute 'name'"): + expect(Obj()).does_not_have_attribute("name") + + +def test_matches_should_pass_when_regex_matches(): + expect("hello world").matches(r"world") + + +def test_matches_should_use_search_semantics(): + expect("abc123def").matches(r"\d+") + + +def test_matches_should_raise_when_regex_does_not_match(): + with pytest.raises(AssertionError, match=r"Expected 'hello' to match pattern '\\d\+'"): + expect("hello").matches(r"\d+") + + +def test_does_not_match_should_pass_when_regex_does_not_match(): + expect("hello").does_not_match(r"\d+") + + +def test_does_not_match_should_raise_when_regex_matches(): + with pytest.raises(AssertionError, match=r"Expected 'hello123' to not match pattern '\\d\+'"): + expect("hello123").does_not_match(r"\d+") + + +def test_starts_with_should_pass_when_prefix_matches(): + expect("hello world").starts_with("hello") + + +def test_starts_with_should_raise_when_prefix_does_not_match(): + with pytest.raises(AssertionError, match="Expected 'hello' to start with 'world'"): + expect("hello").starts_with("world") + + +def test_ends_with_should_pass_when_suffix_matches(): + expect("hello world").ends_with("world") + + +def test_ends_with_should_raise_when_suffix_does_not_match(): + with pytest.raises(AssertionError, match="Expected 'hello' to end with 'world'"): + expect("hello").ends_with("world") + + +def test_each_should_apply_matcher_to_all_elements(): + expect([2, 4, 6]).each().is_greater_than(0) + + +def test_each_should_raise_when_any_element_fails(): + with pytest.raises(AssertionError, match="Expected value to be greater than 0, got -3"): + expect([2, 4, -3]).each().is_greater_than(0) + + +def test_each_should_allow_chaining_multiple_matchers(): + expect([2, 4, 6]).each().is_instance_of(int).is_greater_than(0) + + +def test_each_should_raise_when_value_is_not_iterable(): + with pytest.raises(AssertionError, match="Expected an iterable, got int"): + expect(42).each() + + +def test_sequence_should_pass_when_values_match_positionally(): + expect([1, 2, 3]).sequence(1, 2, 3) + + +def test_sequence_should_accept_predicates(): + expect([1, 2, 3]).sequence( + lambda v: v.equals(1), + lambda v: v.is_greater_than(1), + lambda v: v.is_greater_than(2), + ) + + +def test_sequence_should_raise_when_lengths_differ(): + with pytest.raises(AssertionError, match="Expected sequence of length 3, got 2"): + expect([1, 2]).sequence(1, 2, 3) + + +def test_sequence_should_raise_when_a_value_mismatches(): + with pytest.raises(AssertionError, match="Expected value to equal 99, got 3"): + expect([1, 2, 3]).sequence(1, 2, 99) + + +def test_sequence_should_raise_when_value_is_not_iterable(): + with pytest.raises(AssertionError, match="Expected an iterable, got int"): + expect(42).sequence(1, 2, 3)