6

My use case it is about remotely (RESTful API, etc.) testing a sub-system of an overall system. This means the "pytest test only package" doesn't have any dependencies on production code (means other production code python packages).

I created a python package which just contains test related stuff like pytest tests, pytest fixtures, test helper functions in util modules, pytests conftest.py, pytests pytest.ini, etc. It doesn't contain any production code related stuff.

Right now the functionality is working but the structure of the package is quite "hacky". This means the installation is not working correctly (test, fixture and conftest files are not installed correctly into site_packages via MANIFEST.ini) and deployment of the package must be done "manually".

In the pytest docs I just found best practices about how to structure a package containing both, production and pytest test code: Tests outside application code, Tests as part of application code and Test directory structure.

  1. How should I structure a python package which contains just test code? Are there alternative package structures (advantages, disadvantages)?
  2. Where should test files and their dependencies (fixtures, helpers, etc.) be installed into?

Possible solutions for 2. : The avocado-framework deploys example tests as data files in setup.py. Dependent on the configuration the tests are deployed into /usr/share/avocado/tests per default.

hoefling
  • 59,418
  • 12
  • 147
  • 194
thinwybk
  • 4,193
  • 2
  • 40
  • 76
  • So you want to package only tests, no other code at all? – hoefling Jan 19 '18 at 13:39
  • Right. I have already a structure for 1 but maybe you can give me a hint for a better structure. Right now I have a Python package. In the `` there is a `tests` directory but without `__init__.py` file (that ís important otherwise pytests test discovery would be confused.). The package directory contains various `utility` modules. The `conftest.py`, etc. are located in the root directory, one level above the `` dir. – thinwybk Jan 19 '18 at 15:29
  • Ok, let me summarize up all the stuff that might help you out... – hoefling Jan 19 '18 at 15:50

1 Answers1

7

Requirements

As I see it, you have three requirements to meet:

  1. The installed tests should be easily discoverable
  2. The tests should still be runnable locally for development purposes
  3. Running the tests in both development mode and installed mode should produce identical outcome

Project structure

Considering that, here's how I would structure the project: create some dummy package (but with a unique distinguishable name) and put the tests dir in there, along with the conftest.py and the utilities modules. Here's how it would look like:

project/
├── setup.py
└── mypkg/
    ├── __init__.py
    └── tests/
        ├── conftest.py
        ├── utils.py
        ├── other_utils.py
        ├── test_spam.py
        ├── test_eggs.py
        └── other_tests/  # if you need to further grouping of tests
            ├── conftest.py  # if you need special conftest for other tests
            └── test_bacon.py

setup.py

from setuptools import setup

setup(
    name='mypkg-tests',
    version='0.1',
    install_requires=['pytest'],
    packages=['mypkg'],
    package_data={'mypkg': ['tests/*', 'tests/**/*']},
)

With this project structure:

  1. Tests are correctly discovered and executed locally in the project/ dir:

    $ pytest -v
    ============================= test session starts =============================
    platform darwin -- Python 3.6.3, pytest-3.3.2, py-1.5.2, pluggy-0.6.0 -- /Users
    /hoefling/.virtualenvs/stackoverflow/bin/python
    cachedir: .cache
    rootdir: /Users/hoefling/projects/private/stackoverflow/so-48111426, inifile:
    plugins: forked-0.2, asyncio-0.8.0, xdist-1.22.0, mock-1.6.3, hypothesis-3.44.4
    collected 3 items
    
    spam/tests/test_eggs.py::test_foo PASSED                                 [ 33%]
    spam/tests/test_spam.py::test_bar PASSED                                 [ 66%]
    spam/tests/other_tests/test_bacon.py::test_baz PASSED                    [100%]
    
    ========================== 3 passed in 0.03 seconds ===========================
    
  2. When you build a source tar or a wheel and install the package, it's easy to run the tests by providing the package name:

    $ pytest -pyargs mypkg
    ...
    

    You will get the exact same test result because the way pytest discovers the tests will be the same as when running them locally, only instead of scanning the current working directory, the package's directory will be scanned.

  3. Although all the tests along with config and utils are installed in site-packages, they are not packages or modules themselves. To the outside world, the distribution contains only an empty package mypkg; nothing in tests/ is importable and will be only visible to pytest.

Why data_files is unreliable

Of course you could declare something like this in the setup script:

setup(
    ...
    data_files=[
        ('tests', ['tests/test_spam.py', 'tests/test_eggs.py']),  # etc
    ]
)

First of all, it's not that convenient to create and maintain a list of tests to be included (although it's surely possible to write some kind of custom tests lookup with os.walk or pathlib.glob or whatever). But more important is that you won't be able to reliably install the data_files to an absolute path. I want go into much details here; feel free to check out my other answer for more info - but basically, it's the wheel package that relativizes every absolute path in data_files to sys.prefix, and even if you build a source distribution, pip install will first build a wheel out of it, then install the wheel. So if you want to run the tests from installed package, you will first need to determine the sys.prefix and build the path yourself:

$ SYS_PREFIX=$(python -c "import sys; print(sys.prefix)")
$ pytest -v $SYS_PREFIX/whatever/dir/mapped/in/data_files/tests

The only way to avoid the wheel issue is to build the source distribution and install it with pip install mypkg --no-binary=mypkg. This option will force pip to skip the wheel building step and install directly from source. Only then the tests will be installed to an absolute path. I find it pretty inconvenient because there will be times when you forget the no-binary arg and will spend time looking for the error source. Or someone has to install the package while you aren't there to guide him, and won't be able to run the tests. Simply don't use data_files.

