Why does `python -m unittest discover` report zero tests in a directory full of `test_*.py` files?
answer
- Discovery is a walk plus an import
- It only descends into real packages
- The default glob is stricter than you think
- One empty file usually fixes it
- `__init__.py` and `test*.py`
basics
~10 sUsually the directory has no __init__.py, so discovery refuses to recurse into it and silently finds nothing. The other common cause is a file name that does not match the default test*.py pattern.
solid answer
~40 sDiscovery walks the start directory and only descends into a subdirectory that is a real package — one containing an `__init__.py`. A `tests/` folder without that file is skipped without a word, and the run ends in `NO TESTS RAN`. The second cause is naming: the default pattern is `test*.py`, so `test_csv_import.py` matches but `csv_import_test.py` does not, and you need `-p '*_test.py'`. Three flags control the walk: `-s` sets where discovery starts, `-p` the file-name pattern, and `-t` the top-level directory that goes onto `sys.path` and anchors module names. If `-s` names a directory other than the top-level one, that directory must be importable, and discovery raises `ImportError` saying the start directory is not importable when it lacks an `__init__.py`.
code
python · 18 linesimport os, tempfile, textwrap, unittest
root = os.path.realpath(tempfile.mkdtemp())
os.mkdir(os.path.join(root, "tests"))
with open(os.path.join(root, "tests", "test_csv_import.py"), "w") as fh:
fh.write(textwrap.dedent('''
import unittest
class CsvImportTests(unittest.TestCase):
def test_header_is_validated(self):
self.assertTrue(True)
'''))
loader = unittest.TestLoader()
print(loader.discover(root).countTestCases()) # 0 - tests/ is not a package
open(os.path.join(root, "tests", "__init__.py"), "w").close()
print(loader.discover(root).countTestCases()) # 1go deeper
Remember the two things to check when a run finds nothing: an __init__.py in every test directory, and file names that match test*.py. Know that NO TESTS RAN is a problem, not a pass.
Explain the walk-plus-import model and what each of -s, -p and -t changes, including that the top-level directory is the one added to sys.path. Be ready to state that namespace-package discovery was removed in 3.11.
Demonstrate the diagnosis path: compare a discovery run against loading the same module by dotted name to separate a walk problem from an import problem, and explain why a broken import surfaces as an error rather than a shrinking test count.
Own the layout and the entry point. Decide the repository's test directory convention and pin one documented command so a developer's run and CI's run collect an identical set — a suite that silently shrinks in one environment is worse than a red build.
Discovery is a directory walk plus an import, and almost every "it finds nothing" report is one of those two halves failing quietly. ### The algorithm `python -m unittest discover` takes three options that map onto `unittest.TestLoader.discover`: - `-s START` — where the walk begins. Default: the current directory. - `-p PATTERN` — the file-name glob. Default: `test*.py`. - `-t TOP` — the top-level directory of the project. Default: the same as the start directory. The top-level directory is placed on `sys.path` and every discovered file's module name is computed relative to it. Then the walk begins. For each entry in the start directory, sorted by name: a file matching the pattern is imported as a module and scanned for `unittest.TestCase` subclasses; a directory is descended into **only if it contains an `__init__.py`**. ### Cause one: the directory is not a package That last rule is the classic trap. Support for namespace packages in discovery was removed in Python 3.11 (it had been broken since 3.7), so a `tests/` directory with no `__init__.py` is not a candidate — it is skipped with no message at all, and the run prints `Ran 0 tests` and `NO TESTS RAN`. What makes this genuinely confusing is that the *import system* is perfectly happy with such a directory: `python -m unittest tests.test_csv_import` imports it as a namespace package and runs fine. So a suite can be runnable by name and invisible to discovery at the same time. Adding an empty `__init__.py` fixes it. The mirror-image failure is loud. Point the start directory somewhere below an explicit top level, as in `python -m unittest discover -s tests -t .`, and the two directories differ; discovery then insists the start directory be importable from the top level and raises `ImportError: Start directory is not importable` when `tests/__init__.py` is missing. Note the asymmetry: `-s tests` on its own does *not* raise, because with no `-t` the top-level defaults to the start directory, which is then simply put on `sys.path` and walked as a plain folder. ### Cause two: the file name does not match The default pattern is `test*.py`, matched with shell-glob semantics against the base name. `test_csv_import.py` matches; `csv_import_test.py`, `check_csv.py` and `tests.py` inside a package do not. Teams that use the suffix convention need `-p '*_test.py'` on every invocation, which is a good reason to wrap the command in a make target or a project script rather than relying on everyone remembering it. One more subtlety about the pattern: it is applied to files, never to directory names. A directory called `checks` full of `test_*.py` files is walked normally as long as it is a package, and a directory called `tests` earns no special treatment for its name alone. The `__init__.py` of a package is not itself matched against the pattern either — it is imported because the package is imported, which is what makes it a useful place to hook discovery for a whole subtree. ### What discovery does when a match fails to import A matched file that raises during import is not skipped. Discovery substitutes a placeholder test that re-raises the import error, so the run reports it as an **error** with the original traceback and exits non-zero. That is deliberate: a test module that cannot be imported is a failing test, not an absent one. Reading that traceback usually points straight at a missing dependency or a circular import in the module under test. ### Ordering and reproducibility Entries are sorted, and within a `unittest.TestCase` the method names are sorted too, so a discovery run is deterministic on a given tree. It is *not* stable against edits: adding `test_aaa_rates.py` changes what runs before what, which is how a latent inter-test dependency surfaces on an unrelated commit. ### Diagnosing it in practice Work the two halves in order. First check the walk: `python -m unittest discover -v` prints each test it loaded, so an empty list means nothing was matched or descended into. Then check the import by naming a module directly, `python -m unittest tests.test_csv_import` — if that works while discovery finds nothing, the difference is the missing `__init__.py`, because named loading tolerates a namespace package and the walk does not. If the direct name fails too, it is an import problem and the traceback tells you which. ### What to say in an interview Name the default pattern, the `__init__.py` requirement on every directory walked, and the `-s`/`-p`/`-t` split — especially that `-t` is the thing that lands on `sys.path` and anchors module names. Mentioning that namespace-package discovery was dropped in 3.11 shows you know why so much older advice on this is now wrong.
- What is the difference between `-s` and `-t` in `python -m unittest discover`?`-s` is where the walk starts; `-t` is the project root that goes onto `sys.path` and against which every discovered file's module name is computed. When they are the same, the start directory is simply added to the path and walked. When `-t` is higher up, the start directory must itself be importable from it — a missing `__init__.py` then raises `ImportError: Start directory is not importable` instead of silently finding nothing.
- A discovered test module raises on import. Is it skipped?No. Discovery substitutes a placeholder test that re-raises the exception, so the run reports it as an error with the original traceback and exits non-zero. A module that cannot be imported is treated as a failing test rather than an absent one, which is what stops a broken import from quietly shrinking the suite.
- Your team names files `something_test.py`. How do you discover them?Pass `-p '*_test.py'`, since the default pattern is `test*.py` and matches only the prefix convention. Because that flag has to be on every invocation, put the whole command in a project script or make target so local runs and CI cannot drift apart.
Discovery is a courier who will only enter a building that has a nameplate on the door. The parcels are inside either way; without the nameplate the courier walks past and reports nothing to deliver.
saying these in an interview costs you the question
- Believing discovery walks any directory, package or not
- Assuming the default pattern is `test_*.py` rather than `test*.py`
- Thinking `NO TESTS RAN` means the tests passed
- Confusing -s with -t, so module names resolve against the wrong root
- Claiming namespace packages are still discovered, which stopped in 3.11
- Assuming a test module that fails to import is silently skipped