Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Test discovery drops Python namespaces from relative imports?

I encountered a strange issue with unit tests in a namespaced package. Here's an example I built on GitHub. Here's the basic structure:

$ tree -P '*.py' src 
src
└── namespace
    └── testcase
        ├── __init__.py
        ├── a.py
        ├── sub
        │   ├── __init__.py
        │   └── b.py
        └── tests
            ├── __init__.py
            └── test_imports.py

4 directories, 6 files

I would expect that relative imports within a namespaced package would maintain the namespace. Normally, that seems to be true:

$ cat src/namespace/testcase/a.py 
print(__name__)
$ cat src/namespace/testcase/sub/b.py 
print(__name__)

from ..a import *
$ python -c 'from namespace.testcase.sub import b'
namespace.testcase.sub.b
namespace.testcase.a

But if I involve a test, I get a surprise:

$ cat src/namespace/testcase/tests/test_imports.py 
from namespace.testcase import a
from ..sub import b
$ python -m unittest discover src/namespace/
namespace.testcase.a
testcase.sub.b
testcase.a

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

The code in src/namespace/testcase/a.py is getting run twice! In my case, this caused a singleton I had stubbed to be re-initialized as a real object, subsequently causing test failures.

Is this expected behavior? What is the correct usage here? Should I always avoid relative imports (and have to do global search-and-replace if my company decides to rename something?)

like image 336
kojiro Avatar asked Dec 03 '25 17:12

kojiro


1 Answers

Problem: Overlapping sys.path entries

The duplicate imports with different module names happen when you have overlapping sys.path entries: that is, when sys.path contains both a parent and child directory as separate entries. This situation is almost always an error: it will make Python see the child directory as a separate, unrelated root for imports, which leads surprising behaviour.

In your example:

$ python -m unittest discover src/namespace/
namespace.testcase.a
testcase.sub.b
testcase.a

This means that both src and src/namespace ended up in sys.path, so that:

  • namespace.testcase.a was imported relative to src
  • testcase.sub.b and testcase.a were imported relative to src/namespace

Why?

In this case, the overlapping sys.path entries happen because unittest discover is trying to be helpful: it defaults to assuming that the start directory for test discovery is also the top-level directory that your imports are relative to, and it will insert that top-level directory into sys.path if it's not already there, as a convenience. (…not so convenient, it turns out. 😔️)

Solution: Explicitly specify the correct top-level directory

You can explicitly specify the correct top-level directory with -t (--top-level-directory):

python -m unittest discover -t src -s src/namespace/

This will work as before, but won't treat src/namespace as a top-level directory to insert into sys.path.

Side note: The -s option prefix for src/namespace/ was implicit in the previous example: the above just makes it explicit. (unittest discover has weird positional argument handling: it treats its first three positional arguments as values for -s, -p, and -t, in that order.)

Details

The code responsible for this lives in unittest/loader.py:

class TestLoader(object):

    def discover(self, start_dir, pattern='test*.py', top_level_dir=None):

        ...

        if top_level_dir is None:
            set_implicit_top = True
            top_level_dir = start_dir

        top_level_dir = os.path.abspath(top_level_dir)

        if not top_level_dir in sys.path:
            # all test modules must be importable from the top level directory
            # should we *unconditionally* put the start directory in first
            # in sys.path to minimise likelihood of conflicts between installed
            # modules and development versions?
            sys.path.insert(0, top_level_dir)

        ...
like image 56
Pi Delport Avatar answered Dec 06 '25 07:12

Pi Delport