Robust relative paths in doctests

Viewed 786

I have a function that takes a path to a file as an argument.

output = process('path/to/file.txt')

I was wondering if I could easily doctest such a function. I supply an example input file somewhere along the source code, and I can compare the output to what I expect (a string, a python object, or possibly the content of another file).

The issue is that the path in my test is necessarily relative. Relative to the working directory of the calling script that is.

This means that all the paths in the docstring must be aware of the test suite's entry point. Clearly that is not ideal. In a more sophisticated testing environment I would be able to use __file__ to make the path absolute, but in a doctest, __file__ does not exist.

What is the usual set-up when supplying stimuli as a file?

I hope to hear some better solutions than just 'always run the test suite from the same working directory'.

EDIT: I'd like to run the doctests from a centralized test suite entry point.

import doctest
import mymodule

doctest.testmod(mymodule)
1 Answers

Since you are doing this at the module level, I assume that you have also packaged your modules as a proper Python package using something like setuptools and be deployed onto some environment where the tests will then be executed. Also, you are only partially correct in the assumption where __file__ doesn't exist - it also is not defined for a module imported from a zipped Python egg (which are getting more rare as wheels became the de facto packaging method, but they can and do exist).

There are a number of possible approaches, varying in complexity and trade offs and whether it works depend on how the module(s) to be tested are structured.

1) (Not recommended, but included anyway because sometimes this works best in the cases of simplest examples.) The laziest, but the most stable, self-contained and cross platform way - and it assumes that the file opening methods is only done in that one module that was to be tested and are done using the same call (e.g. open), the usage of extraglobs argument can be used to substitute the open call. e.g.

from io import StringIO
import doctest
import mymodule

files = {
    'path/to/file1.txt': '... contents of file1.txt ...', 
    'path/to/file2.txt': '... contents of file2.txt ...',
} 

def lazyopen(p, flag='r'):
    result = StringIO(files[p] if flag == 'r' else '')
    result.name = p 
    return result

doctest.testmod(mymodule, extraglobs={'open': lazyopen})

2) Create a real testsuite, rather than using the builtin ones through doctest.testmod

While the shorthand is useful, it is too limited as it is standalone, it cannot be used in conjunction with other testsuites that might get built. Consider creating a dedicated test module (e.g. mymodule/tests.py). I generally prefer creating a directory named mymodule/tests, with unittests named something like test_mysubmodule.py, and a __init__.py that contain the test_suite setup like so

def make_suite():
    import mymodule
    import os

    def setUp(suite):
        suite.olddir = os.getcwd()  # save the current working directory
        os.chdir(targetdir)  # need to define targetdir

    def tearDown(suite):
        os.chdir(suite.olddir)  # restore the original working directory

    return doctest.DocTestSuite(mymodule, setUp=setUp, tearDown=tearDown)

So we have covered the basic, but targetdir needs to be defined. Again, multiple things you can consider:

1) Create a temporary directory and populate the directory with the required files using setup, and os.chdir to that, and remove the temporary directory in tearDown. Either manually write data stored as strings inside the test module, copy from your project or extract from an archive, but how do we actually get those? Which leads to...

2) If the source files are inside your project, and setuptools is available/installed in the environment, simply use pkg_resources.resource_filename to get the location, and assign targetdir to that. The setUp might now look something like

    def setUp(suite):
        suite.olddir = os.getcwd()
        targetdir = pkg_resources.resource_filename('mymodule', '')
        os.chdir(targetdir)

Also, finally, as this is now a real test suite that is produced by the make_suite function within mymodules.tests, the execution of that must be done using a testrunner, which fortunately is included as part of the default unittest framework as a simple command which can be done like so:

$ python -m unittest mymodule.tests.make_suite
.
----------------------------------------------------------------------
Ran 1 test in 0.014s

OK

Also, as that is a real test suite, it can be integrated with the testsuite globbing from the unittest module to combine everything into a single complete test suite for your entire package.

def make_suite():
    # ... the other setup code

    # this loads all unittests in mymodule from `test_*.py` files
    # inside `mymodule.tests`
    test_suite = test_loader.discover(
        'mymodule.tests', pattern='test_*.py')
    test_suite.addTest(
        doctest.DocTestSuite(mymodule, setUp=setUp, tearDown=tearDown))
    return test_suite

Again, the python -m unittest command may be used to execute the tests returned by the complete test suite.

Related