Fluent, chainable assertions for Django tests. Inspired by Laravel's elegant testing API.
Project description
pyssertive
Fluent, chainable assertions for Django tests. Inspired by Laravel's elegant testing API.
Features
- Fluent, chainable API for readable test assertions
- HTTP status code assertions (2xx, 3xx, 4xx, 5xx)
- JSON response validation with path navigation
- JSON Schema contract testing
- HTML content assertions
- Template and context assertions
- Form and formset error assertions
- Session and cookie assertions
- Header assertions
- Streaming response and file download assertions
- Architecture assertions for import boundaries, layers, and bounded-context isolation
- Debug helpers for test development
Requirements
- Python 3.10+
- Django 4.2, 5.2, or 6.0
Installation
pip install pyssertive
Usage
Basic Example
import pytest
from pyssertive.http import FluentHttpAssertClient
@pytest.fixture
def client():
from django.test import Client
return FluentHttpAssertClient(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
response.assert_ok() # 2xx
response.assert_created() # 201
response.assert_not_found() # 404
response.assert_forbidden() # 403
response.assert_redirect("/login/")
response.assert_status(418) # Any status code
JSON Assertions
response.assert_ok()\
.assert_json_path("user.name", "John")\
.assert_json_fragment({"status": "active"})\
.assert_json_count(5, path="items")\
.assert_json_structure({"id": int, "name": str})\
.assert_json_is_list()
Scoped JSON assertions with assert_json
Scope assertions to a nested path in the JSON response. The closure receives an AssertableJson bound to the resolved sub-tree; after it returns, the outer chain continues at the response level.
response.assert_ok().assert_json("data.user", lambda user: (
user
.where("id", 1)
.where_type("email", str)
.where("age", lambda v: v >= 18) # predicate
.missing("password")
.has("profile")
.json("profile", lambda p: p.where("verified", True)) # nesting
)).assert_json_path("meta.version", "1.0")
Inside an AssertableJson scope the assert_ prefix is dropped for brevity. Available methods:
| Method | Purpose |
|---|---|
has(key, count=None) |
Path exists, optionally with item count |
missing(key) |
Path does not exist |
where(key, expected) |
Value matches (exact or callable predicate) |
where_not(key, value) |
Value does not equal value (e.g. where_not("name", None)) |
where_truthy(key) |
Value is truthy (not None, 0, "", [], {}, False) |
where_falsy(key) |
Value is falsy |
where_type(key, type) |
Value is an instance of type |
count(n) |
Current scope has n items |
fragment(dict) / missing_fragment(dict) |
Subset match / absence |
exact(value) |
Full equality |
is_dict() / is_list() |
Type assertion |
structure(schema) |
Keys + types schema validation |
json(path, callback=None) |
Scope into a sub-path (recursive) |
matches_schema(schema) |
Validate against a JSON Schema (dict, file path, or URL) |
For ad-hoc use, assert_json() without a callback returns the AssertableJson directly:
root = response.assert_json()
root.has("data").where_type("data.users", list)
scoped = response.assert_json("data.users.0")
scoped.where("name", "Alice").where_type("id", int)
Breaking change:
assert_json()now returns anAssertableJsoninstead ofSelf. 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 eachassert_json_*method validates internally.
Deprecation note:
assert_json_is_objectandassert_json_is_arrayare deprecated in favor ofassert_json_is_dictandassert_json_is_list. They still work and emit aDeprecationWarning.
JSON Schema Validation
Validate entire JSON responses against a JSON Schema for contract testing. Accepts inline dicts, local files, or remote URLs.
# Inline schema
response.assert_ok().assert_json_schema({
"type": "object",
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
"email": {"type": "string", "format": "email"},
},
"required": ["id", "name", "email"],
})
Schema from a local file
Keep your schemas alongside your tests or in a shared schemas/ directory:
from pathlib import Path
SCHEMAS = Path(__file__).parent / "schemas"
def test_user_api(client):
client.get("/api/users/1/").assert_ok().assert_json_schema(SCHEMAS / "user.json")
A plain string path works too:
response.assert_json_schema("tests/schemas/user.json")
Schema from a URL
response.assert_json_schema("https://api.example.com/schemas/user.json")
Chaining with other assertions
assert_json_schema returns Self, so it chains naturally with every other assertion:
client.get("/api/users/1/")\
.assert_ok()\
.assert_json_schema(SCHEMAS / "user.json")\
.assert_json_path("name", "Alice")\
.assert_header("Content-Type", "application/json")
Scoped schema validation
Because matches_schema lives on AssertableJson, it works inside scoped callbacks too — validate a nested fragment against its own schema:
user_schema = {"type": "object", "properties": {"id": {"type": "integer"}, "name": {"type": "string"}}, "required": ["id", "name"]}
response.assert_json("data.user", lambda user: (
user.matches_schema(user_schema)
))
Error messages
When validation fails, the error message includes the exact path and reason:
AssertionError: JSON schema validation failed at 'email': 'not-an-email' is not a 'email'
HTML Assertions
Two explicit families of content assertions:
assert_see_html/assert_dont_see_html— operate on the raw HTML markup (tags preserved). Use when asserting tag structure, class names, attribute values, or specific HTML fragments.assert_see_text/assert_dont_see_text— operate on the rendered visible text (tags stripped). Use when asserting what a reader would see.
response.assert_see_html("<strong>Welcome</strong>")\
.assert_see_text("Welcome back, Alice")\
.assert_dont_see_text("Error")\
.assert_see_html_in_order(["<nav>", "<main>", "<footer>"])\
.assert_see_text_in_order(["Dashboard", "Reports", "Settings"])\
.assert_html_contains("<h1>Dashboard</h1>")
Scoped HTML assertions with assert_html
Scope assertions to a specific section of the response via CSS selectors. The closure receives an AssertableHtml bound to the matched sub-tree; after it returns, the outer chain continues at the document level.
response.assert_ok()\
.assert_see_text("Dashboard")\
.assert_html("table#active-users", lambda users: (
users
.count("tbody tr", 3)
.see_text("Alice")
.dont_see_text("banned@example.com")
.html("tr:first-child", lambda row: row.see_text("Alice")) # nesting
))\
.assert_html("section#billing", lambda s: s.see_text("alice@example.com"))\
.assert_html("section#audit-log", lambda s: s.dont_see_text("alice@example.com"))\
.assert_see_text("Footer text outside the table")
Inside an AssertableHtml scope the assert_ prefix is dropped for brevity. Available methods:
| Method | Purpose |
|---|---|
see_html(fragment) / dont_see_html(fragment) |
Raw HTML markup match (tags preserved) |
see_text(text) / dont_see_text(text) |
Rendered visible text match (tags stripped) |
see_html_in_order([...]) / see_text_in_order([...]) |
Ordered occurrence |
count(selector, n) |
Exactly n elements match the CSS selector |
exists(selector) |
At least one element matches |
missing(selector) |
No elements match |
html(selector, callback=None) |
Scope into the first matching element (recursive) |
html_contains(fragment) |
Django's semantic HTML comparison |
For ad-hoc use, assert_html() without a callback returns the AssertableHtml directly:
table = response.assert_html("table#active-users")
table.count("tbody tr", 3).see_text("Alice")
Deprecation note:
assert_see,assert_dont_see, andassert_see_in_orderare deprecated in favor of the explicit_html/_textpairs. They still work and emit aDeprecationWarningpointing at both replacements.
Session and Cookie Assertions
response.assert_session_has("user_id", 123)\
.assert_session_missing("temp_token")\
.assert_cookie("session_id")\
.assert_cookie_missing("tracking")
Template Assertions
response.assert_template_used("users/list.html")\
.assert_context_has("users")\
.assert_context_equals("page", 1)
Streaming and Download Assertions
response.assert_streaming()\
.assert_download("report.csv")\
.assert_streaming_contains("Expected content")\
.assert_streaming_not_contains("Sensitive data")\
.assert_streaming_matches(r"ID:\d+")\
.assert_streaming_line_count(exact=10)\
.assert_streaming_line_count(min=5, max=20)\
.assert_streaming_csv_header(["id", "name", "email"])\
.assert_streaming_line(0, "header,row")\
.assert_streaming_empty()
Debug Helpers
response.dump() # Print full response
response.dump_json() # Pretty print JSON
response.dump_headers() # Print headers
response.dump_session() # Print session data
response.dd() # Dump and die (raises exception)
Database Assertions
from pyssertive.db import (
assert_model_exists,
assert_model_count,
assert_num_queries,
)
assert_model_exists(User, username="john")
assert_model_count(User, 5)
with assert_num_queries(2):
list(User.objects.all())
Architecture Assertions
Fluent assertions for the shape of your imports — enforce layer boundaries, bounded-context isolation, allow-lists, and forbidden dependencies as ordinary pytest tests. Powered by grimp.
from pyssertive.arch import assert_arch
# Forbidden imports — direct or transitive
def test_shared_does_not_depend_on_bounded_contexts():
assert_arch("shared").should_not_depend_on([
"proxysubscription", "userprofile", "referral",
])
# Allow-list — `"stdlib"` is a magic token for any stdlib module
def test_domain_is_pure():
assert_arch("shared.domain").should_only_depend_on(["stdlib", "shared.domain"])
# Required dependency — verify a contract is still in place
def test_views_use_drf():
assert_arch("myapp.views").should_depend_on("rest_framework")
Method catalog
| Method | Purpose |
|---|---|
should_depend_on(target | [target], directly=False) |
Source must import target(s); transitive by default |
should_not_depend_on(target | [target], directly=False) |
Source must not import target(s); transitive by default |
should_only_depend_on(allowed | [allowed], directly=True) |
Every dependency must match the allow-list; direct by default. "stdlib" expands to any sys.stdlib_module_names entry |
ignoring(patterns) |
fnmatch glob patterns skipped during chain traversal — alternate non-ignored paths still flag |
module(name, callback=None) |
Scope into a submodule (recursive, glob supported) |
assert_arch.layers([...]).should_be_independent() |
Strict layered architecture — each layer may only depend on layers preceding it in the list |
assert_arch.modules([...]).should_be_isolated() |
Mutual isolation across an unordered set; combine with ignoring(...) to allow specific bridges |
Layered architecture
def test_layers_are_independent():
assert_arch.layers([
"myapp.domain",
"myapp.application",
"myapp.infrastructure",
]).should_be_independent()
Lower layers must not import higher ones. Skipping a layer downward (infra → domain directly) is allowed; only upward dependencies trigger a violation.
Bounded-context isolation
def test_bounded_contexts_are_isolated():
assert_arch.modules([
"proxysubscription", "accountsuspension", "referral",
]).should_be_isolated().ignoring(["*.events"])
No module in the set may depend on any other in either direction. ignoring(["*.events"]) grandfathers cross-talk through published event modules — alternate paths through non-ignored modules still surface.
Glob expansion
Glob patterns expand against the import graph and apply the assertion to every match, aggregating failures:
def test_models_dont_import_views():
assert_arch("myapp.*.models").should_not_depend_on(["myapp.*.views"])
def test_selectors_are_read_only():
assert_arch("myapp.*.selectors").should_not_depend_on([
"myapp.*.services", "myapp.*.use_cases",
])
A pattern that matches no module raises ValueError rather than passing silently.
Scoped callbacks
Scoped callbacks let a block of related assertions read as one expression:
def test_domain_module_internals():
assert_arch("myapp.domain", lambda d: (
d.should_only_depend_on(["stdlib", "myapp.domain"])
.module("events", lambda e: (
e.should_not_depend_on("myapp.domain.aggregates")
))
))
The nested assertable inherits the parent's ignoring(...) patterns automatically.
Direct vs transitive
The directly flag flips between checking only direct imports and checking the full transitive closure. Each method has a different default that matches the natural reading of the assertion:
# Transitive by default — even an indirect import counts.
assert_arch("myapp.views").should_not_depend_on("myapp.models")
# directly=True — tolerate transitive paths through services.
assert_arch("myapp.views").should_not_depend_on("myapp.models", directly=True)
# Direct by default — what the source code actually writes.
assert_arch("myapp.application").should_only_depend_on(["stdlib", "myapp.domain"])
# directly=False — strict purity; ban transitive Django leakage.
assert_arch("shared.domain").should_only_depend_on(["stdlib"], directly=False)
Error messages
Failures surface the offending import chain so you know exactly where to fix:
AssertionError: shared should not depend on:
- proxysubscription: shared → shared.auth.custom_basic_authentication → proxysubscription.models
Typo-style mistakes raise ValueError with a Did you mean ...? hint instead of a chain check.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyssertive-0.6.0.tar.gz.
File metadata
- Download URL: pyssertive-0.6.0.tar.gz
- Upload date:
- Size: 45.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9eeb790ffbba6c139762cffaa483089045c13291c02c2d642aabc46e662a7d5b
|
|
| MD5 |
1a9aeb48c0df73217413fd97d5be1759
|
|
| BLAKE2b-256 |
5636fc490061a26ec697635e9ee3be3ffad239909fc162feb5b5ad606b9cd424
|
Provenance
The following attestation bundles were made for pyssertive-0.6.0.tar.gz:
Publisher:
release.yml on othercodes/pyssertive
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyssertive-0.6.0.tar.gz -
Subject digest:
9eeb790ffbba6c139762cffaa483089045c13291c02c2d642aabc46e662a7d5b - Sigstore transparency entry: 1393124484
- Sigstore integration time:
-
Permalink:
othercodes/pyssertive@afe7c3727b2fbaa63a2674af1ad8beb3b6b038dc -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/othercodes
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@afe7c3727b2fbaa63a2674af1ad8beb3b6b038dc -
Trigger Event:
push
-
Statement type:
File details
Details for the file pyssertive-0.6.0-py3-none-any.whl.
File metadata
- Download URL: pyssertive-0.6.0-py3-none-any.whl
- Upload date:
- Size: 29.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7629f523920df8e1b3a7e6fbe11e9e54a587741b95e9fd9d4967a2bd0a1b87f6
|
|
| MD5 |
f498d75549371ea49f0450206944f574
|
|
| BLAKE2b-256 |
1b2954df30b45fbe750dca73000ebcd3392c401ee447ff3d43d2223d4970d00b
|
Provenance
The following attestation bundles were made for pyssertive-0.6.0-py3-none-any.whl:
Publisher:
release.yml on othercodes/pyssertive
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyssertive-0.6.0-py3-none-any.whl -
Subject digest:
7629f523920df8e1b3a7e6fbe11e9e54a587741b95e9fd9d4967a2bd0a1b87f6 - Sigstore transparency entry: 1393124537
- Sigstore integration time:
-
Permalink:
othercodes/pyssertive@afe7c3727b2fbaa63a2674af1ad8beb3b6b038dc -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/othercodes
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@afe7c3727b2fbaa63a2674af1ad8beb3b6b038dc -
Trigger Event:
push
-
Statement type: