The PACT for test-modules
TipThis version of the package was tagged as v.0.0.1 in the project repository, and can be examined there if desired.
I summarized the goal that the Python package that this series of articles is concerned with in the first article in the series as:
GoalEvery code-element in a project’s source tree should have a corresponding test-suite element.
My first step in pursuing this goal, after some thinking on where I
wanted to start, and how I wanted to proceed, was to start with the
code-elements that could only be described as file-system entities:
modules (files) and packages (directories). The corresponding
test-elements rule above, with that focus, could be implemented as some
test or set of tests that simply asserted that for every given
file-system object in a project’s source-tree, there was a corresponding
file-system object in the relevant test-suite of the project. For
example, given a source module at my_package/module.py,
there should be a test_my_package/test_my_module.py in the
related test-suite, and if that test-module did not exist, that was
grounds for a failure of the test in question. For the purposes of the
testing implemented in this version, the goal above could be restated
as
GoalEvery source module and directory should have a corresponding test module and directory.
I did not expect the basic mechanics of that test-process to be difficult to implement. Even though I added some layers of functionality for various interim steps in the processes needed to get to that point, that expectation panned out about as I expected. The most significant initial challenge, as it turned out, was figuring out how to identify a project’s root directory. To understand why that was significant, I’ll summarize where the final version ended up.
The projects.py module
(goblinfish.testing.pact.projects) provides a single class,
ExaminesProjectModules, that provides a “baked in”
test-method (test_source_modules_have_test_modules) that
performs the actual test. That method relies on a handful of properties
that are built in to the class itself:
Everything in the process that eventually get executed by
test_source_modules_have_test_modules starts with
determining the project_root. There are several common
project-structure variations, often depending on how project package
dependencies and the project’s Python Virtual Environment (PVE) are
managed.
When pipenv manages packages,
there is no set project structure, but it is a pretty safe bet that the
Pipfile and Pipfile.lock files will live at
the project root, and the project_root property uses the
presence of either of those as an indicator to determine the value for
the property. A fairly detailed but typical project structure under
pipenv will likely look much like this:
project-name/ ├── Pipfile ├── Pipfile.lock ├── pyproject.toml # Build system configuration (if applicable) ├── src/ # Source code directory │ └── project_name/ # Main package directory │ └── ... # Package modules and subdirectories ├── tests/ # Test suites directory │ ├── unit # Unit tests │ │ └── test_project_name # Test suite for the project starts here │ └── ... # Other test-types, like integration, E2E, etc. ├── scripts/ # Scripts directory (optional) │ └── ... ├── data/ # Data directory (optional) │ └── ... ├── docs/ # Documentation directory (optional) │ └── ... ├── README.md # Project description ├── LICENSE # License file └── .gitignore # Git ignore file
The poetry
package- and dependency-manager tool, as of this writing, shows a
different project structure created using its tools:
project-name
├── pyproject.toml
├── README.md
├── project_name # This is the SOURCE_DIR
│ └── ...
└── tests # Test suites directory
└── ...
The pyproject.toml file in this structure is expected to
live at the project root, so the project_root property will
also look for that file as an indicator.
The uv package- and dependency-manager tool has several
project-creation options, but all of them generate a
pyproject.toml file, several will create a src
directory, and none appear to generate a tests directory,
so the defaults noted above will work for determining the source- and
test-directory properties without modification, though the creation of a
tests/unit directory in the project will be necessary.
Between those tools’ varied project-structure expectations and the src-layout
and flat-layout
structures that have been categorized with respect to their use and
expectations for packaging with the setuptools package, it seems
safe to assume that my preferred structure, the one shown above in the
discussion of pipenv, is a reasonable default to expect,
though I planned to allow configuration options to handle other
structures. In any event, so long as there is a Pipfile,
Pipfile.lock or pyproject.toml file in the
project, that directory can be assumed to be the root directory of the
project, and the src and tests/unit
directories can be found easily enough from there. The basic
implementation rules for this version’s test, then, boil down to:
- The root directory for project source code, modules and directories
alike, will start at a directory named
srcat the project root. - The root directory for project tests in general will start in a
testsdirectory adjacent to the src directory (also in the project root), with unit tests living in aunitdirectory under that.
In that context, the test-method doesn’t really need to do much more
than getting a collection of the source files, generating a collection
of expected test files, then asserting that every test-file in that
expected list exists. The final shape of the
ExaminesProjectModules class can be diagrammed like
this:
Tying the process of the
test_source_modules_have_test_modules test-method to the
class members in that diagram:
test_source_modules_have_test_modulesiterates over the members of the instance’sexpected_test_entities, generating an expected-pathPathvalue for each of those members under the instance’stest_dir, and asserting that the expected path exists.- The
expected_test_entitiesbuilds a path-string, with each directory- and module-name in the instance’ssource_entitiesprefixed with theTEST_PREFIXattribute-value. For example, if there is a module atsrc/package/module.py, theexpected_test_entitiesvalue will include atest_package/test_module.py. - The
source_entitiesproperty simply returns a set of path-strings for each module under the project’s source-directory, which is named in theSOURCE_DIRclass-attribute, resolved in the instance’ssource_dirproperty, and lives under the instance’s `project_root. - The
project_rootproperty finds the project’s root directoryPathby walking up the file-system from the location of the module that the test-case class lives in, and looking for any of the files noted earlier that indicate the root directory of a project at each iteration until it either finds one, or reaches the root of the file-system, raising an error if no viable root directory could be identified. - The
test_dirproperty resolves aPathunder theproject_rootthat is named in theTEST_DIRclass attribute. - The last remaining property,
test_entitiesis actually not used in this process, and I toyed with the idea of removing it, but eventually decided against doing so because I’m planning to eventually make use of it. Functionally, it behaves in much the same way as thesource_dirproperty, building aPaththat resolves from theproject_rootto a subdirectory named in theTEST_DIRclass-attribute.
The SOURCE_DIR, TEST_DIR and
TEST_PREFIX class attributes are where project-specific
configuration can be implemented for projects that use some other
project-structure. A test-module in a project that doesn’t need to
change those could be as simple as this:
import unittest
from goblinfish.testing.pact.projects import \
ExaminesProjectModules
class test_ProjectTestModulesExist(
unittest.TestCase, ExaminesProjectModules
):
pass
if __name__ == '__main__':
# Run tests locally using unittest to facilitate detailed
# views of failures in subTest contexts.
unittest.main()
If a project used a code directory for its source-code,
and a unit-tests directory for its unit tests, and used
test as the prefix for test-elements (instead of
test_), handling those changes would only require the
addition of three lines of class-attribute code:
import unittest
from goblinfish.testing.pact.projects import \
ExaminesProjectModules
class test_ProjectTestModulesExist(
unittest.TestCase, ExaminesProjectModules
):
SOURCE_DIR = Path('code')
TEST_DIR = Path('unit-tests')
TEST_PREFIX = 'test'
if __name__ == '__main__':
# Run tests locally using unittest to facilitate detailed
# views of failures in subTest contexts.
unittest.main()
That’s really all that would be needed in a project’s test-code in
order to actively test that source modules have corresponding
test-modules. When the test-suite containing this module is executed, it
will generate separate failures for each test module that is expected
but does not exist. For example, given a project with
module.py and module2.py modules in it that do
not have unit-test modules defined, the test would report both of those
as missing if the test-suite was executed with unittest
like so:
==============================================================
FAIL: test_source_modules_have_test_modules
(...test_source_modules_have_test_modules)
[Expecting test_my_package/test_module.py test-module]
Test that source modules have corresponding test-modules
--------------------------------------------------------------
Traceback (most recent call last):
File "/.../goblinfish/testing/pact/projects.py",
line 187, in test_source_modules_have_test_modules
AssertionError: False is not true :
The expected test-module at test_my_package/test_module.py
does not exist
...
AssertionError: False is not true :
The expected test-module at test_my_package/test_module2.py
does not exist
---------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (failures=2)
The same test-suite, run with pytest, reports
one of those missing items (because those tests are in a
unittest.TestCase.subTest context). If this failure were
fixed, and the suite re-run, it would report a new failure for
test_module2.py being missing. The output fron the first
pytest run, trimmed down to just the relevant parts,
is:
**================= short test summary info ==================**
FAILED /../tests/unit/test_my_package/
test_project_test_modules_exist.py
::test_ProjectTestModulesExist
::test_source_modules_have_test_modules -
AssertionError: False is not true :
The expected test-module at test_my_package/test_module2.py
does not exist
All that a developer needs to do to make this test pass is create the
missing test-modules, the test_module.py and
test_module2.py modules called out in the test-failure
report above. As soon as those test-modules simply exist, the
test is satisfied.
That pretty much wraps up this installment. The next version and article will focus on what needs to be done to make the same sort of test-process, running in the prescribed test-modules that this version requires, to apply a PACT approach to the members of the source modules.


