A type-safe, structured testing library for Jinja templates
Project description
jinjatest
A type-safe, structured testing library for Jinja templates.
Installation
uv add jinjatest # or: uv add jinjatest[yaml]
Features
- Type-safe context validation with Pydantic
- Structured output parsing (JSON, YAML, XML, markdown, fenced code blocks)
- Test instrumentation with anchors and traces
- Pytest integration with fixtures and snapshots
- StrictUndefined by default - missing variables fail loudly
- Coverage output - output the test coverage for Jinja2 templates
Quick Start
Basic Usage
from pydantic import BaseModel
from jinjatest import TemplateSpec, PromptAsserts
class Ctx(BaseModel):
user_name: str
plan: str # "free" | "pro"
# Load template with context validation
spec = TemplateSpec.from_file("prompts/welcome.j2", context_model=Ctx)
def test_welcome_pro_user():
rendered = spec.render({"user_name": "Ada", "plan": "pro"})
a = PromptAsserts(rendered).normalized()
a.contains("Hello, Ada")
a.not_contains("Upgrade now") # pro users shouldn't see this
a.regex(r"Plan:\s*pro")
Structured Output (JSON)
{% set config = {"model": model_name, "temperature": temperature} %}
{{ config | tojson }}
def test_config_output():
rendered = spec.render({"model_name": "gpt-4", "temperature": 0.7})
config = rendered.as_json()
assert config["model"] == "gpt-4"
assert config["temperature"] == 0.7
Structured Output (XML)
Supports fragments with multiple root elements:
<tool name="search">
<query>{{ query }}</query>
</tool>
{% if include_filter %}
<tool name="filter">
<criteria>{{ criteria }}</criteria>
</tool>
{% endif %}
def test_xml_tool_calls():
rendered = spec.render({"query": "python tutorials", "include_filter": True, "criteria": "beginner"})
tools = rendered.as_xml() # Returns list[XMLElement]
assert tools[0].attrib["name"] == "search"
assert tools[0].find("query").text == "python tutorials"
# Use strict=True for single-root XML
Fenced Code Blocks
Extract code blocks from markdown-style output:
def test_fenced_json_blocks():
rendered = spec.render({"setting_name": "timeout", "setting_value": 30})
configs = rendered.as_json_blocks() # Extracts all ```json blocks
assert configs[0]["value"] == 30
# Also: as_yaml_blocks(), as_xml_blocks()
Section Testing with Anchors
{#jt:anchor:system#}
System rules: Be helpful, be concise.
{#jt:anchor:user#}
User: {{ user_name }}
Request: {{ request }}
{% if context_items %}
{#jt:anchor:context#}
Context: {% for item in context_items %}- {{ item }}{% endfor %}
{% else %}
{#jt:trace:no_context#}
{% endif %}
def test_sections():
rendered = spec.render({"user_name": "Ada", "request": "Help", "context_items": ["doc1"]})
assert rendered.section("user").contains("Ada")
assert rendered.section("system").not_contains("Ada")
def test_branch_coverage():
rendered = spec.render({"user_name": "Ada", "request": "Help", "context_items": []})
assert rendered.has_trace("no_context") # Verify branch was taken
Macros as Functions
{% macro build_prompt(user_input, context=None) %}
You are a helpful assistant.
User: {{ user_input }}
{% if context %}Context: {{ context }}{% endif %}
{% endmacro %}
def test_prompt_builder():
build_prompt = spec.macro("build_prompt")
assert "User: Hello" in build_prompt("Hello")
assert "Context: Info" in build_prompt("Hello", context="Info")
API Reference
TemplateSpec
spec = TemplateSpec.from_file("template.j2", context_model=MyModel)
spec = TemplateSpec.from_string("Hello {{ name }}!", context_model=MyModel)
rendered = spec.render({"name": "World"})
my_macro = spec.macro("macro_name")
Options: template_dir, strict_undefined=True, test_mode=True, use_comment_markers=True
RenderedPrompt
Properties: text, normalized, clean_text, lines, normalized_lines, trace_events
Parsing:
rendered.as_json() # Parse as JSON (allow_comments=True for // comments)
rendered.as_yaml() # Parse as YAML (requires pyyaml)
rendered.as_xml(strict=False) # Parse as XML (strict=True for single root)
rendered.as_json_blocks() # Extract ```json blocks
rendered.as_yaml_blocks() # Extract ```yaml blocks
rendered.as_xml_blocks() # Extract ```xml blocks
rendered.as_markdown_sections() # Parse markdown headings
Sections & Traces:
rendered.section("name") # Get section by anchor
rendered.has_section("name") # Check section exists
rendered.has_trace("event") # Check trace was recorded
rendered.trace_count("event") # Count trace occurrences
Query helpers:
rendered.contains("text") # Check substring
rendered.not_contains("text") # Check absence
rendered.matches(r"pattern") # Regex match
rendered.find_all(r"pattern") # Find all matches
PromptAsserts
a = PromptAsserts(rendered).normalized()
a.contains("text").not_contains("bad").regex(r"pattern")
a.has_trace("event").snapshot("name")
Instrumentation
{#jt:anchor:section_name#}
{#jt:trace:event_name#}
Markers are automatically transformed when test_mode=True. Comments render as empty strings in production, so jinjatest can be dev-only.
Custom Jinja environment:
env = Environment(loader=FileSystemLoader("templates/"))
env.globals["my_filter"] = lambda x: x.upper()
spec = TemplateSpec.from_file("my_template.j2", env=env)
Pytest Integration
def test_with_fixtures(template_from_string, jinja_env):
spec = template_from_string("Hello {{ name }}!")
assert spec.render({"name": "World"}).text == "Hello World!"
def test_with_snapshots(snapshot_manager, template_from_string):
rendered = template_from_string("Hello {{ name }}!").render({"name": "World"})
snapshot_manager.compare_or_update("greeting", rendered.text)
Update snapshots: pytest --update-snapshots
Advanced Configuration
env = create_environment(
template_paths=["templates/", "shared/"],
mock_templates={"header.j2": "Mock Header"},
filters={"my_filter": lambda x: x.upper()},
globals={"version": "1.0"},
)
spec = TemplateSpec.from_file("template.j2", env=env)
spec.assert_variables_subset_of({"user_name", "plan", "items"}) # CI guardrails
Template Coverage
jinjatest tracks branch coverage by instrumenting your templates at render time. When you use TemplateSpec, it automatically discovers all conditional branches (if, elif, else, for loops, macros, etc.) and records which paths are executed during tests. This lets you identify untested template logic without modifying your templates.
pytest --jt-cov --jt-cov-fail-under=80 --jt-cov-report=term
==================== Jinja Template Coverage ====================
Name Branches Covered Missing Cover
------------------------------------------------------------------
templates/welcome.j2 4 3 1 75%
templates/email/confirm.j2 6 6 0 100%
templates/components/nav.j2 8 5 3 62%
------------------------------------------------------------------
TOTAL 18 14 4 78%
| Option | Description |
|---|---|
--jt-cov |
Enable template coverage |
--jt-cov-fail-under=N |
Fail if coverage below N% |
--jt-cov-report=TYPE |
term, term-missing, html, json, xml |
--jt-cov-exclude=PATTERN |
Exclude templates by glob |
pyproject.toml:
[tool.jinjatest.coverage]
enabled = true
fail_under = 80
report = ["term", "html"]
exclude_patterns = ["**/vendor/**"]
Tracked: if/elif/else, for loops, macro, block, include, ternary expressions
License
MIT
Project details
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 jinjatest-0.3.0.tar.gz.
File metadata
- Download URL: jinjatest-0.3.0.tar.gz
- Upload date:
- Size: 90.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.9.28 {"installer":{"name":"uv","version":"0.9.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7f0dd25964ed333346758c34cf9ca18b9264d51335806b1c71db8a1df8daadd0
|
|
| MD5 |
ba0f5ced5840eff7e54b8983ee38596f
|
|
| BLAKE2b-256 |
7b261ab642aea9bdc7b50bf4d646140055a02c8ef088c3cd6ec49b478155acc7
|
File details
Details for the file jinjatest-0.3.0-py3-none-any.whl.
File metadata
- Download URL: jinjatest-0.3.0-py3-none-any.whl
- Upload date:
- Size: 53.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.9.28 {"installer":{"name":"uv","version":"0.9.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
402f91e1c1a3dc9d2cd422dc33b58ccfa2cf73b742287034a5b87e18ae983cb1
|
|
| MD5 |
d11c9e30231bb7e6013a01b5c610ee8b
|
|
| BLAKE2b-256 |
90f2b7bb6d61c7187f1a89b4f7fc18e576a2641d302cbd6b3d3d17504832e450
|