diff --git a/README.md b/README.md index c88cc09..bb71950 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,11 @@ A comment on the first decorator line is still accepted. `unittest` lifecycle methods (`setUp`, `tearDown`, `setUpClass`, `tearDownClass`, `asyncSetUp`, `asyncTearDown`, `setUpModule`, `tearDownModule`) declared in test files are ignored by default, alongside the existing pytest hooks. Declarations with those names outside test files are still reported as unused code. +## Ignoring Django test lifecycle methods +Django test lifecycle methods (`setUpTestData`, the `django.test.TestCase` class method invoked by the framework, and the internal hooks `_pre_setup`, `_post_teardown`, `_fixture_setup`, `_fixture_teardown`) declared in test files are ignored by default. +Declarations with those names outside test files are still reported as unused code. +Django helpers that your test code invokes itself are not suppressed, so an unreferenced `assertContains`, `assertNumQueries`, `settings`, `modify_settings`, or `captureOnCommitCallbacks` is still reported. + ## Contributing Create a feature branch from `main`, open a pull request targeting `main`, and @@ -294,6 +299,7 @@ A checked entry is implemented on `main` and is included in the next release; pu - Features: - Audit runtime and development dependencies for known vulnerabilities in CI on every pull request and weekly. - Declare the package development status as Beta. + - Ignore Django test lifecycle methods in test files. - **v2.6.0** `2026-11-08`: - Features: - Add support for Python 3.13, 3.14 and 3.15. diff --git a/deadcode/visitor/ignore.py b/deadcode/visitor/ignore.py index 6495b72..f58c916 100644 --- a/deadcode/visitor/ignore.py +++ b/deadcode/visitor/ignore.py @@ -31,6 +31,13 @@ 'setUpModule', 'tearDownModule', } +DJANGO_METHOD_NAMES = { + 'setUpTestData', + '_pre_setup', + '_post_teardown', + '_fixture_setup', + '_fixture_teardown', +} # Built-in interface markers: abstract and overriding definitions are never reported, no option needed. IGNORED_INTERFACE_DECORATOR_NAMES: set[str] = { '@abstractmethod', @@ -111,17 +118,24 @@ def _ignore_import(filename: Path, import_name: str) -> bool: def _ignore_function(filename: Path, function_name: str) -> bool: # Class-level lifecycle methods such as `def setUpClass(cls)` have no decorator and a first argument other # than `self`, so `visit_FunctionDef` classifies them as functions and they must be suppressed here as well. + # Django lifecycle methods are declared the same way. return ( function_name in PYTEST_FUNCTION_NAMES or function_name in UNITTEST_METHOD_NAMES or function_name in UNITTEST_FUNCTION_NAMES + or function_name in DJANGO_METHOD_NAMES or function_name.startswith('test_') ) and _is_test_file(filename) def _ignore_method(filename: Path, method_name: str) -> bool: return _is_special_name(method_name) or ( - (method_name in PYTEST_METHOD_NAMES or method_name in UNITTEST_METHOD_NAMES or method_name.startswith('test_')) + ( + method_name in PYTEST_METHOD_NAMES + or method_name in UNITTEST_METHOD_NAMES + or method_name in DJANGO_METHOD_NAMES + or method_name.startswith('test_') + ) and _is_test_file(filename) ) diff --git a/docs/features.md b/docs/features.md index 714cb63..7740911 100644 --- a/docs/features.md +++ b/docs/features.md @@ -43,6 +43,7 @@ for every row. | Per-rule fixing | `### Rules` | `deadcode/actions/remove_file_parts_from_content.py` | `tests/fix/test_unused_imports.py`, `tests/fix/test_assign.py` | 3 | | `# noqa` comments | `## Ignoring checks with noqa comments` | `deadcode/visitor/noqa.py` | `tests/noqa/test_noqa_comments.py` | 3 | | Unittest lifecycle methods | `## Ignoring unittest lifecycle methods` | `deadcode/visitor/ignore.py` | `tests/visitor/test_unittest_lifecycle_methods.py` | 3 | +| Django test lifecycle methods | `## Ignoring Django test lifecycle methods` | `deadcode/visitor/ignore.py` | `tests/visitor/test_django_lifecycle_methods.py` | 3 | | FastAPI guide | `## Using deadcode on a FastAPI project` | `deadcode/actions/parse_arguments.py` | `tests/documented_examples/test_fastapi.py`, `tests/documented_examples/test_fastapi_documentation.py` | 3 | | Pre-commit hook | `## Pre-commit hook` | `.pre-commit-hooks.yaml` | `tests/documented_examples/test_pre_commit_hook_configuration.py` | 3 | | Wildcard imports and `__all__` | `## Known limitations` | `deadcode/visitor/module_exports.py` | `tests/visitor/test_wildcard_imports.py` | 3 | diff --git a/tests/documented_examples/test_documented_features.py b/tests/documented_examples/test_documented_features.py index d787033..1310020 100644 --- a/tests/documented_examples/test_documented_features.py +++ b/tests/documented_examples/test_documented_features.py @@ -39,6 +39,7 @@ 'Per-rule fixing', '# noqa comments', 'Unittest lifecycle methods', + 'Django test lifecycle methods', 'FastAPI guide', 'Pre-commit hook', 'Wildcard imports and __all__', diff --git a/tests/visitor/test_django_lifecycle_methods.py b/tests/visitor/test_django_lifecycle_methods.py new file mode 100644 index 0000000..0ba9115 --- /dev/null +++ b/tests/visitor/test_django_lifecycle_methods.py @@ -0,0 +1,207 @@ +from pathlib import Path + +import pytest + +from deadcode.cli import main, print_main + +SOURCE = b""" +from unittest import TestCase + + +class MyTests(TestCase): + def setUpTestData(cls): + pass + + def _pre_setup(self): + pass + + def _post_teardown(self): + pass + + def _fixture_setup(self): + pass + + def _fixture_teardown(self): + pass + + def test_something(self): + pass +""" + +SOURCE_WITH_UNUSED_HELPER = ( + SOURCE + + b""" + def unused_helper(self): + pass +""" +) + +SOURCE_WITH_DECORATED_SET_UP_TEST_DATA = b""" +from django.test import TestCase + + +class MyTests(TestCase): + @classmethod + def setUpTestData(cls): + pass + + def test_something(self): + pass +""" + +SOURCE_WITHOUT_TEST_CASE_BASE = b""" +class TestMySuite: + def setUpTestData(cls): + pass + + def _pre_setup(self): + pass +""" + +SOURCE_WITH_USER_INVOKED_HELPERS = b""" +class MyTests: + def assertContains(self): + pass + + def assertNumQueries(self): + pass + + def settings(self): + pass + + def modify_settings(self): + pass + + def captureOnCommitCallbacks(self): + pass +""" + + +def test_django_lifecycle_methods_in_test_file_are_not_reported(virtual_file_system): + # Having + virtual_file_system.files = {'/project/tests/test_sample.py': SOURCE} + + # When + result = main(['/project/tests/test_sample.py', '--no-color']) + + # Then + assert result is None + + # When + result = print_main(['/project/tests/test_sample.py', '--no-color']) + + # Then + assert result == 0 + + +def test_django_lifecycle_declared_without_classmethod_is_not_reported(virtual_file_system): + # Having + virtual_file_system.files = {'/project/tests/test_sample.py': SOURCE_WITHOUT_TEST_CASE_BASE} + + # When + result = main(['/project/tests/test_sample.py', '--no-color']) + + # Then + assert result is None + + # When + result = print_main(['/project/tests/test_sample.py', '--no-color']) + + # Then + assert result == 0 + + +def test_django_test_case_class_method_set_up_test_data_is_not_reported(virtual_file_system): + # Having + virtual_file_system.files = {'/project/tests/test_sample.py': SOURCE_WITH_DECORATED_SET_UP_TEST_DATA} + + # When + result = main(['/project/tests/test_sample.py', '--no-color']) + + # Then + assert result is None + + # When + result = print_main(['/project/tests/test_sample.py', '--no-color']) + + # Then + assert result == 0 + + +def test_django_lifecycle_methods_outside_test_file_are_still_reported(virtual_file_system): + # Having + virtual_file_system.files = {'/project/sample.py': SOURCE} + + # When + output = main(['/project/sample.py', '--no-color']) + + # Then + assert output is not None + assert 'DC02 Function `setUpTestData` is never used' in output + assert 'DC04 Method `_pre_setup` is never used' in output + assert 'DC04 Method `_post_teardown` is never used' in output + assert 'DC04 Method `_fixture_setup` is never used' in output + assert 'DC04 Method `_fixture_teardown` is never used' in output + + # When + result = print_main(['/project/sample.py', '--no-color']) + + # Then + assert result == 1 + + +def test_unrelated_unused_method_in_test_file_is_still_reported(virtual_file_system): + # Having + virtual_file_system.files = {'/project/tests/test_sample.py': SOURCE_WITH_UNUSED_HELPER} + + # When + output = main(['/project/tests/test_sample.py', '--no-color']) + + # Then + assert output is not None + assert len(output.splitlines()) == 1 + assert output.endswith('DC04 Method `unused_helper` is never used') + + +def test_user_invoked_django_helpers_in_test_file_are_still_reported(virtual_file_system): + # Having + virtual_file_system.files = {'/project/tests/test_sample.py': SOURCE_WITH_USER_INVOKED_HELPERS} + + # When + output = main(['/project/tests/test_sample.py', '--no-color']) + + # Then + assert output is not None + assert 'DC04 Method `assertContains` is never used' in output + assert 'DC04 Method `assertNumQueries` is never used' in output + assert 'DC04 Method `settings` is never used' in output + assert 'DC04 Method `modify_settings` is never used' in output + assert 'DC04 Method `captureOnCommitCallbacks` is never used' in output + + # When + result = print_main(['/project/tests/test_sample.py', '--no-color']) + + # Then + assert result == 1 + + +def test_django_lifecycle_in_nested_test_directory_returns_no_findings( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Having + test_directory = tmp_path / 'app' / 'tests' + test_directory.mkdir(parents=True) + (test_directory / 'test_models.py').write_bytes(SOURCE) + monkeypatch.chdir(tmp_path) + + # When + result = main(['app', '--no-color']) + + # Then + assert result is None + + # When + result = print_main(['app', '--no-color']) + + # Then + assert result == 0