Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
24 changes: 22 additions & 2 deletions deadcode/visitor/ignore.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)
)


Expand Down
76 changes: 76 additions & 0 deletions tests/test_unittest_lifecycle_methods.py
Original file line number Diff line number Diff line change
@@ -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
Loading