Testing Providers¶
The repolish.testing module gives provider authors a lightweight harness for
exercising provider hooks without a full CLI pipeline, git repo, or installed
wheels. Import it directly in your test suite:
from repolish.testing import (
ProviderTestBed,
SnapshotRunOptions,
run_snapshot_case,
include_paths,
exclude_paths,
assert_snapshots,
make_context,
)
Quick Start: End-to-End Fixture Testing (Recommended)¶
The most faithful way to test a provider is to run the real repolish apply
pipeline against a made-up project and assert on what it produces:
apply_provider() stages, preprocesses, renders, inserts, post-processes,
applies, and validates: everything repolish apply does, with one difference:
the repolish.yaml is written by the harness, pointing at your provider
package. No link CLI, no installed wheels, no real repository.
Resource copies and symlinks work as in production: the harness registers
resources_dir at the package's resources/ root (the parent of the templates
directory), which is what the provider's link CLI records, so sources outside
the templates tree resolve correctly. The run is also anchored to the project
directory, so relative copy and symlink targets land inside the fixture, not
your test process's working directory.
The made-up project is a fixture: a checked-in directory holding the
simplified state of a repo you want to simulate: developer-owned files,
insertion markers, values for repolish-regex capture, an old config file your
provider is about to delete. Stage it into a per-test copy, apply, and assert or
snapshot the result:
from pathlib import Path
from repolish.testing import (
apply_fast_lane,
apply_provider,
assert_idempotent,
assert_snapshots,
stage_project,
)
from my_provider.resources.templates.repolish import MyProvider
FIXTURES = Path(__file__).parent / 'fixtures'
SNAPSHOT_DIR = Path(__file__).parent / 'snapshots' / 'my_provider'
def test_full_apply(tmp_path: Path) -> None:
project = stage_project(FIXTURES / 'my-repo', tmp_path / 'project')
result = apply_provider(MyProvider, project)
assert result.exit_code == 0
assert result.apply_result['README.md'] == 'written'
assert_snapshots(result.managed_files(), SNAPSHOT_DIR)
Testing fast lanes end to end¶
The same fixture pattern works for named fast lanes. Use apply_fast_lane()
when you want the real lane runtime, but you do not want to shell out through
the generated provider CLI in every test.
from pathlib import Path
from repolish.testing import (
apply_fast_lane,
assert_snapshots,
include_paths,
stage_project,
)
def test_actions_lane(tmp_path: Path) -> None:
project = stage_project(FIXTURES / 'my-repo', tmp_path / 'project')
result = apply_fast_lane(MyProvider, project, 'actions')
lane_files = include_paths(result.managed_files(), exact={'action.yaml'})
assert result.exit_code == 0
assert_snapshots(lane_files, SNAPSHOT_DIR / 'actions')
This is useful when a lane generates templates through the normal repolish pipeline and you want the same fixture-and-snapshot ergonomics as full apply tests. The helper exercises the real lane restriction path, so regular provider hooks stay out of the run and lane-specific post-process behavior is honored. If the provider also auto-stages other managed files in the same fixture, filter the result down to the lane-owned paths before snapshotting.
Two views over the result, both {rel_path: content} and both ready for
assert_snapshots:
result.managed_files()is the one to snapshot: exactly the files this run applied, staged insertions into, or copied, taken from the run's own records (apply_result,insertion_results,resolved_copies). Because the file set comes from the session rather than a directory walk, nothing can leak in: no__pycache__/, no*.pyc, no exclusion list to maintain. Untouched fixture files are absent; they are checked in with the fixture.result.project_files()is the whole applied project tree (fixture state plus provider output) when you also want to assert the surrounding project: scratch dirs (.repolish/,.git/,__pycache__/at any depth),*.pycfiles, and the harness-writtenrepolish.yamlare excluded. Byte caches left behind bypost_processcommands that invoke python are build junk, not project output.
Both return plain dicts, so include_paths / exclude_paths shape them further
before assert_snapshots when a test needs to focus on a subset.
Narrowing the snapshot scope¶
Filtering with include_paths / exclude_paths has a sharp edge worth stating
plainly: a filtered snapshot is blind to anything it was not handed. If a
test narrows the view to certain files and a new template later writes outside
that set, the comparison passes and the new output goes unseen. The full
managed_files() snapshot has no blind spot: adding a template lights up every
fixture's snapshot diff, and reviewing that diff once with
REPOLISH_UPDATE_SNAPSHOTS=1 is the test doing its job, not a chore. Prefer
paying that review cost over filtering it away.
Reach for a filter only when the full view cannot work, and make determinism the
first attempt: the harness freezes year=, repo_owner=, and repo_name=
precisely so snapshot content never drifts between runs. The cases that justify
filtering:
- a generated file whose content your provider genuinely cannot reproduce
deterministically (a lockfile with resolved hashes, a file embedding a
wall-clock timestamp produced by
post_process), and - a file that exists only to exercise a side effect (a validator target, scratch
output of a
post_processstep) whose content carries no information worth pinning.
Exclude the single offending path, not the whole category, and keep the
exclusion next to the test with a comment saying why. A comment like
# lockfile: resolved hashes differ per run is self-documenting; a bare
exclude_paths(result.managed_files(), {'generated/'}) in a helper far from the
test is how blind spots accumulate quietly.
The workflow never changes¶
The first run is the only setup you ever do:
Snapshots are written for you; review the git diff and commit. There is no "first run without local files" dance: preprocessor directives read the fixture files (the same files the real repo would have), so the test code is identical on the first and every later run.
The same command is the natural response to template changes. As you work on the provider and edit templates, the generated output, and therefore the snapshots, changes with it. Re-run the tests with the environment variable set, then review the diff to see exactly what the change produces across every fixture project:
If a snapshot diff surprises you, that is the test earning its keep: the template change did something you didn't intend, and you found out before a real project did.
Assert the project, not just the render¶
ApplyResult exposes what the pipeline collected:
exit_code:0success;1validator failure;2drift in check modeapply_result: per-file status:'written','unchanged','deleted'validation_results: validator failures per destination path (passes don't need asserting; the exit code already covers them)insertion_results: per-file insertion execution summariesrender_tree: the staged render tree, open it to debug a diff
Use check_only=True to compare without writing, and assert_idempotent for
the test that pays for itself: apply, then check, expecting no drift. A provider
that produces output it can't reproduce on the second pass is the classic
spurious-drift bug, and this catches it:
def test_no_drift(tmp_path: Path) -> None:
project = stage_project(FIXTURES / 'my-repo', tmp_path / 'project')
assert_idempotent(MyProvider, project)
Testing validators: bad data belongs in the fixture¶
Validators generate nothing; they inspect. That is exactly why they pair
naturally with fixtures: the pipeline resolves each validator's target by
preferring the real project file and falling back to the render tree, so a
validator registered for a developer-owned file (config.toml the repo already
had, not a template) validates the fixture's copy directly. A fixture with bad
data is just a fixture: check in the broken state you want the validator to
catch, apply, and assert on the records.
Two fixtures, one validator, both sides of the check:
from repolish.providers.models import ValidationStatus
def test_catches_missing_api_key(tmp_path: Path) -> None:
"""The bad fixture fails validation exactly as a real repo would."""
project = stage_project(FIXTURES / 'config-missing-key', tmp_path / 'project')
result = apply_provider(MyProvider, project)
# 1 = validator failure, distinct from 2 (drift) and 0 (clean run)
assert result.exit_code == 1
failure = result.validation_results['config.toml']['has-api-key']
assert failure.status == ValidationStatus.ERROR
assert 'api_key' in failure.message
def test_accepts_valid_config(tmp_path: Path) -> None:
"""The good fixture passes: nothing lands in validation_results."""
project = stage_project(FIXTURES / 'config-valid', tmp_path / 'project')
result = apply_provider(MyProvider, project)
assert result.exit_code == 0
assert 'config.toml' not in result.validation_results
validation_results holds failures and warnings only, keyed
{dest_path: {validator_name: ValidationResult}}. Passes never appear: a clean
run is already proven by exit_code == 0 and the empty dict. A validator that
crashes is reported as ValidationStatus.ERROR with the same
"Validator 'name' for 'dest' crashed: ..." message shape production prints, so
a crash test asserts on exactly what a real run would report. Validators
disabled through FileValidatorOptions are skipped entirely, just as in
production.
Warnings are the one nuance: a ValidationStatus.WARNING leaves
exit_code == 0 by default, matching repolish apply. Pass
fail_on_warnings=True to the harness to test the strict behavior:
result = apply_provider(MyProvider, project, fail_on_warnings=True)
assert result.exit_code == 1
warned = result.validation_results['docs/README.md']['stale-links']
assert warned.status == ValidationStatus.WARNING
When a test needs the full picture (passes and disables included, not just
failures), result.session.validation_reports maps each destination path to the
JSON report the pipeline wrote under .repolish/_/validators/: open it and read
every registered validator's outcome from the run.
Cross-provider inputs: sending and receiving¶
Providers talk through inputs: one provider's provide_inputs() emits payloads,
and every provider whose get_inputs_schema() matches receives them in
finalize_context(). The end-to-end harness covers both directions without a
second installed provider.
Sending: everything the run emitted is recorded on the result.
result.emitted_inputs lists each payload, captured before local routing, so a
provider consuming its own output cannot hide what it sent:
def test_emits_ci_tasks(tmp_path: Path) -> None:
project = stage_project(FIXTURES / 'my-repo', tmp_path / 'project')
result = apply_provider(MyProvider, project)
assert [inp.model_dump() for inp in result.emitted_inputs] == [
{'ci_tasks': ['lint', 'test']},
]
Receiving: pass extra_inputs= to inject payloads into the run. They join
the routing pool before finalization and are delivered by schema match exactly
as a peer provider's outputs would be, so a dependency test needs no real peer:
def test_receives_ci_tasks(tmp_path: Path) -> None:
project = stage_project(FIXTURES / 'my-repo', tmp_path / 'project')
result = apply_provider(
MyProvider,
project,
extra_inputs=[CiProviderInputs(ci_tasks=['lint', 'test'])],
)
assert result.exit_code == 0
assert result.apply_result['.github/workflows/ci.yml'] == 'written'
Routing matches by schema, not by class identity: a payload from a separate module is accepted when it validates against the provider's inputs model. Import the peer provider's inputs class when the peer is installed; define a structurally identical model in the test when it is not.
For the read pattern (get_provider_context) or a full two-provider run,
register the peer through config=. Provider entries other than the harness's
alias are kept as written, so both providers load and the pipeline routes inputs
between them for real:
result = apply_provider(
MyProvider,
project,
alias='my-provider',
config={'providers': {
'ci': {'provider_root': str(CI_TEMPLATES_ROOT)},
}},
)
assert_idempotent accepts extra_inputs too and forwards it to both runs, so
a dependency-driven provider gets the same drift guarantee.
apply_provider writes the repolish.yaml for you; the config= mapping
carries everything else you'd put in that file, with the harness's entry for its
own alias always winning over anything you supply there:
result = apply_provider(
MyProvider,
project,
alias='my-provider',
config={
'post_process': [f'{sys.executable} -c "..."'],
'paused_files': ['legacy/old.py'],
},
)
Context values are deterministic by default: repolish.repo.owner is
test-owner, repolish.repo.name is test-repo, and nothing is derived from
your cwd or git. Override with repo_owner / repo_name, freeze
repolish.year with year= for license headers, and pass git_init=True to
stage_project when your provider code itself reads git.
Standalone projects are supported; monorepo (root/member) fixtures are not yet wired through the harness.
Choosing a tier¶
- End-to-end (
apply_provider): behavior, output, drift, the tier this page recommends; it cannot drift from the pipeline because it is the pipeline. - Hook-level (
ProviderTestBed): fast unit tests for context, mappings, inputs, and validators in isolation. See below. run_snapshot_case: the earlier snapshot pattern, retained as-is. It may be deprecated in a future release as the end-to-end tier covers its use cases.
Snapshot Tests with run_snapshot_case¶
For most snapshot tests, use run_snapshot_case() with SnapshotRunOptions.
This captures the common flow in a single call:
from pathlib import Path
from repolish.testing import SnapshotRunOptions, run_snapshot_case, include_paths
from my_provider.repolish.provider import MyProvider
from my_provider.repolish.models import MyProviderInputs
SNAPSHOT_DIR = Path(__file__).parent / 'snapshots' / 'my_provider'
def test_standalone_snapshot() -> None:
opts = SnapshotRunOptions[MyProviderInputs](
mode='standalone',
received_inputs=[],
preprocess=True,
local_files_dir=SNAPSHOT_DIR,
)
ctx, rendered = run_snapshot_case(
MyProvider,
options=opts,
snapshot_dir=SNAPSHOT_DIR,
)
# Extra assertions beyond snapshot comparison
assert 'expected_value' in rendered['config.toml']
def test_root_mode_with_filter() -> None:
"""Filter rendered output to only snapshot relevant files."""
opts = SnapshotRunOptions[MyProviderInputs](
mode='root',
received_inputs=[MyProviderInputs(feature='enabled')],
local_files_dir=SNAPSHOT_DIR,
)
ctx, rendered = run_snapshot_case(
MyProvider,
options=opts,
snapshot_dir=SNAPSHOT_DIR / 'root',
filter_rendered=lambda r: include_paths(
r,
exact={'README.md', 'config.toml'},
prefixes=('tasks/',),
exclude_prefixes=('tasks/sessions/',),
),
)
SnapshotRunOptions parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
mode |
'standalone' | 'root' | 'member' |
'standalone' |
Workspace mode for context and mode-handler dispatch. |
received_inputs |
list[BaseInputs] |
[] |
Inputs from other providers to merge in finalize(). |
all_providers |
list[ProviderEntry] or None |
None |
Provider entries visible during input emission / finalization. Defaults to single-entry list with this provider. |
provider_index |
int |
0 |
Position in the load order. |
preprocess |
bool |
True |
Run the full preprocessing pipeline after Jinja2 rendering. |
local_files_dir |
Path or None |
None |
Directory of existing local files for preprocessor directives to read from. |
extra_context |
dict[str, object] or None |
None |
Additional variables merged on top of provider context during rendering. |
mutate_context |
Callable[[BaseContext], None] or None |
None |
Optional callback to mutate context after finalize() for one-off customizations. |
Filter helpers¶
Use include_paths() and exclude_paths() to filter rendered output before
snapshot comparison. This is useful for mode-specific tests or excluding
generated paths.
from repolish.testing import include_paths, exclude_paths
# Include only specific files and paths under 'tasks/', excluding sessions
filtered = include_paths(
rendered,
exact={'README.md', 'config.toml'},
prefixes=('tasks/',),
exclude_prefixes=('tasks/sessions/',),
include_regex=(r'.*\.jinja$',), # Also include any .jinja files
exclude_regex=(r'.*\.tmp$',), # Exclude any .tmp files
)
# Or exclude specific paths from the full output
filtered = exclude_paths(
rendered,
prefixes=('generated/', '.git/'),
regex=(r'.*\.cache$',),
)
Snapshot workflow: first run vs subsequent runs¶
First run (snapshots don't exist yet):
def test_standalone_snapshot() -> None:
opts = SnapshotRunOptions[MyProviderInputs](
preprocess=True,
# No local_files_dir on first run
)
ctx, rendered = run_snapshot_case(
MyProvider,
options=opts,
snapshot_dir=SNAPSHOT_DIR, # Will fail with rendered content shown
)
The test fails with missing snapshot errors: the assertion prints the rendered
content so you can copy it into SNAPSHOT_DIR.
Subsequent runs (feed snapshot content back as local files):
def test_standalone_snapshot() -> None:
opts = SnapshotRunOptions[MyProviderInputs](
preprocess=True,
local_files_dir=SNAPSHOT_DIR, # Read from snapshots for regex/keep
)
ctx, rendered = run_snapshot_case(
MyProvider,
options=opts,
snapshot_dir=SNAPSHOT_DIR,
)
With local_files_dir set, preprocessor directives can extract values from the
existing snapshot files, exactly as repolish apply reads from the real repo.
Why this pattern?¶
- Removes boilerplate: no repeated
ProviderTestBedconstruction,finalize(),render_all()calls - Explicit defaults:
SnapshotRunOptionsmakes all parameters visible - Flexible filtering:
include_paths()/exclude_paths()for mode-specific snapshot subsets - Full compatibility: advanced tests can still use
ProviderTestBeddirectly
Testing Cross-Provider Dependencies¶
Providers can communicate in two ways:
-
Push pattern (via
provide_inputs()/finalize_context()): One provider emits typed inputs that another receives. Test this by passingreceived_inputstoSnapshotRunOptions. -
Read pattern (via
get_provider_context()): A provider reads another provider's context directly fromopt.all_providers. Test this by constructing mock provider entries withmock_provider_entry().
Testing the read pattern¶
When your provider reads another provider's context (e.g., CI-checks reading
Poe's tasks), use mock_provider_entry() to create a fake peer provider:
from repolish.testing import (
SnapshotRunOptions,
run_snapshot_case,
mock_provider_entry,
)
def test_ci_checks_with_poe_context() -> None:
"""Test CI-checks provider reading Poe provider's context."""
# Mock the Poe provider with pre-populated context
poe_entry = mock_provider_entry(
PoeProvider,
context=PoeCtx(ci_tasks=['lint', 'test', 'typecheck']),
alias='poe',
)
opts = SnapshotRunOptions(
all_providers=[poe_entry],
)
ctx, rendered = run_snapshot_case(
CIChecksProvider,
options=opts,
snapshot_dir=SNAPSHOT_DIR,
)
# CI-checks should have generated workflows for each Poe task
assert 'lint' in rendered['ci-workflows.toml']
assert 'test' in rendered['ci-workflows.toml']
Testing the push pattern¶
When your provider receives inputs from another provider, pass them via
received_inputs:
from other_provider.repolish.models import OtherProviderInputs
def test_workspace_with_python_inputs() -> None:
"""Test workspace provider receiving inputs from Python provider."""
opts = SnapshotRunOptions[OtherProviderInputs](
received_inputs=[
OtherProviderInputs(poe_tasks_block='check-ruff.help = "..."'),
],
local_files_dir=SNAPSHOT_DIR,
)
ctx, rendered = run_snapshot_case(
WorkspaceProvider,
options=opts,
snapshot_dir=SNAPSHOT_DIR,
)
# Workspace should have merged the Python provider's tasks
assert 'check-ruff' in rendered['poe_tasks.toml']
Full example: CI-checks reading from Poe¶
from repolish import BaseContext, FinalizeContextOptions, Provider, get_provider_context
from repolish.testing import SnapshotRunOptions, run_snapshot_case, mock_provider_entry
class PoeCtx(BaseContext):
ci_tasks: list[str] = ['lint']
class PoeProvider(Provider[PoeCtx, BaseInputs]):
def create_context(self) -> PoeCtx:
return PoeCtx()
class CIChecksCtx(BaseContext):
workflow_tasks: list[str] = []
project: str = 'my-project'
class CIChecksProvider(Provider[CIChecksCtx, BaseInputs]):
def finalize_context(
self,
opt: FinalizeContextOptions[CIChecksCtx, BaseInputs],
) -> CIChecksCtx:
# Read Poe's context directly
poe = get_provider_context(PoeProvider, opt.all_providers)
if poe is not None:
opt.own_context.workflow_tasks = poe.ci_tasks
return opt.own_context
def create_file_mappings(self, context: CIChecksCtx) -> dict:
return {'ci-workflows.toml': 'ci-workflows.toml.jinja'}
def test_ci_checks_reads_poe_tasks() -> None:
SNAPSHOT_DIR = Path(__file__).parent / 'snapshots'
# Mock Poe provider with specific tasks
poe_entry = mock_provider_entry(
PoeProvider,
context=PoeCtx(ci_tasks=['lint', 'test', 'typecheck']),
)
opts = SnapshotRunOptions(
all_providers=[poe_entry],
)
ctx, rendered = run_snapshot_case(
CIChecksProvider,
options=opts,
snapshot_dir=SNAPSHOT_DIR,
)
assert ctx.workflow_tasks == ['lint', 'test', 'typecheck']
Deterministic Snapshots for Dynamic Values¶
Snapshot tests should be deterministic. If provider output includes dynamic values (current year/month, timestamps, random IDs, or environment-derived values), freeze or patch them in tests.
Preferred pattern: use mocker: MockerFixture and mock.patch to patch the
function or module that produces the dynamic value:
from pytest_mock import MockerFixture
from repolish.testing import SnapshotRunOptions, run_snapshot_case
def test_snapshot_stable_year(mocker: MockerFixture) -> None:
"""Freeze the year to prevent snapshot drift."""
mocker.patch('my_provider.repolish.provider.current_year', return_value=2026)
opts = SnapshotRunOptions()
ctx, rendered = run_snapshot_case(
MyProvider,
options=opts,
snapshot_dir=SNAPSHOT_DIR,
)
# Snapshots remain stable over time
Common values to freeze:
| Value | Patch target | Example |
|---|---|---|
| Current year | repolish.provider.current_year or your provider's equivalent |
mocker.patch('my_provider.provider.current_year', return_value=2026) |
| Current month | Similar pattern | mocker.patch('my_provider.provider.current_month', return_value='July') |
| Timestamps | datetime.datetime.now |
mocker.patch('datetime.datetime.now', return_value=fixed_dt) |
| Random IDs | uuid.uuid4 or random |
mocker.patch('uuid.uuid4', return_value=fixed_uuid) |
| Version strings | Your provider's version source | mocker.patch('my_provider.__version__', return_value='1.0.0') |
Advanced: Direct ProviderTestBed Usage¶
For tests that need fine-grained control over individual lifecycle hooks, use
ProviderTestBed directly.
ProviderTestBed¶
ProviderTestBed is a dataclass that wraps a provider instance, injects a
synthetic context, and exposes methods mirroring every lifecycle hook.
Quick start¶
from repolish.testing import ProviderTestBed
from my_provider.repolish.provider import MyProvider
bed = ProviderTestBed(MyProvider)
assert bed.resolved_context.repolish.workspace.mode == 'standalone'
When no context is supplied the provider's own create_context() is called.
Pass an explicit context to override:
from my_provider.repolish.models import MyProviderContext
bed = ProviderTestBed(
MyProvider,
context=MyProviderContext(flag=True),
)
assert bed.resolved_context.flag is True
Constructor parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
provider_class |
type[Provider] |
required | The concrete Provider subclass to test. |
context |
context model or None |
None |
If None, calls create_context() on the provider. |
mode |
'standalone' | 'root' | 'member' |
'standalone' |
Controls mode-handler dispatch and repolish.workspace.mode. |
templates_root |
Path or None |
None |
Explicit path to resources/templates. Auto-detected when omitted. |
alias |
str |
'test-provider' |
Provider alias injected into instance metadata. |
version |
str |
'0.1.0' |
Provider version injected into instance metadata. |
preprocess |
bool |
False |
Run the full preprocessing pipeline after Jinja2 rendering. Strips preprocessor directive lines and applies anchor replacements, matching repolish apply production output. |
local_files_dir |
Path or None |
None |
Directory of existing local files passed as local_content to the preprocessor. Only used when preprocess=True. See Snapshot workflow below. |
Lifecycle hook methods¶
Each method calls the corresponding provider hook through the same dispatch path
that repolish apply uses, including mode-handler routing:
bed.file_mappings() # -> dict[str, str | TemplateMapping | None]
bed.anchors() # -> dict[str, str]
bed.symlinks() # -> list[Symlink]
bed.promote_file_mappings() # -> dict[str, str | TemplateMapping | None]
bed.validators() # -> dict[str, dict[str, FileValidatorEntry]]
bed.insertions() # -> FileInsertionContribution
bed.insertion_registry() # -> InsertionRegistry
bed.copies() # -> list[ResourceCopy]
bed.provide_inputs() # -> Sequence[BaseInputs]
bed.finalize(received_inputs=[]) # -> context
provide_inputs() and finalize() accept optional all_providers and
provider_index keyword arguments. When omitted they default to a single-entry
list containing the test provider itself.
validators(), insertions(), and insertion_registry() route through the
same mode-handler dispatch as repolish apply. copies() and symlinks() are
no-argument hooks and call the provider instance directly.
Running validators¶
run_validators() executes the registry returned by create_file_validators()
against real files, so a provider can test its validators the way the pipeline
runs them:
bed = ProviderTestBed(MyProvider)
results = bed.run_validators({'config.toml': 'generated by repolish\n'})
header = results['config.toml']['lint']
assert header.status == ValidationStatus.PASS
filesmaps destination paths to file content. It defaults to the output ofrender_all(), sobed.run_validators()validates the provider's rendered output in one call.- The files are materialized under
base_dirwhen given, otherwise a throwaway temporary directory that is removed after the run. Validators receive a real path to read from, just like inrepolish apply. - The return value is
{dest_path: {validator_name: ValidationResult}}with one entry per enabled validator that ran, passes included. Validators disabled throughFileValidatorOptions(eitherenabled=Falseor the per-namevalidatorsmap) are skipped. - A validator that raises is reported as
ValidationStatus.ERRORwith the same"Validator 'name' for 'dest' crashed: ..."message shape the apply pipeline produces, so a crash test asserts on exactly what production would report.
Template rendering¶
render(template_name, *, extra_context=None)¶
Renders a single template from resources/templates/repolish/ using the
provider's context (flattened through ctx_to_dict, matching production).
render_all(*, extra_context=None)¶
Renders every file returned by create_file_mappings() plus auto-discovered
templates, returning a {dest_path: rendered_content} dict:
rendered = bed.render_all()
assert '.github/workflows/ci.yml' in rendered
assert 'my-project' in rendered['README.md']
Auto-discovery mirrors production behavior: files in templates/repolish/
without the _repolish. prefix are included automatically, while _repolish.
prefixed files appear only when explicitly mapped.
render_all() also respects TemplateMapping.extra_context. When a mapping
entry carries per-file extra context, it is merged on top of the provider
context for that destination only, exactly as repolish apply does. This means
a single template can fan out to multiple files with different content:
class SessionCtx(BaseModel):
session_name: str
class MyProvider(Provider[Ctx, BaseInputs]):
def create_file_mappings(self, context: Ctx):
return {
f'poe-tasks/{s.session_name}.toml': TemplateMapping(
'_repolish.task.toml.jinja',
extra_context=SessionCtx(session_name=s.session_name),
)
for s in context.session_tasks
}
bed = ProviderTestBed(MyProvider)
rendered = bed.render_all()
# Each destination gets its own session_name baked in
assert 'session_name = lint' in rendered['poe-tasks/lint.toml']
Snapshot tests with full pipeline output¶
By default the testbed only performs Jinja2 rendering, keeping tests fast.
Enable preprocess=True to also run the preprocessing pipeline
(repolish-regex, repolish-keep-*, anchor replacements, etc.), producing
output that matches repolish apply exactly.
See
Snapshot workflow: first run vs subsequent runs
above for the recommended pattern using run_snapshot_case(). For direct
ProviderTestBed usage:
SNAPSHOT_DIR = Path(__file__).parent / 'snapshots' / 'my_provider'
def test_render_all_pipeline_output() -> None:
# First run: no local_files_dir, snapshots don't exist yet
bed = ProviderTestBed(MyProvider, preprocess=True)
rendered = bed.render_all()
assert_snapshots(rendered, SNAPSHOT_DIR)
def test_render_all_pipeline_output_with_local_files() -> None:
# Subsequent runs: feed snapshots back as local content
bed = ProviderTestBed(
MyProvider,
preprocess=True,
local_files_dir=SNAPSHOT_DIR,
)
rendered = bed.render_all()
assert_snapshots(rendered, SNAPSHOT_DIR)
make_context¶
Factory for a synthetic RepolishContext with sensible defaults. Useful when
building context objects for tests without constructing the full object graph:
from repolish.testing import make_context
ctx = make_context(mode='root', alias='my-provider', version='2.0.0')
assert ctx.workspace.mode == 'root'
assert ctx.provider.alias == 'my-provider'
| Parameter | Default | Description |
|---|---|---|
mode |
'standalone' |
'standalone', 'root', or 'member' |
alias |
'test-provider' |
Provider alias |
version |
'0.1.0' |
Provider version |
repo_owner |
'test-owner' |
GitHub repo owner |
repo_name |
'test-repo' |
GitHub repo name |
assert_snapshots¶
Compares rendered output against golden files on disk. Produces a unified diff on mismatch and reports missing snapshots with the rendered content so you can copy it into place:
from repolish.testing import ProviderTestBed, assert_snapshots
bed = ProviderTestBed(MyProvider)
rendered = bed.render_all()
assert_snapshots(rendered, 'tests/snapshots/my_provider')
Workflow¶
- Run
render_all()to get the rendered output dict. - Create a
tests/snapshots/directory with expected files matching each key. - Call
assert_snapshots(rendered, snapshot_dir). - On first run (empty snapshot dir), the assertion fails with the rendered content printed; copy it into the snapshot directory.
- On subsequent runs, any drift produces a readable unified diff.
AssertionError: 1 snapshot(s) failed:
Failed files:
- README.md
--- snapshot/README.md
+++ rendered/README.md
@@ -1,3 +1,3 @@
-# old-project
+# new-project
Snapshot update mode¶
To update snapshots automatically instead of failing, use the update parameter
or set the REPOLISH_UPDATE_SNAPSHOTS=1 environment variable:
# Option 1: Pass update=True to assert_snapshots
assert_snapshots(rendered, 'tests/snapshots/my_provider', update=True)
# Option 2: Use environment variable
# Run: REPOLISH_UPDATE_SNAPSHOTS=1 pytest tests/
When update mode is enabled:
- A warning is printed to stderr reminding you to review git changes
- All rendered files are written to the snapshot directory
- No assertion errors are raised
Important: Always review the git diff before committing updated snapshots!
Testing mode handlers¶
ProviderTestBed routes calls through the same call_provider_method dispatch
that production uses. Set the mode parameter to exercise specific handlers:
bed_root = ProviderTestBed(MyProvider, mode='root')
root_mappings = bed_root.file_mappings()
bed_member = ProviderTestBed(MyProvider, mode='member')
member_mappings = bed_member.file_mappings()
assert 'root-only.md' in root_mappings
assert 'member-only.md' in member_mappings
Testing cross-provider inputs¶
Exercise provide_inputs() and finalize() to verify input exchange without
running the full pipeline:
from my_provider.repolish.models import MyProviderInputs
bed = ProviderTestBed(MyProvider)
# Check what inputs this provider emits
inputs = bed.provide_inputs()
assert len(inputs) == 1
# Simulate receiving inputs from another provider
result = bed.finalize(received_inputs=[MyProviderInputs(flag=True)])
assert result.some_field == 'derived-from-input'
Full Examples¶
Canonical pattern (recommended)¶
Use run_snapshot_case() for most snapshot tests:
from pathlib import Path
from pytest_mock import MockerFixture
from my_provider.repolish.models import MyProviderContext, MyProviderInputs
from my_provider.repolish.provider import MyProvider
from repolish.testing import SnapshotRunOptions, run_snapshot_case, include_paths
SNAPSHOT_DIR = Path(__file__).parent / 'snapshots' / 'my_provider'
def test_standalone_default() -> None:
"""Basic snapshot test with defaults."""
opts = SnapshotRunOptions[MyProviderInputs](
preprocess=True,
local_files_dir=SNAPSHOT_DIR,
)
ctx, rendered = run_snapshot_case(
MyProvider,
options=opts,
snapshot_dir=SNAPSHOT_DIR,
)
def test_root_mode_filtered(mocker: MockerFixture) -> None:
"""Root mode with filtered output and frozen dynamic values."""
# Freeze dynamic values for stable snapshots
mocker.patch('my_provider.provider.current_year', return_value=2026)
opts = SnapshotRunOptions[MyProviderInputs](
mode='root',
received_inputs=[MyProviderInputs(feature='enabled')],
local_files_dir=SNAPSHOT_DIR,
)
ctx, rendered = run_snapshot_case(
MyProvider,
options=opts,
snapshot_dir=SNAPSHOT_DIR / 'root',
filter_rendered=lambda r: include_paths(
r,
exact={'README.md', 'config.toml'},
prefixes=('tasks/',),
exclude_prefixes=('tasks/sessions/',),
),
)
# Extra assertions beyond snapshot comparison
assert 'expected_value' in rendered['config.toml']
def test_mutate_context() -> None:
"""Use mutate_context for one-off context customizations."""
def apply_overrides(ctx: MyProviderContext) -> None:
ctx.project_name = 'overridden-project'
ctx.feature_flag = True
opts = SnapshotRunOptions[MyProviderInputs](
mutate_context=apply_overrides,
)
ctx, rendered = run_snapshot_case(
MyProvider,
options=opts,
snapshot_dir=SNAPSHOT_DIR / 'overridden',
)
Advanced pattern (fine-grained control)¶
Use ProviderTestBed directly when you need to test individual hooks:
from pathlib import Path
from my_provider.repolish.models import MyProviderContext, MyProviderInputs
from my_provider.repolish.provider import MyProvider
from repolish.testing import ProviderTestBed, assert_snapshots, make_context
SNAPSHOT_DIR = Path(__file__).parent / 'snapshots' / 'my_provider'
def test_default_context() -> None:
bed = ProviderTestBed(MyProvider)
ctx = bed.resolved_context
assert isinstance(ctx, MyProviderContext)
assert ctx.project_name == 'my-project'
def test_file_mappings() -> None:
bed = ProviderTestBed(MyProvider)
fm = bed.file_mappings()
assert 'README.md' in fm
assert fm.get('SETUP.md') is not None
def test_anchors() -> None:
bed = ProviderTestBed(MyProvider)
anchors = bed.anchors()
assert 'project-name' in anchors
def test_mode_handlers() -> None:
"""Test different mode handler behavior."""
bed_root = ProviderTestBed(MyProvider, mode='root')
root_mappings = bed_root.file_mappings()
bed_member = ProviderTestBed(MyProvider, mode='member')
member_mappings = bed_member.file_mappings()
assert 'root-only.md' in root_mappings
assert 'member-only.md' in member_mappings
def test_cross_provider_inputs() -> None:
"""Test input exchange between providers."""
bed = ProviderTestBed(MyProvider)
# Check what inputs this provider emits
inputs = bed.provide_inputs()
assert len(inputs) == 1
# Simulate receiving inputs from another provider
result = bed.finalize(received_inputs=[MyProviderInputs(flag=True)])
assert result.some_field == 'derived-from-input'
def test_render_all_matches_snapshots() -> None:
bed = ProviderTestBed(
MyProvider,
preprocess=True,
local_files_dir=SNAPSHOT_DIR,
)
rendered = bed.render_all()
assert_snapshots(rendered, SNAPSHOT_DIR)
def test_custom_context_changes_output() -> None:
bed = ProviderTestBed(
MyProvider,
context=MyProviderContext(project_name='custom'),
)
rendered = bed.render_all()
assert 'custom' in rendered['README.md']
def test_template_mapping_extra_context() -> None:
"""Test fanning out a single template to multiple destinations."""
from pydantic import BaseModel
from repolish import TemplateMapping
class SessionCtx(BaseModel):
session_name: str
class FanOutProvider(MyProvider):
def create_file_mappings(self, context: MyProviderContext):
return {
f'tasks/{name}.toml': TemplateMapping(
'_repolish.task.toml.jinja',
extra_context=SessionCtx(session_name=name),
)
for name in ['lint', 'test', 'build']
}
bed = ProviderTestBed(FanOutProvider)
rendered = bed.render_all()
assert 'session_name = lint' in rendered['tasks/lint.toml']
assert 'session_name = test' in rendered['tasks/test.toml']
assert 'session_name = build' in rendered['tasks/build.toml']