diff --git a/README.md b/README.md index f3ea60f..55e2425 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,10 @@ def foo(): # noqa: DC02 A comment on the first decorator line is still accepted. +## Ignoring unittest lifecycle methods +`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. + ## Contributing Create a feature branch from `main`, open a pull request targeting `main`, and @@ -266,6 +270,7 @@ the public module-level names are exported. - --ignore-bodies-if-inherits-from and --ignore-definitions-if-inherits-from now recognize dotted base classes such as graphene.ObjectType and match their final component. - The option help text and README document the accepted plain, dotted and glob base-name forms. - Render decorator names deterministically, including dotted names, and stop unusual decorators such as `@Class().function()` from crashing or producing memory addresses. + - Stop reporting unittest lifecycle methods declared in test files as unused code (issue #21). - v2.5.0 (2026-11-07): - Bugfixes: - Fix a crash when a decorator has no `id` attribute. diff --git a/deadcode/visitor/ignore.py b/deadcode/visitor/ignore.py index 4895aff..a8cc99b 100644 --- a/deadcode/visitor/ignore.py +++ b/deadcode/visitor/ignore.py @@ -19,6 +19,18 @@ 'setup_method', 'teardown_method', } +UNITTEST_METHOD_NAMES = { + 'setUp', + 'tearDown', + 'setUpClass', + 'tearDownClass', + 'asyncSetUp', + 'asyncTearDown', +} +UNITTEST_FUNCTION_NAMES = { + 'setUpModule', + 'tearDownModule', +} ERROR_CODES = { 'variable': b'DC01', @@ -88,12 +100,20 @@ def _ignore_import(filename: Path, import_name: str) -> bool: def _ignore_function(filename: Path, function_name: str) -> bool: - return (function_name in PYTEST_FUNCTION_NAMES or function_name.startswith('test_')) and _is_test_file(filename) + # 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. + return ( + function_name in PYTEST_FUNCTION_NAMES + or function_name in UNITTEST_METHOD_NAMES + or function_name in UNITTEST_FUNCTION_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.startswith('test_')) and _is_test_file(filename) + (method_name in PYTEST_METHOD_NAMES or method_name in UNITTEST_METHOD_NAMES or method_name.startswith('test_')) + and _is_test_file(filename) ) diff --git a/tests/test_unittest_lifecycle_methods.py b/tests/test_unittest_lifecycle_methods.py new file mode 100644 index 0000000..e3d9d65 --- /dev/null +++ b/tests/test_unittest_lifecycle_methods.py @@ -0,0 +1,76 @@ +from pathlib import Path + +import pytest + +from deadcode.cli import main, print_main +from deadcode.utils.base_test_case import BaseTestCase + +SOURCE = b""" +from unittest import TestCase + + +class MyTests(TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def setUpClass(cls): + pass + + def tearDownClass(cls): + pass + + def test_something(self): + pass +""" + +SOURCE_WITH_UNUSED_HELPER = ( + SOURCE + + b""" + def unused_helper(self): + pass +""" +) + + +class TestUnittestLifecycleMethods(BaseTestCase): + def test_lifecycle_methods_in_test_file_are_not_reported(self): + self.files = {'/project/tests/test_sample.py': SOURCE} + + self.assertIsNone(main(['/project/tests/test_sample.py', '--no-color'])) + self.assertEqual(print_main(['/project/tests/test_sample.py', '--no-color']), 0) + + def test_lifecycle_methods_outside_test_file_are_still_reported(self): + self.files = {'/project/sample.py': SOURCE} + + output = main(['/project/sample.py', '--no-color']) + + assert output is not None + self.assertIn('DC04 Method `setUp` is never used', output) + self.assertIn('DC04 Method `tearDown` is never used', output) + self.assertIn('DC02 Function `setUpClass` is never used', output) + self.assertIn('DC02 Function `tearDownClass` is never used', output) + self.assertEqual(print_main(['/project/sample.py', '--no-color']), 1) + + def test_unrelated_unused_method_in_test_file_is_still_reported(self): + self.files = {'/project/tests/test_sample.py': SOURCE_WITH_UNUSED_HELPER} + + output = main(['/project/tests/test_sample.py', '--no-color']) + + assert output is not None + self.assertEqual(len(output.splitlines()), 1) + self.assertTrue(output.endswith('DC04 Method `unused_helper` is never used')) + + +def test_unittest_lifecycle_in_temporary_test_directory_returns_no_findings( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + test_directory = tmp_path / 'tests' + test_directory.mkdir() + (test_directory / 'test_sample.py').write_bytes(SOURCE) + monkeypatch.chdir(tmp_path) + + assert main(['tests', '--no-color']) is None + assert print_main(['tests', '--no-color']) == 0