36 KiB
Pytest conversion plan: what to convert, in what order
Survey date: 2026-09-22, branch fix-outbound-connect-guard. Line numbers are as of that tree.
Companion report: 2026-09-22-test-bugs.md (fix those first; several are touched below).
Progress
One file per branch, each branched fresh off dev. Bug fixes for the companion report are
tracked separately in PR #14244 (fix-test-suite-bugs, open as of this writing) — Phase 1
items touching the same files should wait for that to merge, or rebase after.
All branches below have been rebased onto origin/dev (post-#14244 merge, 091ddf7c4) and
re-verified on the VM; commit hashes reflect the post-rebase state.
| # | File | Branch | Commit(s) | Status |
|---|---|---|---|---|
| 1 | paperless/tests/test_signals.py |
test-convert-signals |
929355291 |
done |
| 2 | paperless/tests/parsers/test_tesseract_custom_settings.py |
test-convert-tesseract-custom-settings |
306adaaaf |
done |
| 5 | documents/tests/test_api_app_config.py |
test-convert-api-app-config |
f5e138d90, 4044fe875 |
done |
| 11 | paperless/tests/settings/test_settings.py |
test-convert-settings |
7b7e889f9 |
done |
| 9 | documents/tests/test_api_schema.py |
test-convert-api-schema |
1e534d9f3 |
done |
| 7 | paperless_ai/tests/test_matching.py |
test-convert-ai-matching |
05690997f |
done |
| 8 | documents/tests/test_api_chat.py |
test-convert-api-chat |
2744361f3 |
done |
| 16 | test_api_documents.py:3361-3412 regex matching → test_api_object_regex_matching.py |
test-convert-api-documents-slices |
cc669257e |
done (branch reused for further test_api_documents.py slices) |
| 17 | test_api_documents.py:3309-3360 logs → test_api_logs.py |
test-convert-api-documents-slices |
624118b6a |
done |
| 18 | test_api_documents.py:4440-4698 TestDocumentApiTagColors → test_api_tag_colors.py, TestDocumentApiCustomFieldsSorting → test_api_document_custom_field_sorting.py |
test-convert-api-documents-slices |
b607174b0, 871524377 |
done |
| 6 | documents/tests/test_merge_documents_as_versions.py |
test-convert-merge-documents-as-versions |
ab2af7ba2 |
done |
| 19 | test_management_exporter.py:1060-1275 CLI argument validation → TestExporterCliValidation |
test-convert-management-exporter |
ddcc3595d |
done |
| 10 | documents/tests/test_document_model.py |
test-convert-document-model |
ae20342f8 |
done |
| 12 | documents/tests/test_admin.py |
test-convert-admin |
3d2813dd8 |
done |
| 20 | test_api_permissions.py:28-91, 1488-1562, 1665-1693 → TestAuthRequired, TestBulkEditSetPermissionsValidation, TestFullPermissionsFlag |
test-convert-api-permissions |
8defe169d |
done |
| 3 | documents/tests/test_management_superuser.py |
test-convert-management-superuser |
0272366ea |
done |
| 4 | documents/tests/test_management_fuzzy.py |
test-convert-management-fuzzy |
a976bb504 |
done |
| P2-1 | paperless_testing/factories.py: CustomFieldFactory, CustomFieldInstanceFactory, WorkflowTriggerFactory, WorkflowActionFactory, WorkflowFactory |
test-add-custom-field-workflow-factories |
79a802293 |
done |
| 29 | documents/tests/test_api_filter_by_custom_fields.py, whole file |
test-add-custom-field-workflow-factories |
32dd7df43 |
done |
| 30 | test_workflows.py DOCUMENT_ADDED filters → TestDocumentAddedTriggerFilters |
test-add-custom-field-workflow-factories |
8c2615e71 |
done |
Where things stand
- 48 test files still contain
TestCase/APITestCaseclasses (about 36k lines); 114 test files are already pytest-style. DirectoriesMixinis no longer a blocker:paperless_testing/dirs.py:135is an autouse bridge topaperless_dirsand works on plain classes too.- The real blocker is the
TestCasebase itself.@pytest.mark.parametrizeandmockerdon't work on its methods, so every "parametrize this cluster" item means moving that cluster into a plain@pytest.mark.django_dbclass. - pytest 9.1 reports
self.subTestfailures natively. For files not being converted yet, addingsubTestto a bare loop is a real fix, not a stopgap.
Conventions for the conversions
- Build users with
UserFactory. Use plainUserFactory()plusgrant_global/grant_objectfor permissioned users, andUserFactory(superuser=True)where the test cares about the user.admin_client(and soadmin_user) is fine where a test just needs an authenticated superuser; the autouse MD5 hasher makes it cheap. Use it where it fits, not by default. - Staff and superuser are different gates.
admin_user(pytest-django,create_superuser) andUserFactory(superuser=True)set both flags, so tests using them can't tell which flag a code path checks. Paperless gates several things onis_staffalone:PaperlessAdminPermissions, the task queryset (documents/views.py:4518) and duplicate visibility (documents/serialisers.py:2778). Tests for those should useUserFactory(staff=True)and say which flag they mean. - Hand-rolled
Model.objects.createbecomes the factory where one exists. Keep explicitpk=,checksum=,title=orcontent=where the test asserts on them:DocumentFactoryfills title and content with Faker text, which matters for the content-matching workflow tests. @override_settingsbecomes thesettingsfixture. A class-level@override_settingscan't decorate a plain pytest class, so it becomes an autouse fixture.assertLogsbecomescaplog;mock.patchbecomesmocker;patch.dict(os.environ)becomesmonkeypatch.- Drop the redundant setup the shared layer already does:
cache.clear()(autouse_clear_django_caches),reset_backend()in search setUp/tearDown (paperless_dirs/_search_index), andforce_loginright afterforce_authenticate. strict_parametrization_idsis on, so everypytest.paramneeds anid=.
Phase 1: mechanical wins, no new infra (S effort each)
Batch 2 to 4 files per PR. Start with test_signals.py as the reference conversion.
Whole-file (or last-class) conversions
| # | File | What collapses | Value |
|---|---|---|---|
| 1 | paperless/tests/test_signals.py |
Failed-login 4→1; group sync 5→1; role sync 9 plus the 3 inline cases in test_sync_both_groups → 1. About 700 lines to about 300. No mixins, no setUp state. Add GroupFactory here (8 hand-rolled Group creates). |
high |
| 2 | paperless/tests/parsers/test_tesseract_custom_settings.py |
9 near-identical "settings plus DB config → parser params" tests become 1 or 2. The existing tesseract fixtures mock out the DB config, so they don't fit; use an app_config DB fixture. |
high |
| 3 | documents/tests/test_management_superuser.py |
test_no_user and test_no_password are duplicates; create/email/username 3→1; "exists" pair 2→1. DirectoriesMixin not needed. Fixes the patch.dict env leak. |
high |
| 4 | documents/tests/test_management_fuzzy.py |
Ratio limits 2→1; no-match/matches/3-matches/empty 4→1. No mixins. Use DocumentFactory (14 hand-rolled creates). |
high |
| 5 | documents/tests/test_api_app_config.py |
12 SVG-rejection tests → 1; 2 SVG accepts → 1; 5 internal-endpoint tests → 1; subTest at L226 → parametrize. No mocks, trivial setUp. | high |
| 6 | documents/tests/test_merge_documents_as_versions.py |
The same 3 @mock.patch on 7 methods become one autouse mocker fixture; about 6 serializer rejection tests → 1 or 2; permission pair → 1. |
med |
| 7 | paperless_ai/tests/test_matching.py |
Last TestCase class; test_match_*_by_name 4→1. The file already imports all four factories. |
med-high |
| 8 | documents/tests/test_api_chat.py |
Last APITestCase class; reuse the existing mocked_stream_chat fixture; oversized/missing question 2→1. |
med-high |
| 9 | documents/tests/test_api_schema.py |
Last APITestCase class; reuse the session-scoped api_schema fixture (faster); set-subset asserts in place of loops. |
med-high |
| 10 | documents/tests/test_document_model.py |
Manual tempdir plus override_settings.enable() becomes paperless_dirs; 4 test_file_name* → 1. |
med-high |
| 11 | paperless/tests/settings/test_settings.py |
Last two unittest.TestCase classes; the 63-case loop at L28 becomes parametrize. |
med |
| 12 | documents/tests/test_admin.py |
Last class; redundant reset_backend in setUp/tearDown; grant_global at L193. |
med |
| 13 | documents/tests/test_tag_hierarchy.py |
Already plain assert; two 2→1 pairs; TagFactory for 25 creates. |
med |
| 14 | documents/tests/test_management_retagger.py |
Replace DirectoriesMixin on 6 classes with pytestmark = pytest.mark.usefixtures("paperless_dirs"). Five minutes. |
low |
| 15 | test_api_uisettings.py, settings/test_remote_user.py, test_management.py, test_version_conditionals.py, test_config_precedence.py, test_compression_middleware.py, test_auth_middleware.py, test_api_auth.py |
Small files; mostly base-class removal. Batch them together. | low-med |
Slices of big files
Carve these out into new plain classes, or new files where they don't belong in the host file.
| # | Slice | Collapse | Value |
|---|---|---|---|
| 16 | test_api_documents.py:3361-3412 regex matching → new file (it tests the objects endpoints) |
4 tests × 3 endpoints → 1 test, 12 params | high |
| 17 | test_api_documents.py:3309-3360 logs → test_api_logs.py |
7 tests → about 3 | med-high |
| 18 | test_api_documents.py:4440-4698 TestDocumentApiTagColors, TestDocumentApiCustomFieldsSorting |
color validation → params; the unguarded data_type loop → about 8 params |
med-high |
| 19 | test_management_exporter.py:1060-1275 CLI argument validation → lightweight class (tmp_path only) |
4 tests (3 are subTest loops) → 1 with 7 ids, plus the zstd pair. They stop paying for the heavy setUp. Also turn test_exporter / _with_filename_format (a test calling a test) into a parametrize. |
high |
| 20 | test_api_search.py:2103-2175 search_by_* |
4 → 1, on _search_index |
med |
| 21 | test_api_document_versions.py:886-1024 two pure ORM filter classes |
direct conversion; one 2→1 pair | med |
| 22 | test_api_custom_fields.py:705-993 value validation plus the L28 create loop |
6 → 1 (about 10 params); create loop → params | med-high |
| 23 | test_api_permissions.py:28-91, 1488-1562, 1665-1693 |
14-URL auth-required list → params; set_permissions rejection 3→1; full-permissions flag → params | med |
| 24 | test_api_objects.py:540-606 storage-path render and hidden-field loops |
1 test → 2 tests, 5 params (security-relevant) | med |
| 25 | test_workflows.py:4427-4490 password removal pair |
2 → 1; fixes bug 4 | med |
| 26 | test_tasks.py |
auto-match trio 3→1; RemoteOCR trio 3→1; last TestCase classes |
med |
| 27 | paperless_mail/tests/test_mail.py small classes: TestPostConsumeAction (bug 2), TestManagementCommand, TestTasks, TestGetMailboxHostPinning, TestMailAccountProcess (its MailMocker is unused), TestMailRuleAPI |
mostly mechanical; use MailAccountFactory / MailRuleFactory |
med |
| 28 | test_consumer.py TestMetadataOverrides, TestBarcodeApplyDetectedASN |
plain TestCase, no mixins |
low-med |
Phase 1b: subTest fixes in files not being converted soon
One PR. Each fix is a few lines and makes failures report which case broke.
test_api_documents.py:4622custom-field sorting (8 types; skip if item 18 lands first)test_api_documents.py:2415upload custom-field errors (5 payloads)test_views.py:59test_index(7 languages; cookie state carries between iterations)test_barcodes.py:208(3 files)test_api_workflows.py:1211, 1335(webhook URLs, password payloads)test_api_bulk_edit.py:2113-2197: fold about 7 inline POST+assert blocks into the(operation, expected)subTest table that follows themtest_api_bulk_edit.py:1102test_api_selection_data_emptytest_mail.py:1826label fix (bug 3)- Already pytest-style, so parametrize instead:
paperless/tests/parsers/test_mail_parser.py:826(a nested helper called 4 times),paperless/tests/test_celery.py:17
Every other existing subTest use in the suite was reviewed and is fine.
Lower-value DRY merges in files that are already pytest-style (they fail independently
today, so there's no reporting gain): test_parser_utils.py:23-46,
test_migration_replace_skip_archive_file.py:48-78, the paired tests in
test_tesseract_parser.py, test_text_parser.py, test_ocr_config.py,
search/test_backend.py, search/test_query.py, and the management command tests. Pick
these up opportunistically.
Phase 2: shared infra PR(s)
This unblocks the high-value Phase 3 work. Four surveys converged on the same list, ordered by how much each item unblocks:
- Factories in
paperless_testing/factories.py:GroupFactory, if not already added in item 1 (about 25 hand-rolled creates suite-wide)CustomFieldFactoryandCustomFieldInstanceFactory, used by custom_fields, filter_by_custom_fields, bulk_edit, documents sorting and the exporterWorkflowFactory,WorkflowTriggerFactoryandWorkflowActionFactory, with post-generationtriggers/actionshooks.test_workflows.pyalone hand-rolls about 100 of each, plus about 90triggers.add/actions.addsequences.- Optional:
ShareLinkFactory,ShareLinkBundleFactory,NoteFactory,SavedViewFactory
consume_task_mockfixture to replaceConsumeTaskMixin(documents/tests/utils.py:126): amocker.patchon the consume task, with the call-args helpers as free functions. It unblocks about 20 upload tests intest_api_documents.py(L1850-2503) andTestBarcodeNewConsume.samples_dirandbarcode_samples_dirfixtures.document_samples_dirpoints atsamples/documents/, not thesamples/root thatSampleDirMixin.SAMPLE_DIRuses. Needed by email, bulk_download, thumbnails, barcodes and exporter.progress_managerfixture for thedocuments.tasks.ProgressManager→DummyProgressManagerpatch: 14 times in workflows, plus barcodes, consumer and double_sided.- Mail mock support module. Move
BogusMailBox,BogusClient,MessageBuilder,fake_magic_from_bufferandreset_bogus_mailboxout oftest_mail.py(L53-440).test_api.pyandtest_mail_nfc.pycurrently import them from a test file. Addbogus_mailbox,message_builderandmail_mockerfixtures topaperless_mail/tests/conftest.py, and promote the local ones intest_mail_nfc.py. make_clientfactory fixture returning an authenticatedAPIClient(v10 header) for any given user. It covers:- about 115 mid-test
self.client.force_authenticate(...)user switches across the API tests - the "non-superuser with
grant_all_global" client built by hand intest_api_trash.py, in all three classes ofpaperless_mail/tests/test_api.py, and locally asviewer_clientintest_api_chat.py
- about 115 mid-test
- Smaller items:
- an
app_configfixture (if not already added in item 2) assert_file/assert_file_counthelpers to replaceFileSystemAssertsMixin- a
consumer_factoryfixture to replaceGetConsumerMixin - a
barcode_readerfixture to replaceGetReaderPluginMixin(get_readeris also duplicated attest_barcodes.py:658and:817) - a "documents with real files on disk" helper on
paperless_dirs, for email, bulk_download, thumbnails and document_model
- an
Items 1-3 could be one PR; 4-6 another.
Phase 3: high value once Phase 2 lands (M effort, one file per PR)
| # | Target | Collapse | Needs |
|---|---|---|---|
| 29 | test_api_filter_by_custom_fields.py, whole file |
About 25 predicate tests → 1; 10 invalid-query tests → 1. About 35 tests to about 3, the best ratio in the suite. | CustomField factory (or inline creates); setUp → fixture |
| 30 | test_workflows.py DOCUMENT_ADDED filters, L813-1895, as TestDocumentAddedTriggerFilters |
11 "did not match" tests → 1 (about 330 lines) | Workflow factories |
| 31 | test_api_bulk_edit.py validation class |
11 invalid-parameter tests (L916-1094) → 1; 10 subTest loops → parametrize (the 2×8 legacy-method one at L1985 is the best) | one setUp fixture (local) |
| 32 | test_classifier.py TestClassifier |
predict trio and _manydocs trio 6→2; corrupt-file loaders 4→1; load_classifier_raise_exception 4 blocks → params |
setUp → fixture |
| 33 | test_barcodes.py TestBarcode first |
"scan → separation pages" repeated about 14 times → 1; about 30 tests to about 8 across the file | samples fixtures, barcode_reader, consume_task_mock |
| 34 | test_api_status.py |
classifier/sanity 6→1; celery, redis and AI groups; manual patchers → autouse mocker |
none |
| 35 | test_api_email.py |
about 8 "payload → 400" cases → 1; mailoutbox |
samples_dir, file-backed docs |
| 36 | test_file_handling.py TestFilenameGeneration first, then TestFileHandlingWithArchive |
about 25 tests to about 10 across the file; 59 @override_settings |
none |
| 37 | test_share_link_bundles.py |
about 9 tests collapse; FilterSet and Model classes first | bug 1 fixed; optional factories |
| 38 | test_views.py |
AI error quartet 4→1; chat permission pair 2→1; grant_global in place of codename__contains grants |
none |
| 39 | test_api_search.py:431-753 relative-date / timezone |
6 → 1 | care with timezone.now() and settings.TIME_ZONE |
| 40 | test_api_documents.py upload cluster, L2055-2213 |
9 → 2 | consume_task_mock |
| 41 | paperless_mail/tests/test_api.py |
subTest → params; test-endpoint 2→1 and 3→1; merge the overlapping TestMailAccountTestView from test_mail.py |
mail fixtures, make_client |
| 42 | test_api_bulk_download.py, test_api_trash.py, test_api_profile.py, test_management_thumbnails.py, test_api_workflows.py, test_api_objects.py, test_api_permissions.py (rest) |
moderate dedup each | Phase 0 and Phase 2 items |
Phase 4: large; do class by class or defer (L)
- The rest of
test_workflows.py: split the 6k-lineTestWorkflowsinto about 9 topic classes (consumption, document added, updated, scheduled, email, webhook, password, trash). It has 43assertLogs, 51mock.patchand 17override_settings. test_mail.pyTestMail: 2 subTest loops → params (9 and 8), 2 groups of mail-action tests 4→1 each. Several tests carryflaky(reruns=4).test_consumer.py: about 17 tests to about 7, onceconsumer_factoryexists.test_bulk_edit.py: 99 stacked@mock.patch, modest dedup.TestBulkEditReprocessandTestBulkEditcan go first.- The rest of
test_api_documents.pyand theTestExportImportcore oftest_management_exporter.py.test_import_db_transaction_failedmay needdjango_db(transaction=True).
Suggested PR order
- Bug fixes (companion report); bug 1 is the important one.
- Phase 1 in batches:
test_signals.pyfirst, then items 2-5, then the rest. - Phase 1b: one
subTestPR. - Phase 2 infra, in one or two PRs.
- Phase 3, one file per PR, in table order.
- Phase 4 opportunistically.