hoefling
  • 59,418
  • 12
  • 147
  • 194
  • Thanks for your hint why `data_files` is unreliable. A note to further improve your answer: In case the utils in `project/mypkg/tests/utils.py` and `.../other_utils.py` would have tests itself their tests could be put into `project/tests/`. BTW: Does something prevent from pulling the `project/mypkg/tests/utils.py` and `.../another_util.py` into a `project/mypkg/utils/` dir? – thinwybk Jan 20 '18 at 15:50
  • @thinwybk I wouldn't put tests in `project/tests`, also not in `project/test`. The reason is that there are a lot of packages out there that aren't properly configured and install tests along with the source code (an example is `jedi`, a pretty common package pulled by `ipython`). You risk putting your tests in a directory already populated by other package. What if your tests are named the same way as some other packag tests? They may be overwritten when the package is uninstalled. Also, invoking `pytest` will then execute all tests in `tests` directory, yours and from other packages. – hoefling Jan 20 '18 at 16:49
  • As for your second question, that's fine by me, if you don't get problems with imports (relative imports should be fine though). – hoefling Jan 20 '18 at 16:51
  • But where should I put tests of utils then (if I do not want to put the utils with their tests into a separate package)? Potential import issues is exactly why I asked about refactoring into `project/mypkg/utils/`... however I will see. – thinwybk Jan 20 '18 at 17:55
  • Oh, think I misunderstood you - are you talking about specific tests for the utils that shouldn't be released with the _real_ tests? So to say, _tests for the tests_? Put them wherever you are putting the usual tests, `project/tests` is a good choice indeed. – hoefling Jan 20 '18 at 18:01
  • After build and install a wheel for my tests with `python setup.py bdist_wheel; cd dist; pip install --upgrade test_rc_visard_ros-*.whl` in the root directory of the project I cannot discover the tests via `pytest -pyargs mypkg`. The directory `/usr/local/lib/python2.7/dist-packages/test_rc_visard_ros` contains the package_data files and the root directry of the tests `/usr/local/lib/python2.7/dist-packages/test_rc_visard_ros/tests` with just a `__pycache__` file in there. – thinwybk May 22 '18 at 16:32
  • First check whether the files are in the wheel: `unzip -l pkg.whl`. If not, they are not added correctly - does the `bdist_wheel` log mention them? – hoefling May 22 '18 at 18:03
  • The wheel does not contain all data files. I use `package_data={'test_rc_visard_ros': ['test_rc_visard_ros/tests/**/*']}` in `setup.py`. The directory structure: `test_rc_visard_ros/setup.py`, `test_rc_visard_ros/test_rc_visard_ros/tests/*` (this directory files are missing), `test_rc_visard_ros/test_rc_visard_ros/*` (this directories files are included in the wheel). – thinwybk May 23 '18 at 07:29
  • Isn't it easier/more error prone to use `MANIFEST.in` with `include_package_data=True` in `setup.py`s `setup()` to get data in? https://stackoverflow.com/a/25964691/5308983 – thinwybk May 23 '18 at 07:34
  • Looks to me like your `package_data` is incorrect - it should be a mapping of `packagename` to list of globs for files inside `packagename`. So `package_data={'test_rc_visard_ros': ['test_rc_visard_ros/tests/**/*']}` means you should have a package `test_rc_visard_ros`, inside it a directory `test_rc_visard_ros/tests` and you include only subdirectories of `tests`. Change it to `package_data={'test_rc_visard_ros': ['tests/*', 'tests/**/*']}` to include everything inside the directory `tests` located in package `test_rc_visard_ros`. – hoefling May 24 '18 at 10:48
  • Ouh, thanks a lot. I missed that typo. After fixing it `pytest -pyargs test_rc_slam_ros` works perfectly fine as well... – thinwybk May 24 '18 at 11:57
  • Nice, glad to hear that! – hoefling May 24 '18 at 13:16
  • Because I dockerized the package and run it then I did not notice that the wheel build lacks the ini file: `$ pytest -pkg test_rc_visard_ros --rcvisard_ip usage: pytest [options] [file_or_dir] [file_or_dir] [...] pytest: error: unrecognized arguments: --rcvisard_ip inifile: None`. I placed the init file into `test_rc_visard_ros/tests/pytest.ini`. The conftest file is in there as well `test_rc_visard_ros/tests/conftest.py`. The wheel includes both files but pytest doesnt find it... – thinwybk May 28 '18 at 15:27
  • I invoke with `$ pytest --pyargs test_rc_visard_ros --rcvisard_ip ` (not `... -pkg ...`). The tests are detected as they should but they raise an assert due to missing `--rcvisard_ip` custom option in setup which is not processed (option is gathered in `test_rc_visard_ros/tests/conftest.py` via `pytest_addoption()` which works fine in general). Would it help to move `test_rc_visard_ros/tests/conftest.py` into `test_rc_visard_ros/conftest.py`? – thinwybk May 28 '18 at 16:00
  • Move of `test_rc_visard_ros/tests/conftest.py` to `test_rc_visard_ros/conftest.py` (same for `pytest.ini`) and including it into `package_data` list did not help. If I run `pytest --pyargs test_rc_visard_ros --fixtures` it is stated `raise ValueError("option names %s already added" % conflict) E ValueError: option names set(['--rcvisard_ip']) already added`. Probably not good to add `conftest.py` to `data_package=...`. `pytest --pyargs test_rc_visard_ros --markers` -> no custom markers from `pytest.ini` listed. – thinwybk May 28 '18 at 16:14
  • Here is a [workaround for this issue](https://stackoverflow.com/a/43747114/5308983)... – thinwybk May 28 '18 at 16:36
  • In case one uses custom arguments and cause [conftests are not discovered in packages](https://github.com/pytest-dev/pytest/issues/3517) one has to use a pytest-plugin in this scenario. – thinwybk May 29 '18 at 17:14