Merge pull request #22201 from netbox-community/22125-extend-test-coverage-of-background-jobs

Closes #22125: Add test coverage for system housekeeping, data source sync, and script jobs
This commit is contained in:
bctiemann 2026-05-15 13:07:25 -04:00 committed by GitHub
commit a65afe7eaf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 696 additions and 0 deletions

View File

@ -0,0 +1,367 @@
import uuid
from datetime import timedelta
from unittest.mock import MagicMock, patch
import requests
from django.conf import settings
from django.test import TestCase, override_settings
from django.utils import timezone
from core.choices import DataSourceStatusChoices
from core.jobs import SyncDataSourceJob, SystemHousekeepingJob
from core.models import DataFile, DataSource, Job
def _make_runner(cls, **job_attrs):
"""
Build a JobRunner without going through ``__init__``.
``JobRunner.__init__`` attaches a ``JobLogHandler`` to a module-level
singleton logger, so calling it once per test would accumulate handlers
across the suite. Bypass it and stub the logger directly.
"""
runner = cls.__new__(cls)
runner.job = MagicMock(**job_attrs)
runner.logger = MagicMock()
return runner
class HousekeepingRunnerMixin:
"""Provides a `_runner()` helper that builds a SystemHousekeepingJob with a mock job."""
@staticmethod
def _runner():
return _make_runner(SystemHousekeepingJob)
class SyncDataSourceJobTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.datasource = DataSource.objects.create(
name='Test Source',
type='local',
source_url='/tmp/test',
)
def test_enqueue_sets_datasource_status_to_queued(self):
job = MagicMock()
job.object = self.datasource
with patch('core.models.Job.enqueue', return_value=job):
result = SyncDataSourceJob.enqueue(instance=self.datasource)
self.assertIs(result, job)
# Verify both the in-memory assignment (`datasource.status = ...`) and the
# persisted update (`DataSource.objects.filter(pk=...).update(...)`).
self.assertEqual(self.datasource.status, DataSourceStatusChoices.QUEUED)
self.datasource.refresh_from_db()
self.assertEqual(self.datasource.status, DataSourceStatusChoices.QUEUED)
def test_enqueue_without_object_is_noop(self):
# Baseline: the datasource starts at NEW. (Captures setUpTestData drift.)
self.assertEqual(self.datasource.status, DataSourceStatusChoices.NEW)
job = MagicMock()
job.object = None
with (
patch('core.models.Job.enqueue', return_value=job),
patch(
'core.jobs.DataSource.objects.filter',
wraps=DataSource.objects.filter,
) as filter_,
):
result = SyncDataSourceJob.enqueue()
self.assertIs(result, job)
# Intent: the `if datasource := job.object` branch was skipped, so no
# filter().update() call was even attempted.
filter_.assert_not_called()
# Outcome: no side effect on any existing DataSource.
self.datasource.refresh_from_db()
self.assertEqual(self.datasource.status, DataSourceStatusChoices.NEW)
def test_run_syncs_datasource_and_updates_search_cache(self):
datafile = DataFile.objects.create(
source=self.datasource,
path='test.txt',
last_updated=timezone.now(),
size=4,
hash='0' * 64,
data=b'test',
)
runner = _make_runner(SyncDataSourceJob, object_id=self.datasource.pk)
with (
patch('core.models.DataSource.sync') as sync,
patch('core.jobs.search_backend.cache') as cache,
):
runner.run()
sync.assert_called_once_with()
cache.assert_called_once()
# The cache argument should iterate over the datasource's data files.
cache_arg = cache.call_args.args[0]
self.assertEqual(list(cache_arg), [datafile])
def test_run_marks_datasource_failed_and_reraises_on_sync_error(self):
runner = _make_runner(SyncDataSourceJob, object_id=self.datasource.pk)
with (
patch('core.models.DataSource.sync', side_effect=RuntimeError('boom')),
patch('core.jobs.search_backend.cache') as cache,
):
with self.assertRaisesMessage(RuntimeError, 'boom'):
runner.run()
self.datasource.refresh_from_db()
self.assertEqual(self.datasource.status, DataSourceStatusChoices.FAILED)
cache.assert_not_called()
def test_run_raises_when_datasource_no_longer_exists(self):
# If the DataSource was deleted between enqueue and run, the initial lookup
# raises DoesNotExist; the framework will surface it as a job error.
runner = _make_runner(SyncDataSourceJob, object_id=99_999_999)
with self.assertRaises(DataSource.DoesNotExist):
runner.run()
class SystemHousekeepingRunTestCase(HousekeepingRunnerMixin, TestCase):
SUBMETHODS = (
'send_census_report',
'clear_expired_sessions',
'prune_changelog',
'delete_expired_jobs',
'check_for_new_releases',
)
@override_settings(DEBUG=True)
def test_run_skips_when_debug_is_enabled(self):
# DEBUG is checked before sys.argv; sys.argv is irrelevant here.
runner = self._runner()
patches = {name: patch.object(SystemHousekeepingJob, name) for name in self.SUBMETHODS}
mocks = {name: p.start() for name, p in patches.items()}
self.addCleanup(lambda: [p.stop() for p in patches.values()])
runner.run()
for mock in mocks.values():
mock.assert_not_called()
@override_settings(DEBUG=False)
def test_run_skips_during_test_invocation(self):
runner = self._runner()
patches = {name: patch.object(SystemHousekeepingJob, name) for name in self.SUBMETHODS}
mocks = {name: p.start() for name, p in patches.items()}
self.addCleanup(lambda: [p.stop() for p in patches.values()])
with patch('core.jobs.sys.argv', ['manage.py', 'test']):
runner.run()
for mock in mocks.values():
mock.assert_not_called()
@override_settings(DEBUG=False)
def test_run_executes_all_housekeeping_tasks(self):
runner = self._runner()
patches = {name: patch.object(SystemHousekeepingJob, name) for name in self.SUBMETHODS}
mocks = {name: p.start() for name, p in patches.items()}
self.addCleanup(lambda: [p.stop() for p in patches.values()])
with patch('core.jobs.sys.argv', ['netbox']):
runner.run()
for mock in mocks.values():
mock.assert_called_once_with()
class SendCensusReportTestCase(HousekeepingRunnerMixin, TestCase):
@override_settings(ISOLATED_DEPLOYMENT=True)
def test_send_census_report_skips_when_isolated_deployment(self):
with patch('core.jobs.requests.get') as get:
self._runner().send_census_report()
get.assert_not_called()
@override_settings(ISOLATED_DEPLOYMENT=False, CENSUS_REPORTING_ENABLED=False)
def test_send_census_report_skips_when_reporting_disabled(self):
with patch('core.jobs.requests.get') as get:
self._runner().send_census_report()
get.assert_not_called()
@override_settings(
ISOLATED_DEPLOYMENT=False,
CENSUS_REPORTING_ENABLED=True,
CENSUS_URL='https://census.example/',
DEPLOYMENT_ID='abc123',
)
def test_send_census_report_sends_expected_payload(self):
with (
patch('core.jobs.requests.get') as get,
patch('core.jobs.resolve_proxies', return_value={'https': 'proxy'}) as resolve,
):
self._runner().send_census_report()
resolve.assert_called_once_with(url='https://census.example/')
get.assert_called_once()
kwargs = get.call_args.kwargs
self.assertEqual(kwargs['url'], 'https://census.example/')
self.assertEqual(kwargs['timeout'], 3)
self.assertEqual(kwargs['proxies'], {'https': 'proxy'})
self.assertEqual(kwargs['params']['deployment_id'], 'abc123')
self.assertIn('version', kwargs['params'])
self.assertIn('python_version', kwargs['params'])
@override_settings(
ISOLATED_DEPLOYMENT=False,
CENSUS_REPORTING_ENABLED=True,
CENSUS_URL='https://census.example/',
)
def test_send_census_report_swallows_request_exception(self):
with (
patch('core.jobs.requests.get', side_effect=requests.RequestException('down')),
patch('core.jobs.resolve_proxies', return_value={}),
):
self._runner().send_census_report() # must not raise
class ClearExpiredSessionsTestCase(HousekeepingRunnerMixin, TestCase):
def test_clear_expired_sessions_calls_session_store(self):
engine = MagicMock()
with patch('core.jobs.import_module', return_value=engine) as import_module:
self._runner().clear_expired_sessions()
import_module.assert_called_once_with(settings.SESSION_ENGINE)
engine.SessionStore.clear_expired.assert_called_once_with()
def test_clear_expired_sessions_handles_not_implemented(self):
engine = MagicMock()
engine.SessionStore.clear_expired.side_effect = NotImplementedError
runner = self._runner()
with patch('core.jobs.import_module', return_value=engine):
runner.clear_expired_sessions() # must not raise
runner.logger.warning.assert_called_once()
self.assertIn(
'does not support',
runner.logger.warning.call_args.args[0],
)
class DeleteExpiredJobsTestCase(HousekeepingRunnerMixin, TestCase):
def test_delete_expired_jobs_skips_when_retention_unset(self):
old_job = Job.objects.create(name='old', job_id=uuid.uuid4())
Job.objects.filter(pk=old_job.pk).update(created=timezone.now() - timedelta(days=365))
with patch('core.jobs.Config') as config_cls:
config_cls.return_value.JOB_RETENTION = 0
self._runner().delete_expired_jobs()
self.assertTrue(Job.objects.filter(pk=old_job.pk).exists())
def test_delete_expired_jobs_deletes_only_jobs_older_than_retention(self):
old_job = Job.objects.create(name='old', job_id=uuid.uuid4())
recent_job = Job.objects.create(name='recent', job_id=uuid.uuid4())
Job.objects.filter(pk=old_job.pk).update(created=timezone.now() - timedelta(days=30))
Job.objects.filter(pk=recent_job.pk).update(created=timezone.now() - timedelta(hours=1))
with patch('core.jobs.Config') as config_cls:
config_cls.return_value.JOB_RETENTION = 7
self._runner().delete_expired_jobs()
self.assertFalse(Job.objects.filter(pk=old_job.pk).exists())
self.assertTrue(Job.objects.filter(pk=recent_job.pk).exists())
class CheckForNewReleasesTestCase(HousekeepingRunnerMixin, TestCase):
@override_settings(ISOLATED_DEPLOYMENT=True)
def test_check_for_new_releases_skips_when_isolated(self):
with patch('core.jobs.requests.get') as get:
self._runner().check_for_new_releases()
get.assert_not_called()
@override_settings(ISOLATED_DEPLOYMENT=False, RELEASE_CHECK_URL=None)
def test_check_for_new_releases_skips_when_url_unset(self):
with patch('core.jobs.requests.get') as get:
self._runner().check_for_new_releases()
get.assert_not_called()
@override_settings(ISOLATED_DEPLOYMENT=False, RELEASE_CHECK_URL='https://api.example/')
def test_check_for_new_releases_handles_request_exception(self):
with (
patch('core.jobs.requests.get', side_effect=requests.RequestException('down')),
patch('core.jobs.cache') as cache,
patch('core.jobs.resolve_proxies', return_value={}),
):
self._runner().check_for_new_releases()
cache.set.assert_not_called()
@override_settings(ISOLATED_DEPLOYMENT=False, RELEASE_CHECK_URL='https://api.example/')
def test_check_for_new_releases_caches_latest_stable_release(self):
response = MagicMock()
response.json.return_value = [
{'tag_name': 'v4.5.0', 'html_url': 'https://example/4.5.0'},
{'tag_name': 'v4.6.0', 'html_url': 'https://example/4.6.0'},
{'tag_name': 'v4.7.0-rc1', 'html_url': 'https://example/rc', 'prerelease': True},
{'tag_name': 'v4.7.0-dev', 'html_url': 'https://example/dev', 'devrelease': True},
{'html_url': 'https://example/no-tag'},
]
with (
patch('core.jobs.requests.get', return_value=response) as get,
patch('core.jobs.cache.set') as cache_set,
patch('core.jobs.resolve_proxies', return_value={'http': 'proxy'}) as resolve,
):
self._runner().check_for_new_releases()
# HTTP request: URL, GitHub API Accept header, resolved proxies.
resolve.assert_called_once_with(url='https://api.example/')
get.assert_called_once_with(
url='https://api.example/',
headers={'Accept': 'application/vnd.github.v3+json'},
proxies={'http': 'proxy'},
)
response.raise_for_status.assert_called_once_with()
cache_set.assert_called_once()
# Accept either positional or keyword form for cache.set(key, value, ttl).
call = cache_set.call_args
bound = {**dict(zip(('key', 'value', 'timeout'), call.args)), **call.kwargs}
self.assertEqual(bound['key'], 'latest_release')
self.assertIsNone(bound['timeout'])
latest_version, latest_url = bound['value']
self.assertEqual(str(latest_version), '4.6.0')
self.assertEqual(latest_url, 'https://example/4.6.0')
class PruneChangelogTestCase(HousekeepingRunnerMixin, TestCase):
def test_prune_changelog_skips_when_retention_unset(self):
with (
patch('core.jobs.Config') as config_cls,
patch('core.jobs.ObjectChange') as object_change,
):
config_cls.return_value.CHANGELOG_RETENTION = None
self._runner().prune_changelog()
object_change.objects.filter.assert_not_called()
def test_prune_changelog_uses_strict_cutoff_filter(self):
# Implementation pin: prune_changelog must use time__lt (strict less-than) so a
# record exactly at the cutoff is retained. End-to-end behavior of the prune is
# covered by ChangelogPruneRetentionTestCase in core/tests/test_changelog.py.
with (
patch('core.jobs.Config') as config_cls,
patch('core.jobs.ObjectChange') as object_change,
patch('core.jobs.timezone') as tz,
):
config_cls.return_value.CHANGELOG_RETENTION = 7
config_cls.return_value.CHANGELOG_RETAIN_CREATE_LAST_UPDATE = False
tz.now.return_value = timezone.datetime(2026, 1, 8, tzinfo=timezone.get_current_timezone())
object_change.objects.filter.return_value.delete.return_value = (0, {})
self._runner().prune_changelog()
expected_cutoff = timezone.datetime(2026, 1, 1, tzinfo=timezone.get_current_timezone())
object_change.objects.filter.assert_called_once_with(time__lt=expected_cutoff)

