Skip to content

Commit 6909c51

Browse files
committed
TST: use pytest, instead of nose
for compatibility with Python 3.10. - add `pytest >= 4.6.11` to argument `tests_require` of the function `setuptools.setup` in script `setup.py` This lower bound has been selected to ensure compatibility with both Python 3 and Python 2.7. See below for details. - add `pytest >= 4.6.11` to file `requirements.txt` - remove `nose` from argument `tests_require` of the function `setuptools.setup` in script `setup.py` - remove `nose` from file `requirements.txt` - update file `.travis.yml` - remove the collection of coverage measurements on Travis CI, because coverage measurement is incorrect (lower than the real coverage, and even lower after switching to `pytest`) due to Cython coverage not being correctly collected with the build configuration currently used for testing. Also, negligible changes in coverage measurements affected the status of commits on GitHub, turning it to a red "X", which can give the false impression that the tests failed when they passed, and requires clicking on the "X" in order to see more information that clarifies that the tests actually passed. - remove `coveralls` from file `requirements.txt`, because `coveralls` was used only on Travis CI. - add a configuration file `tests/pytest.ini` and include it in `MANIFEST.in` - ignore `.pytest_cache/` in `.gitignore` Motivation ========== The change from [`nose == 1.3.7`]( https://pypi.org/project/nose/1.3.7/#history) to [`pytest`]( https://pypi.org/project/pytest) is motivated by compatibility with Python 3.10. `nose` is incompatible with Python 3.10 ======================================= The package `nose`, which was used to run the tests of `dd`, is not compatible with Python 3.10 (for details, read the last section below). And `nose` is unmaintained. (Also, `nose` uses the `imp` module from Python's standard library, which [is deprecated](https://docs.python.org/3.10/library/imp.html), so may be removed in some future Python version.) Summary of transition to `pytest` ================================= In summary, using `pytest` with the existing tests requires adding a [configuration file `tests/pytest.ini`]( https://docs.pytest.org/en/latest/reference/customize.html#configuration-file-formats) to tell `pytest` which functions, classes, and methods to collect tests from (called "discovery" of tests). The [parameter `--continue-on-collection-errors`]( https://docs.pytest.org/en/latest/reference/reference.html#command-line-flags) tells `pytest` to not stop in case any test module fails to import, and continue with running the tests. The ability to run the tests when some `dd` C extension modules are not installed is necessary. After transitioning the tests to `pytest`, the tests have been confirmed to run successfully: - on Python 2.7 with `pytest == 4.6.11`, and - on Python 3.9 with `pytest == 6.2.4`. Failed attempts to use `unittest` ================================= First, I tried to use [`unittest`]( https://docs.python.org/3/library/unittest.html) (part of CPython's standard library). For writing `dd` tests, `unittest` suffices. For *discovering* the tests, `unittest` seems to require that tests be methods of subclasses of the class `unittest.TestCase`. This is not the case in the tests of `dd` tests. Using `pytest` allows changing the test runner from `nosetests` with minimal changes to the tests themselves. Test discovery using `unittest` could possibly be implemented by adding a file `tests/__init__.py`, and defining in that file a function `load_tests`, following the [documentation of `unittest`]( https://docs.python.org/3/library/unittest.html#unittest.TestLoader.discover). In any case, it is simpler to use `pytest`, which requires only a configuration file. If `unittest` encounters an `ImportError` during collection of the tests (i.e., when it tries to import test modules), then it stops. There does not appear to be any way to tell `unittest` to continue and run the rest of the test modules (those that *could* be imported). Usage of `nose` =============== The dependence on `nose` is minimal. Only one function is used from `nose`: the function `nose.tools.assert_raises`. The function `assert_raises` is dynamically defined [in the module `nose.tools.trivial`]( https://github.com/nose-devs/nose/blob/release_1.3.7/nose/tools/trivial.py#L32-L54) by instantiating the class `unittest.TestCase`, and setting `assert_raises` to equal the [bound method `assertRaises`]( https://docs.python.org/3/library/unittest.html#unittest.TestCase.assertRaises) of the instance of `TestCase`. So the function `assert_raises` from `nose` is just a PEP8-compliant binding for the method `unittest.TestCase.assertRaises`. Reading the code of `unittest`: - https://github.com/python/cpython/blob/6fdc4d37f3fdbc1bd51f841be6e5e4708a3b8798/Lib/unittest/case.py#L156-L243 - https://github.com/python/cpython/blob/6fdc4d37f3fdbc1bd51f841be6e5e4708a3b8798/Lib/unittest/case.py#L704-L735 it follows that the existing usage: ```python with nose.tools.assert_raises(AssertionError): foo(1, 2) ``` is equivalent to the following code (the use of `AssertionError` here is just as an example): ```python with unittest.TestCase().assertRaises(AssertionError): foo(1, 2) ``` Replacing usage of `nose` with `pytest` in test code ==================================================== The [context manager `pytest.raises`]( https://docs.pytest.org/en/latest/reference/reference.html#pytest-raises) is a [drop-in replacement]( https://en.wikipedia.org/wiki/Drop-in_replacement) for the function `nose.tools.assert_raises`: ```python with pytest.raises(AssertionError): foo(1, 2) ``` Also, the tests can still be run with `nosetests` on Python versions where `nose` is still available. Replacing the test runner `nosetests` with `pytest` =================================================== - `pytest` correctly recognized the test files by default - `pytest` does not recognize by default methods of classes that do not start with "Test" as test methods, even if the methods start with `test_`. The configuration file is necessary to change this behavior of `pytest` (in particular the command-line parameter `-k` did not seem to work for classes). Relevant documentation: - https://docs.pytest.org/en/latest/explanation/goodpractices.html#conventions-for-python-test-discovery - https://docs.pytest.org/en/latest/example/pythoncollection.html#changing-naming-conventions - https://docs.pytest.org/en/latest/how-to/nose.html - https://docs.pytest.org/en/latest/reference/reference.html#confval-python_classes - https://docs.pytest.org/en/latest/reference/reference.html#confval-python_functions - https://docs.pytest.org/en/latest/reference/reference.html#confval-python_files - The call `pytest tests/foo_test.py` imports the package `dd` from `site-packages` (assuming that the module `foo_test.py` contains the statement `import dd`). So the default behavior of `pytest` is as desired. In contrast, `nosetests tests/foo_test.py` imports the package `dd` from the local directory `dd/`, even though `dd` *is* installed under `site-packages`. In any case, `pytest` is called from within the directory `tests/`, as was done for `nosetests`. `python -m pytest tests/foo_test.py` and `PYTHONPATH=. pytest tests/foo_test.py` both result in importing `dd` from the local directory `dd/`. Relevant documentation: - https://docs.pytest.org/en/latest/explanation/pythonpath.html#invoking-pytest-versus-python-m-pytest - https://docs.pytest.org/en/latest/how-to/usage.html#invoke-python As remarked above, the `pytest` [parameter `--continue-on-collection-errors`]( https://docs.pytest.org/en/latest/reference/reference.html#command-line-flags) needs to be used for running the tests when some of the C extension modules are not installed, for example: ``` cd tests/ pytest -v --continue-on-collection-errors . ``` or, to also activate [Python's development mode]( https://docs.python.org/3/library/devmode.html): ``` cd tests/ python -X dev -m pytest -vvv --continue-on-collection-errors . ``` Colored output of test results from `pytest` ============================================ With `nose`, I used to use [`rednose`](https://pypi.org/project/rednose/) for coloring test results, which was convenient. `pytest` colors its output by default, no plugin is required. This capability is an optional way of viewing test results, so the coloring comparison is mentioned only for completeness. Observations about `pytest`: - shows colored source code that includes more source lines - detects assertions that failed, and marks their source lines - avoids the deprecated `imp` module (standard library) that `nose` uses (and thus the associated `DeprecationWarning`) https://docs.python.org/3.10/library/imp.html - running the tests of `dd` with `pytest` revealed several `DeprecationWarnings` that were previously hidden by `nose` (these warnings were about invalid escape sequences due to backslashes appearing in non-raw strings). Further remarks =============== [`pytest == 6.2.4`](https://pypi.org/project/pytest/6.2.4) is not compatible with Python 2.7. [`pytest == 4.6.11`](https://pypi.org/project/pytest/4.6.11/) is the latest version of `pytest` that is compatible with Python 2.7 (released on June 5, 2020). `pytest` specifies `python_requires` [PEP 345]( https://www.python.org/dev/peps/pep-0345/#requires-python), [PEP 503]( https://www.python.org/dev/peps/pep-0503/): - https://github.com/pytest-dev/pytest/blob/4.6.11/setup.cfg#L48 - https://github.com/pytest-dev/pytest/blob/5.0.0/setup.cfg#L43 So including `pytest>=4.6.11` in the file `requirements.txt` suffices to install, on each Python version, the latest version of `pytest` that is compatible with that Python version. This simplifies testing on CI, and packaging. In other words, conditional installations in the file `.travis.yml` are not needed for `pytest`, neither conditional definition of `tests_require` in the script `setup.py`. This approach leaves implicit the upper bound on `pytest` in `tests_require`. This upper bound is specified explicitly by `pytest` itself, depending on the Python version of the interpreter. It appears that `pip == 9.0.0` and `setuptools == 24.2.0` are required to correctly implement `python_requires`: - https://pip.pypa.io/en/stable/news/#v9-0-0 - https://setuptools.readthedocs.io/en/latest/history.html#v24-2-1 How replacing usage of `nose` with `unittest` would have looked like ==================================================================== A way to replace `nose` could have been to add a module `tests/utils.py` containing: ```python """Common functionality for tests.""" import unittest _test_case = unittest.TestCase() assert_raises = _test_case.assertRaises ``` which is close to what `nose` does. The function `assert_raises` could then be imported from the module `utils` in test modules, and used. Using `pytest` avoids the need for this workaround. Details about the incompatibility of `nose` with Python 3.10 ============================================================ [`nose == 1.3.7`](https://pypi.org/project/nose/1.3.7/#history) imports in Python 3.10 fine, but `nosetests` fails, due to imports from the module `collections` of classes that have moved to the module `collections.abc`. Relevant information about CPython changes in `collections` (removal of ABCs): - python/cpython#23754 - https://bugs.python.org/issue37324 - https://docs.python.org/3.10/library/collections.abc.html#collections-abstract-base-classes - Deprecation since Python 3.3, present until Python 3.9: https://docs.python.org/3.9/library/collections.html#module-collections - Removed in Python 3.10: https://docs.python.org/3.10/whatsnew/3.10.html#removed About skipping tests ==================== The decorator `unittest.skipIf` is recognized by `pytest`, and skipped tests are correctly recorded and reported. In any case, note also the `pytest` test-skipping facilities: - https://docs.pytest.org/en/latest/how-to/skipping.html - https://docs.pytest.org/en/latest/how-to/unittest.html - https://docs.pytest.org/en/latest/example/simple.html#control-skipping-of-tests-according-to-command-line-option About passing `-X dev` to `python` in the `Makefile` ==================================================== The argument `dev` is available for the `python` option [`-X` only on Python 3.7 and higher]( https://docs.python.org/3/library/devmode.html#devmode). So the `Makefile` rules where `-X dev` appears are not compatible with ealier Python versions supported by `dd`. This is not an issue: the development environment is intended to be Python 3.9 or higher, so there is no issue with using `-X dev`. Avoiding interaction between tests via class attributes ======================================================= Avoid class attributes in test classes. Use [data attributes][1] instead. Initialize the data attributes in setup methods of the test classes, as is common practice. This approach avoids interaction (via class attributes) between test scripts that import the same modules of common tests. With `nose`, this kind of interaction apparently did not occur, as observed by test failures that were expected to happen. However, `pytest` apparently runs tests in a way that changes to imported modules (e.g., class attributes) persist between different test scripts. This `pytest` behavior was observed by the disappearance of test failures when running with `pytest` (the test failures were observable with `pytest` only when telling `pytest` to run individual test scripts, instead of collecting tests from all test scripts. The cause of the issue with `pytest` was the modification of class attributes (not [data attributes][1]) from the importing module, of classes in the imported module. The modifications were done by setting the class attribute `DD` that defines the BDD or ZDD manager class. Both the scripts `cudd_test.py` and `autoref_test.py` made modifications. The end result was `autoref` tests being run using the class `dd.cudd.BDD`. Using data attributes instead of class attributes, and subclassing, avoids this kind of erroneous testing. This approach is explicit [PEP 20][4]. Also, note that the `pytest` extension packages [`pytest-xdist`][2] and [`pytest-forked`][3] do not avoid this issue. [1]: https://docs.python.org/3/tutorial/classes.html#instance-objects [2]: https://github.com/pytest-dev/pytest-xdist [3]: https://github.com/pytest-dev/pytest-forked [4]: https://www.python.org/dev/peps/pep-0020/
1 parent a43690e commit 6909c51

17 files changed

+83
-49
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
.coverage
22
.cache
3+
.pytest_cache/
34
todo.txt
45
dd/_version.py
56
cython_debug/*

.travis.yml

+1-4
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,4 @@ install:
4343

4444
script:
4545
- cd tests/
46-
- nosetests --with-coverage --cover-package=dd
47-
48-
after_success:
49-
- coveralls
46+
- pytest -v --continue-on-collection-errors .

CHANGES.md

+2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
## 0.5.7
55

6+
- require `pytest >= 4.6.11`, instead of `nose`, for Python 3.10 compatibility
7+
68
API:
79

810
- return memory size in bytes from methods `dd.cudd.BDD.statistics` and

MANIFEST.in

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ include AUTHORS
66
include requirements.txt
77
include download.py
88
include tests/README.md
9+
include tests/pytest.ini
910
include tests/inspect_cython_signatures.py
1011
include tests/common.py
1112
include tests/common_bdd.py

Makefile

+15-9
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
# The script `rtests.py` below is an adaptation of:
2-
# https://github.com/tulip-control/tulip-control/blob/master/run_tests.py
3-
41
wheel_file := $(wildcard dist/*.whl)
52

63
.PHONY: cudd install test
@@ -12,15 +9,16 @@ build_cudd: clean cudd install test
129
build_sylvan: clean
1310
-pip uninstall -y dd
1411
python setup.py install --sylvan
15-
rtests.py --rednose
12+
pip install pytest
13+
make test
1614

1715
sdist_test: clean
1816
python setup.py sdist --cudd --buddy
1917
cd dist; \
2018
pip install dd*.tar.gz; \
2119
tar -zxf dd*.tar.gz
22-
pip install nose rednose
23-
rtests.py --rednose
20+
pip install pytest
21+
make -C dist/dd*/ -f ../../Makefile test
2422

2523
sdist_test_cudd: clean
2624
pip install cython ply
@@ -30,8 +28,8 @@ sdist_test_cudd: clean
3028
tar -zxf dd*.tar.gz; \
3129
cd dd*; \
3230
python setup.py install --fetch --cudd
33-
pip install nose rednose
34-
rtests.py --rednose
31+
pip install pytest
32+
make -C dist/dd*/ -f ../../Makefile test
3533

3634
# use to create source distributions for PyPI
3735
sdist: clean
@@ -68,7 +66,15 @@ develop:
6866
python setup.py develop
6967

7068
test:
71-
rtests.py --rednose
69+
cd tests/; \
70+
python -X dev -m pytest -v --continue-on-collection-errors .
71+
# `pytest -Werror` turns all warnings into errors
72+
# https://docs.pytest.org/en/latest/how-to/capture-warnings.html
73+
# including pytest warnings about unraisable exceptions:
74+
# https://docs.pytest.org/en/latest/how-to/failures.html
75+
# #warning-about-unraisable-exceptions-and-unhandled-thread-exceptions
76+
# https://docs.pytest.org/en/latest/reference/reference.html
77+
# #pytest.PytestUnraisableExceptionWarning
7278

7379
test_abc:
7480
python -X dev tests/inspect_cython_signatures.py

README.md

+3-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
[![Build Status][build_img]][travis]
2-
[![Coverage Status][coverage]][coveralls]
32

43

54
About
@@ -322,14 +321,15 @@ The modules `dd.cudd` and `dd.cudd_zdd` in the wheel dynamically link to the:
322321
Tests
323322
=====
324323

325-
Require [`nose`](https://pypi.python.org/pypi/nose). Run with:
324+
Use [`pytest`](https://pypi.org/project/pytest). Run with:
326325

327326
```shell
328327
cd tests/
329-
nosetests
328+
pytest -v --continue-on-collection-errors .
330329
```
331330

332331
Tests of Cython modules that were not installed will fail.
332+
The code is covered well by tests.
333333

334334

335335
License
@@ -339,5 +339,3 @@ License
339339

340340
[build_img]: https://travis-ci.com/tulip-control/dd.svg?branch=master
341341
[travis]: https://travis-ci.com/tulip-control/dd
342-
[coverage]: https://coveralls.io/repos/tulip-control/dd/badge.svg?branch=master
343-
[coveralls]: https://coveralls.io/r/tulip-control/dd?branch=master

requirements.txt

+1-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ cython==0.29.21
33

44

55
# dev
6-
nose==1.3.7
6+
pytest>=4.6.11
77
gitpython
88
grip
9-
coveralls

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
'pydot >= 1.2.2',
5353
'setuptools >= 19.6']
5454
TESTS_REQUIRE = [
55-
'nose >= 1.3.4']
55+
'pytest >= 4.6.11']
5656
CLASSIFIERS = [
5757
'Development Status :: 2 - Pre-Alpha',
5858
'Intended Audience :: Developers',

tests/autoref_test.py

+11-5
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,23 @@
22

33
from dd import autoref as _bdd
44
import dd.bdd
5-
from nose.tools import assert_raises
5+
from pytest import raises as assert_raises
66

7-
from common import Tests
8-
from common_bdd import Tests as BDDTests
7+
import common
8+
import common_bdd
99

1010

1111
logging.getLogger('astutils').setLevel('ERROR')
1212

1313

14-
Tests.DD = _bdd.BDD
15-
BDDTests.DD = _bdd.BDD
14+
class Tests(common.Tests):
15+
def setup_method(self):
16+
self.DD = _bdd.BDD
17+
18+
19+
class BDDTests(common_bdd.Tests):
20+
def setup_method(self):
21+
self.DD = _bdd.BDD
1622

1723

1824
def test_find_or_add():

tests/bdd_test.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from dd import bdd as _bdd
88
import networkx as nx
99
import networkx.algorithms.isomorphism as iso
10-
from nose.tools import assert_raises
10+
from pytest import raises as assert_raises
1111

1212

1313
class BDD(_BDD):

tests/common.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Common tests for `autoref`, `cudd`, `cudd_zdd`."""
2-
from nose.tools import assert_raises
2+
from pytest import raises as assert_raises
33

44

55
class Tests(object):
6-
DD = None # `autoref.BDD` or `cudd.BDD` or
7-
# `cudd_zdd.ZDD`
6+
def setup_method(self):
7+
self.DD = None # `autoref.BDD` or `cudd.BDD` or
8+
# `cudd_zdd.ZDD`
89

910
def test_true_false(self):
1011
bdd = self.DD()

tests/common_bdd.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Common tests for `autoref`, `cudd`."""
22
import os
33

4-
from nose.tools import assert_raises
4+
from pytest import raises as assert_raises
55

66

77
class Tests(object):
8-
DD = None # `autoref.BDD` or `cudd.BDD`
8+
def setup_method(self):
9+
self.DD = None # `autoref.BDD` or `cudd.BDD`
910

1011
def test_succ(self):
1112
bdd = self.DD()

tests/common_cudd.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Common tests for `cudd`, `cudd_zdd`."""
2-
from nose.tools import assert_raises
2+
from pytest import raises as assert_raises
33

44

55
class Tests(object):
6-
DD = None # `cudd.BDD` or `cudd_zdd.ZDD`
7-
MODULE = None # `cudd` or `cudd_zdd`
6+
def setup_method(self):
7+
self.DD = None # `cudd.BDD` or `cudd_zdd.ZDD`
8+
self.MODULE = None # `cudd` or `cudd_zdd`
89

910
def test_add_var(self):
1011
bdd = self.DD()

tests/cudd_test.py

+18-8
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
import logging
22

33
from dd import cudd
4-
from nose.tools import assert_raises
4+
from pytest import raises as assert_raises
55

6-
from common import Tests
7-
from common_bdd import Tests as BDDTests
8-
from common_cudd import Tests as CuddTests
6+
import common
7+
import common_bdd
8+
import common_cudd
99

1010

1111
logging.getLogger('astutils').setLevel('ERROR')
1212

1313

14-
Tests.DD = cudd.BDD
15-
BDDTests.DD = cudd.BDD
16-
CuddTests.DD = cudd.BDD
17-
CuddTests.MODULE = cudd
14+
class Tests(common.Tests):
15+
def setup_method(self):
16+
self.DD = cudd.BDD
17+
18+
19+
class BDDTests(common_bdd.Tests):
20+
def setup_method(self):
21+
self.DD = cudd.BDD
22+
23+
24+
class CuddTests(common_cudd.Tests):
25+
def setup_method(self):
26+
self.DD = cudd.BDD
27+
self.MODULE = cudd
1828

1929

2030
def test_insert_var():

tests/cudd_zdd_test.py

+11-5
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,19 @@
55
from dd import cudd_zdd
66
from dd import _copy
77

8-
from common import Tests as Tests
9-
from common_cudd import Tests as CuddTests
8+
import common
9+
import common_cudd
1010

1111

12-
Tests.DD = cudd_zdd.ZDD
13-
CuddTests.DD = cudd_zdd.ZDD
14-
CuddTests.MODULE = cudd_zdd
12+
class Tests(common.Tests):
13+
def setup_method(self):
14+
self.DD = cudd_zdd.ZDD
15+
16+
17+
class CuddTests(common_cudd.Tests):
18+
def setup_method(self):
19+
self.DD = cudd_zdd.ZDD
20+
self.MODULE = cudd_zdd
1521

1622

1723
def test_false():

tests/dddmp_test.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from dd.dddmp import Lexer, Parser, load, _rewrite_tables
55
import networkx as nx
6-
from nose.tools import assert_raises
6+
from pytest import raises as assert_raises
77

88

99
logging.getLogger('dd.dddmp.parser_logger').setLevel(logging.ERROR)

tests/pytest.ini

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# configuration file for package `pytest`
2+
[pytest]
3+
python_files = *_test.py
4+
python_classes = *Tests
5+
python_functions = test_*

0 commit comments

Comments
 (0)