View File

@ -0,0 +1,329 @@
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.db import DEFAULT_DB_ALIAS
from django.test import TestCase
from extras.jobs import ScriptJob
from utilities.exceptions import AbortScript
def _make_runner(**job_attrs):
"""
Build a ScriptJob without going through ``__init__``.
``JobRunner.__init__`` attaches a ``JobLogHandler`` to a module-level
singleton logger; instantiating one per test would accumulate handlers.
"""
runner = ScriptJob.__new__(ScriptJob)
runner.job = MagicMock(**job_attrs)
runner.logger = MagicMock()
return runner
class DummyScript:
"""A minimal stand-in for a Script implementation."""
full_name = 'tests.DummyScript'
def __init__(self, run_result=None, run_exception=None, failed=False):
self._run_result = run_result
self._run_exception = run_exception
self.failed = failed
self.output = None
self.request = None
self.run = MagicMock(side_effect=self._run_impl)
self.log_info = MagicMock()
self.log_failure = MagicMock()
self.get_job_data = MagicMock(return_value={'status': 'done'})
def _run_impl(self, data, commit):
if self._run_exception is not None:
raise self._run_exception
return self._run_result
class RunScriptTestCase(TestCase):
def test_run_script_success_commit_true_sets_output_and_job_data(self):
runner = _make_runner()
script = DummyScript(run_result='hello')
runner.run_script(script, request=None, data={'k': 'v'}, commit=True)
self.assertEqual(script.output, 'hello')
script.run.assert_called_once_with({'k': 'v'}, True)
self.assertEqual(runner.job.data, {'status': 'done'})
def test_run_script_commit_false_rolls_back_without_raising(self):
runner = _make_runner()
script = DummyScript(run_result='hello')
runner.run_script(script, request=None, data={}, commit=False)
script.run.assert_called_once_with({}, False)
# Rollback path: log_info() is called (with the translated revert message);
# job.data is still populated. Don't assert exact wording.
script.log_info.assert_called_once()
self.assertEqual(runner.job.data, {'status': 'done'})
def test_run_script_commit_false_logs_warning_when_script_failed(self):
runner = _make_runner()
script = DummyScript(run_result='hello', failed=True)
with patch('extras.jobs.logging.getLogger') as get_logger:
logger = get_logger.return_value
runner.run_script(script, request=None, data={}, commit=False)
logger.warning.assert_any_call('Script failed')
def test_run_script_abort_script_logs_failure_and_reraises(self):
# Non-report scripts call `script.log_failure(msg)` positionally; report-style
# scripts use `log_failure(message=msg)`. See `test_run_script_abort_script_uses_report_log_failure_signature`.
runner = _make_runner()
script = DummyScript(run_exception=AbortScript('nope'))
with self.assertRaises(AbortScript):
runner.run_script(script, request=None, data={}, commit=True)
# Outcome assertions: AbortScript re-raised, failure logged on the script,
# rollback path traversed (log_info called), job.data populated.
script.log_failure.assert_called_once()
# Non-report path uses the positional signature; verify both that there is
# a positional arg and no `message=` kwarg.
self.assertTrue(script.log_failure.call_args.args)
self.assertFalse(script.log_failure.call_args.kwargs)
script.log_info.assert_called_once()
self.assertEqual(runner.job.data, {'status': 'done'})
def test_run_script_abort_script_uses_report_log_failure_signature(self):
runner = _make_runner()
script = DummyScript(run_exception=AbortScript('reportfail'))
with (
patch('extras.jobs.is_report', return_value=True),
self.assertRaises(AbortScript),
):
runner.run_script(script, request=None, data={}, commit=True)
# For reports, log_failure is called with the keyword-form signature.
script.log_failure.assert_called_once()
self.assertIn('message', script.log_failure.call_args.kwargs)
def test_run_script_general_exception_logs_traceback_and_reraises(self):
runner = _make_runner()
script = DummyScript(run_exception=RuntimeError('boom'))
with self.assertRaisesMessage(RuntimeError, 'boom'):
runner.run_script(script, request=None, data={}, commit=True)
script.log_failure.assert_called_once()
# The failure message wraps a traceback; assert on the structural marker
# (Traceback header) rather than translated copy.
message = script.log_failure.call_args.kwargs['message']
self.assertIn('Traceback', message)
self.assertEqual(runner.job.data, {'status': 'done'})
def test_run_script_sends_clear_events_when_request_is_present_and_error_occurs(self):
runner = _make_runner()
script = DummyScript(run_exception=RuntimeError('boom'))
request = MagicMock()
with patch('extras.jobs.clear_events.send') as send:
with self.assertRaises(RuntimeError):
runner.run_script(script, request=request, data={}, commit=True)
send.assert_called_once_with(request)
def test_run_script_default_db_uses_single_atomic(self):
# Symmetric with test_run_script_enters_secondary_atomic_when_changelog_db_differs:
# when the changelog DB is the default, only a single atomic() block is opened.
runner = _make_runner()
script = DummyScript(run_result='ok')
atomic_calls = []
@contextmanager
def fake_atomic(using):
atomic_calls.append(using)
yield
with (
patch('extras.jobs.router.db_for_write', return_value=DEFAULT_DB_ALIAS),
patch('extras.jobs.transaction.atomic', side_effect=fake_atomic),
):
runner.run_script(script, request=None, data={}, commit=True)
self.assertEqual(atomic_calls, [DEFAULT_DB_ALIAS])
script.run.assert_called_once_with({}, True)
self.assertEqual(runner.job.data, {'status': 'done'})
def test_run_script_enters_secondary_atomic_when_changelog_db_differs(self):
# Verifies that the code enters two nested atomic() context managers when
# the changelog DB differs from the default. Does not exercise real rollback
# semantics (transaction.atomic is patched).
runner = _make_runner()
script = DummyScript(run_result='ok')
atomic_calls = []
@contextmanager
def fake_atomic(using):
atomic_calls.append(using)
yield
with (
patch('extras.jobs.router.db_for_write', return_value='changelog_db'),
patch('extras.jobs.transaction.atomic', side_effect=fake_atomic),
):
runner.run_script(script, request=None, data={}, commit=True)
self.assertEqual(atomic_calls, [DEFAULT_DB_ALIAS, 'changelog_db'])
script.run.assert_called_once_with({}, True)
self.assertEqual(runner.job.data, {'status': 'done'})
def test_run_script_enters_secondary_atomic_when_commit_false(self):
# Mirror of the previous test for the commit=False branch on the secondary
# DB path. Verifies both atomic blocks are entered; does not exercise true
# rollback semantics (transaction.atomic is patched).
runner = _make_runner()
script = DummyScript(run_result='ok')
atomic_calls = []
@contextmanager
def fake_atomic(using):
atomic_calls.append(using)
yield
with (
patch('extras.jobs.router.db_for_write', return_value='changelog_db'),
patch('extras.jobs.transaction.atomic', side_effect=fake_atomic),
):
runner.run_script(script, request=None, data={}, commit=False)
self.assertEqual(atomic_calls, [DEFAULT_DB_ALIAS, 'changelog_db'])
script.run.assert_called_once_with({}, False)
script.log_info.assert_called_once()
class ScriptJobRunTestCase(TestCase):
@staticmethod
def _runner():
return _make_runner(object_id=1)
@staticmethod
def _script_model(script_instance):
return SimpleNamespace(pk=1, python_class=MagicMock(return_value=script_instance))
def test_run_loads_script_model_and_instantiates_python_class(self):
runner = self._runner()
script_instance = DummyScript()
script_model = self._script_model(script_instance)
with (
patch('extras.jobs.ScriptModel.objects.get', return_value=script_model) as get_script_model,
patch.object(ScriptJob, 'run_script') as run_script,
patch.dict('netbox.registry.registry', {'request_processors': []}, clear=False),
):
runner.run(data={'k': 'v'}, commit=True)
get_script_model.assert_called_once_with(pk=1)
script_model.python_class.assert_called_once_with()
run_script.assert_called_once()
args, _ = run_script.call_args
self.assertIs(args[0], script_instance)
self.assertEqual(args[2], {'k': 'v'}) # data
self.assertIs(args[3], True) # commit
def test_run_merges_request_files_into_data(self):
runner = self._runner()
script_instance = DummyScript()
script_model = self._script_model(script_instance)
request = MagicMock(FILES={'upload': 'fileobj'}, id='req-1')
data = {'k': 'v'}
with (
patch('extras.jobs.ScriptModel.objects.get', return_value=script_model),
patch.object(ScriptJob, 'run_script') as run_script,
patch.dict('netbox.registry.registry', {'request_processors': []}, clear=False),
):
runner.run(data=data, request=request, commit=True)
passed_data = run_script.call_args.args[2]
self.assertEqual(passed_data['k'], 'v')
self.assertEqual(passed_data['upload'], 'fileobj')
def test_run_sets_script_request_when_request_is_present(self):
runner = self._runner()
script_instance = DummyScript()
script_model = self._script_model(script_instance)
request = MagicMock(FILES={}, id='req-1')
with (
patch('extras.jobs.ScriptModel.objects.get', return_value=script_model),
patch.object(ScriptJob, 'run_script'),
patch.dict('netbox.registry.registry', {'request_processors': []}, clear=False),
):
runner.run(data={}, request=request, commit=True)
self.assertIs(script_instance.request, request)
def _processor_factories(self, entered):
def ctx_a(request):
@contextmanager
def _ctx():
entered.append('proc_a')
yield
return _ctx()
def ctx_event(request):
@contextmanager
def _ctx():
entered.append('event_tracking')
yield
return _ctx()
return ctx_a, ctx_event
def test_run_uses_request_processors_when_commit_true(self):
runner = self._runner()
script_instance = DummyScript()
script_model = self._script_model(script_instance)
entered = []
ctx_a, ctx_event = self._processor_factories(entered)
with (
patch('extras.jobs.ScriptModel.objects.get', return_value=script_model),
patch.object(ScriptJob, 'run_script'),
patch('extras.jobs.event_tracking', new=ctx_event),
patch.dict(
'netbox.registry.registry',
{'request_processors': [ctx_a, ctx_event]},
clear=False,
),
):
runner.run(data={}, commit=True)
self.assertEqual(entered, ['proc_a', 'event_tracking'])
def test_run_skips_event_tracking_when_commit_false(self):
runner = self._runner()
script_instance = DummyScript()
script_model = self._script_model(script_instance)
entered = []
ctx_a, ctx_event = self._processor_factories(entered)
with (
patch('extras.jobs.ScriptModel.objects.get', return_value=script_model),
patch.object(ScriptJob, 'run_script'),
patch('extras.jobs.event_tracking', new=ctx_event),
patch.dict(
'netbox.registry.registry',
{'request_processors': [ctx_a, ctx_event]},
clear=False,
),
):
runner.run(data={}, commit=False)
self.assertEqual(entered, ['proc_a'])