Skip to content

solver parameterised pytest#780

Draft
ThomSerg wants to merge 58 commits intomasterfrom
pytest_parametrised
Draft

solver parameterised pytest#780
ThomSerg wants to merge 58 commits intomasterfrom
pytest_parametrised

Conversation

@ThomSerg
Copy link
Collaborator

@ThomSerg ThomSerg commented Oct 24, 2025

Attempt to parametrise our pytest test-suite. The default behavior stays the same: generic tests get run on the default solver OR-Tools and the more solver specific tests / across solver tests check which backends are available on the current system.

But sometimes you want to just run the testsuite on a single solver without having to uninstall all other solvers / create a fresh environment. Now you can pass an optional argument --solver:

python -m pytest ./tests --solver exact

This will have three consequences:

  • the "generic tests" will now be run on exact instead of the default ortools
  • the solver specific tests not targeting exact will be filtered
  • the across solver tests will be limited to only running on exact

In general, I opted for filtering instead of skipping tests. So the non-exact tests will not count towards the total number of tests. I believe we should reserve "skipping" for tests which don't get run due reasons of which we want to inform the user, e.g. missing dependencies which they need to install. When the user provides the --solver option, they already know that tests targeting other solvers won't be run so it would just clutter the results if we were to skip instead of filter those tests.

To parameterise a unittest class for the "generic" tests, simply decorate it with:

@pytest.mark.usefixtures("solver")

After which self.solver will be available, matching the user provided solver argument.

All solver specific tests can now be decorated with:

@pytest.mark.requires_solver("<SOLVER_NAME>")

And will automatically be skipped if a --solver argument has bee provided which doesn't match SOLVER_NAME.

For the across-solver tests which use generators (those in test_constraints.py), the pytest_collection_modifyitems hook will filter out parameterised pytest functions which have been instantiated with a solver different than the user provided one. Both the argument solver and solver_name get filtered on.

There are still some smaller places (see test_solveAll.py) where cp.SolverLookup.base_solvers() is used more directly, which can't be filtered without making changes to the test itself (not possible with one of the decorators / callback functions)

As a further improvement, it might be possible to merge the following two (Already did it ;) )

@pytest.mark.requires_solver("minizinc")
@pytest.mark.skipif(not CPM_minizinc.supported(), reason="MinZinc not installed")

So do the skipping if solver is not available also through the first mark and skip the tests more centrally in pytest_collection_modifyitems.

Using this parameterisation with solver different from OR-Tools revealed some issues with our testsuite related to #779

@ThomSerg ThomSerg added the blocked Pull request blocked by another pull request/issue. label Nov 3, 2025
@ThomSerg ThomSerg removed the blocked Pull request blocked by another pull request/issue. label Nov 6, 2025
@ThomSerg
Copy link
Collaborator Author

ThomSerg commented Dec 8, 2025

Added some more functionality. Can now define more then one solver:

python -m pytest ./tests --solver exact,gurobi

Solver-specific tests will be filtered to these two. Non-solver-specific tests will be parametrised with each of these solvers.

You can also run on all installed solvers:

python -m pytest ./tests --solver all

Or skipp all tests which depend on "solving a model":

python -m pytest ./tests --solver None

Also added a README with instructions on how to use the testsuite, how to use the new decorators and in general how to write new tests.

@ThomSerg
Copy link
Collaborator Author

ThomSerg commented Dec 8, 2025

Currently a lot of tests have their own solver parametrisation, for example in test_constraints.py:

SOLVERNAMES = [name for name, solver in SolverLookup.base_solvers() if solver.supported()]

def _generate_inputs(generator):
    exprs = []
    for solver in SOLVERNAMES:
        exprs += [(solver, expr) for expr in generator(solver)]
    return exprs


@pytest.mark.parametrize(("solver","constraint"),list(_generate_inputs(bool_exprs)), ids=str)
def test_bool_constraints(solver, constraint):
    ...

This makes it so that for the default behavior (no --solver provided) these tests are still run on all installed solvers. Would we want to keep this or make the default behavior be to only run these on OR-Tools but still run solver-specific tests for other solvers?

@ThomSerg
Copy link
Collaborator Author

Now examples with optional dependencies are also "skipped"; an exception due to missing dependencies causes the test to be ignored. This is a hacky way of not having to label each example script with its dependencies. An additional downside is that when the required dependencies are installed, the test is always run (even when the required solver has been excluded from --solver)

@ThomSerg
Copy link
Collaborator Author

Had to remove the parametrisation on some tests that use .solveAll(), which can take too long on certain solvers.

if not_installed_solvers:
warnings.warn(
f"The following solvers are not installed and will not be tested: {', '.join(not_installed_solvers)}. "
f"Only installed solvers will be used for testing.",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe print the list of solvers that will be tested here?

tests/README.md Outdated
This automatically detects all installed solvers from `SolverLookup` and parametrises non-solver-specific tests to run against each one.

#### Skip Solver Tests
Skip all solver-parametrised tests (only run tests that don't depend on solver parametrisation):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"I.e., tests that do not rely on solving a model. Examples are tests that evaluate constructors of expressions"

@ThomSerg
Copy link
Collaborator Author

Encountered an issue with the unittest.TestCase classes. unittest provides its own test generation system, thus when pytest encounters such a class it ignores it during the pytest_generate_tests hook. As a consequence, we can't externally parametrise these tests for the solver argument (set with --solver). It has additional downsides, like lacking parametrisation tags in the test name (seen as test_name[parameter1,parameter2,...]), etc.

Looking at our use of unittest's TestCase, we only use it for the nice assertion methods (assertEquals, assertIsInstance, ...). So to be compatible with pytest's parametrisation I created my own TestCase class (under utils.py) which simply copies all methods from unittest without subclassing. This fixes our problem, parametrisation now seems to work perfectly (before it caused issues when multiple solvers were declared, causing it to only parametrise with the first one, ignoring all the rest)

@ThomSerg
Copy link
Collaborator Author

Found some remaining issues in some of the tests. But these are specific to the tests themselves, and not really on topic for "parametrisation". So will make separate pull requests for those once this PR has been merged (as to not increase the size of this one even more ;) ).

@tias
Copy link
Collaborator

tias commented Jan 13, 2026

Code looks good, also good that you removed the dirty import *s.

  • If I now run pytest on my laptop it goes from 81182 tests to 12665 because the new default is to only run the default solver; I think that is OK as long as on github it runs it with all?
  • in the docs, I need to do pytest tests/ because of other subdirectories in my cpmpy folder... does it do work properly on a fresh checkout? should we document the option of specifying the folder to be safer?

I have 2 failing errors:
FAILED tests/test_examples.py::test_advanced_example[examples/advanced/decision_focused_learning.py] - Exception: CPM_gurobi: A problem occured during license check. Make sure yo...

because I am in this weird situation that gurobipy is installed, but my license has expired.. is there a way to be safe against that?

FAILED tests/test_examples.py::test_advanced_example[examples/advanced/explain_satisfaction.py] - ImportError: cannot import name 'musx' from 'musx' (/home/tias/local/src/mi...

not sure what happend with this one... if I run python -m pytest tests/test_examples.py it runs fine, but if I run pytest tests/test_examples.py it fails? not sure we can/have to do something about it.

Finally, if I run python -m pytest tests -n5 --solver=all it runs 81284 tests (so slightly more), which give very cryptic 'def worker_internal_error' INTERNALERROR>'s ending with "INTERNALERROR> assert not 'tests/test_globalconstraints.py::TestTypeChecks::test_issue_699[pysdd]'"

=============================================== 106 failed, 78692 passed, 4 skipped, 249 warnings in 211.46s (0:03:31)

If I don't run with '-n5' then it reports 81329 tests (bit more?) and does show the "per file" progress that allows me to locate the errors... is this a known side effect of -n? we should document that then?

And it shows me that it is because of e.g.:
cpmpy/solvers/pysdd.py:409: NotImplementedError
======================================================================= short test summary info ========================================================================
FAILED tests/test_builtins.py::TestBuiltin::test_max - NotImplementedError: CPM_pysdd: Non supported constraint (IV5) + 9 <= 8
FAILED tests/test_builtins.py::TestBuiltin::test_min - NotImplementedError: CPM_pysdd: Non supported constraint (IV6) + 9
FAILED tests/test_builtins.py::TestBuiltin::test_abs - NotImplementedError: CPM_pysdd: Non supported constraint (IV7) + 9 <= 8

(indeed, we have not hooked int2bool to pytest yet)

So, I think the essence of this dump is:

  • docs: folder or not? side effects of -n?
  • gurobi when no valid license, can catch/skip?
  • pysdd fails in mutliple places

@ThomSerg
Copy link
Collaborator Author

As mentioned a couple of times, the parametrisation of our entire test suite (as opposed to a limited section as currently on master) reveals a bunch of small bugs / edge cases we'll have to fix (some are just as simple as not running a certain test for a certain solver because it doesn't support it). So currently the default behavior of pytest (without --solver) has changed as opposed to master; all tests are only run on ortools, except for the solver dependent ones. This is not great, since (as Tias mentioned internally) this no longer fully tests our entire transformation pipeline. Currently the only way to somewhat replicate master is to call --solver=all with all the solvers from the github runner. But due to all the newly parametrised test, this has a bunch of failing tests (that can be easy to fix, but don't fit in this PR). What I think might be the best approach is to add a very simple but temporary pytest marker to add to those tests that we want run on all solvers (like master). That way running without --solver should have the exact same behavior as master, with minimal code change for adding those small markers in some places. This PR would then basically put all the infrastructure in place for running all tests on all solvers, but for now not run it yet by default (just simulate current master). Then in a later PR we can start fixing those issues, up to a point where they are no longer needed and we can simply run with --solver=all and the full benefit of this PR can be exploited.

So for example this could look like:

@pytest.mark.run_all_by_default()     <- only marker to add in selective places to replicate current behavior on master, can have different name
@pytest.mark.generate_constraints.with_args(bool_exprs)
@skip_on_missing_pblib(skip_on_exception_only=True)
def test_bool_constraints(solver, constraint):
    ...

This seems like a minimal effort temporary fix to be able to merge this PR, with the goal to fix these remaining issues in future PRs and being able to run even more tests parametrised across all solvers.

@tias
Copy link
Collaborator

tias commented Jan 13, 2026

so on my machine all test failures (ignore examples) are pysdd related (which properly throws a not implemented error on any integer expression).

The temporary fix for that can be simply to remove pysdd from 'all':
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -62,6 +62,7 @@ def _parse_solver_option(solver_option: Optional[str] , filter_not_installed: bo
# Expand "all" to all installed solvers
if "all" in original_solvers:
solvers = [name for name, solver in cp.SolverLookup.base_solvers() if solver.supported()]

  •    solvers = [name for name in solvers if name != "pysdd"]  # TODO: add int2bool pipeline for it
    

in a later PR, we will make pysdd like pysat and pindakaas also have int2bool and we can reactivate this...

However, while deactivating pysdd, I noticed:

  • tests/test_trans_simplify.py claims to have 0 tests... on master it has 6
  • other files have the same, the way I ran it doesnt make clear which (alphabetically inbetween test_tocnf and test_transf_comp; perhaps more)
  • examples dont seem to timeout, e.g. for choco its been running for 21 minutes. Not sure if we expect them to timeout? or we have to manually be more aggressive and disabling some of them for certain solvers?

@ThomSerg
Copy link
Collaborator Author

Will look into those issues you mentioned. But when I run with the same solvers as the github runner (except pysdd) and with the examples ignored due to the timeout issue, I get 59 failed tests:

FAILED tests/test_builtins.py::TestBuiltin::test_min[pysat] - ImportError: The model contains a PB constraint, for which PySAT needs an additional dependency (PBLib). To install it, run `pip install pypblib`.
FAILED tests/test_builtins.py::TestBuiltin::test_abs[pysat] - ImportError: The model contains a PB constraint, for which PySAT needs an additional dependency (PBLib). To install it, run `pip install pypblib`.
FAILED tests/test_expressions.py::TestArrayExpressions::test_min[pysat] - ImportError: The model contains a PB constraint, for which PySAT needs an additional dependency (PBLib). To install it, run `pip install pypblib`.
FAILED tests/test_flatten.py::TestFlattenModel::test_abs[pysat] - ImportError: The model contains a PB constraint, for which PySAT needs an additional dependency (PBLib). To install it, run `pip install pypblib`.
FAILED tests/test_flatten.py::TestFlattenModel::test_mod[pysat] - NotImplementedError: Linearization of integer multiplication (IV7466) * (IV7464) >= 0 is not supported
FAILED tests/test_expressions.py::TestSum::test_sum_unary[pysat] - cpmpy.exceptions.NotSupportedError: CPM_pysat: only satisfaction, does not support an objective function
FAILED tests/test_expressions.py::TestSum::test_sum_unary[pindakaas] - cpmpy.exceptions.NotSupportedError: CPM_pindakaas: only satisfaction, does not support an objective function
FAILED tests/test_flatten.py::TestFlattenModel::test_mod[pindakaas] - NotImplementedError: Linearization of integer multiplication (IV7485) * (IV7483) >= 0 is not supported
FAILED tests/test_expressions.py::TestBounds::test_dtype[pindakaas] - AssertionError: <class 'float'> != <class 'int'>
FAILED tests/test_expressions.py::TestArrayExpressions::test_sum[pysat] - ImportError: The model contains a PB constraint, for which PySAT needs an additional dependency (PBLib). To install it, run `pip install pypblib`.
FAILED tests/test_expressions.py::TestComparison::test_comps[pysat] - ImportError: The model contains a PB constraint, for which PySAT needs an additional dependency (PBLib). To install it, run `pip install pypblib`.
FAILED tests/test_globalconstraints.py::TestGlobal::test_minimax_cpm[pysat] - cpmpy.exceptions.NotSupportedError: CPM_pysat: only satisfaction, does not support an objective function
FAILED tests/test_expressions.py::TestArrayExpressions::test_prod[pysat] - NotImplementedError: Linearization of integer multiplication ((IV7141) * (x[2])) == (y) is not supported
FAILED tests/test_expressions.py::TestArrayExpressions::test_prod[pindakaas] - NotImplementedError: Linearization of integer multiplication ((IV7146) * (x[2])) == (y) is not supported
FAILED tests/test_globalconstraints.py::TestGlobal::test_minimax_cpm[pindakaas] - cpmpy.exceptions.NotSupportedError: CPM_pindakaas: only satisfaction, does not support an objective function
FAILED tests/test_expressions.py::TestArrayExpressions::test_max[pysat] - ImportError: The model contains a PB constraint, for which PySAT needs an additional dependency (PBLib). To install it, run `pip install pypblib`.
FAILED tests/test_globalconstraints.py::TestBounds::test_incomplete_func[pysat] - ImportError: The model contains a PB constraint, for which PySAT needs an additional dependency (PBLib). To install it, run `pip install pypblib`.
FAILED tests/test_globalconstraints.py::TestGlobal::test_not_global_cardinality_count[choco] - TypeError: in method 'int_array_set', argument 2 of type 'int'
FAILED tests/test_globalconstraints.py::TestTypeChecks::test_circuit[pumpkin] - TypeError: argument 'variables': 'int' object cannot be converted to 'IntExpression'
FAILED tests/test_globalconstraints.py::TestGlobal::test_shorttable[choco] - ValueError: cannot convert float NaN to integer
FAILED tests/test_globalconstraints.py::TestTypeChecks::test_AllDiff[pumpkin] - TypeError: argument 'variables': 'BoolExpression' object cannot be converted to 'IntExpression'
FAILED tests/test_globalconstraints.py::TestTypeChecks::test_allEqual[choco] - ValueError: __bool__ should not be called on a CPMPy expression boolval(False) < -2147483646 as it will always return True
FAILED tests/test_solvers.py::TestSolvers::test_only_objective[pysat] - cpmpy.exceptions.NotSupportedError: CPM_pysat: only satisfaction, does not support an objective function
FAILED tests/test_globalconstraints.py::TestGlobal::test_cumulative_negative_dur[pumpkin] - AssertionError: Pumpkin only accepts Cumulative with fixed durations
FAILED tests/test_solvers.py::TestSolvers::test_only_objective[pindakaas] - cpmpy.exceptions.NotSupportedError: CPM_pindakaas: only satisfaction, does not support an objective function
FAILED tests/test_solvers.py::TestSolvers::test_tsp[pysat] - cpmpy.exceptions.NotSupportedError: CPM_pysat: only satisfaction, does not support an objective function
FAILED tests/test_solvers.py::TestSolvers::test_tsp[pindakaas] - cpmpy.exceptions.NotSupportedError: CPM_pindakaas: only satisfaction, does not support an objective function
FAILED tests/test_solvers.py::TestSupportedSolvers::test_bug810[pysat] - ImportError: The model contains a PB constraint, for which PySAT needs an additional dependency (PBLib). To install it, run `pip install pypblib`.
FAILED tests/test_globalconstraints.py::TestGlobal::test_global_cardinality_count[choco] - TypeError: in method 'int_array_set', argument 2 of type 'int'
FAILED tests/test_globalconstraints.py::TestGlobal::test_InDomain[choco] - TypeError: in method 'int_array_set', argument 2 of type 'int'
FAILED tests/test_solvers_solhint.py::TestSolutionHinting::test_hints[z3] - AttributeError: 'TestSolutionHinting' object has no attribute 'solver'
FAILED tests/test_solvers_solhint.py::TestSolutionHinting::test_hints[exact] - AttributeError: 'TestSolutionHinting' object has no attribute 'solver'
FAILED tests/test_solvers_solhint.py::TestSolutionHinting::test_hints[pysat] - AttributeError: 'TestSolutionHinting' object has no attribute 'solver'
FAILED tests/test_solvers_solhint.py::TestSolutionHinting::test_hints[choco] - AttributeError: 'TestSolutionHinting' object has no attribute 'solver'
FAILED tests/test_solvers_solhint.py::TestSolutionHinting::test_hints[pindakaas] - AttributeError: 'TestSolutionHinting' object has no attribute 'solver'
FAILED tests/test_solvers_solhint.py::TestSolutionHinting::test_hints[pumpkin] - AttributeError: 'TestSolutionHinting' object has no attribute 'solver'
FAILED tests/test_globalconstraints.py::TestGlobal::test_InDomain[pumpkin] - TypeError: argument 'table': '_IntVarImpl' object cannot be interpreted as an integer
FAILED tests/test_trans_safen.py::TestTransLinearize::test_division_by_zero[pindakaas] - NotImplementedError: Linearization of integer multiplication ((IV0) * (IV2)) == (IV3) is not supported
FAILED tests/test_trans_linearize.py::TestTransLinearize::test_alldiff[pysat] - ImportError: The model contains a PB constraint, for which PySAT needs an additional dependency (PBLib). To install it, run `pip install pypblib`.
FAILED tests/test_trans_safen.py::TestTransLinearize::test_division_by_zero_proper_hole[pysat] - NotImplementedError: Linearization of integer multiplication ((IV5) * (IV9)) == (IV12) is not supported
FAILED tests/test_trans_safen.py::TestTransLinearize::test_division_by_zero_proper_hole[pindakaas] - NotImplementedError: Linearization of integer multiplication ((IV28) * (IV32)) == (IV35) is not supported
FAILED tests/test_trans_safen.py::TestTransLinearize::test_division_by_zero[pysat] - NotImplementedError: Linearization of integer multiplication ((IV20) * (IV22)) == (IV23) is not supported
FAILED tests/test_trans_safen.py::TestTransLinearize::test_nested_partial_functions[pysat] - NotImplementedError: Linearization of integer multiplication ((IV30) * (IV35)) == (IV39) is not supported
FAILED tests/test_trans_safen.py::TestTransLinearize::test_multiple_partial_functions[pysat] - NotImplementedError: Linearization of integer multiplication ((IV9) * (IV12)) == (IV14) is not supported
FAILED tests/test_trans_safen.py::TestTransLinearize::test_nested_partial_functions2[pysat] - NotImplementedError: Linearization of integer multiplication (IV6428) * (IV6430) != 0 is not supported
FAILED tests/test_trans_safen.py::TestTransLinearize::test_nested_partial_functions2[pindakaas] - NotImplementedError: Linearization of integer multiplication (IV6435) * (IV6437) != 0 is not supported
FAILED tests/test_transf_comp.py::TestTransfComp::test_only_numexpr_eq[z3] - AssertionError: Items in the first set but not the second:
FAILED tests/test_transf_comp.py::TestTransfComp::test_only_numexpr_eq[pysat] - ImportError: The model contains a PB constraint, for which PySAT needs an additional dependency (PBLib). To install it, run `pip install pypblib`.
FAILED tests/test_transf_comp.py::TestTransfComp::test_only_numexpr_eq[exact] - AssertionError: Items in the first set but not the second:
FAILED tests/test_trans_safen.py::TestTransLinearize::test_nested_partial_functions[pindakaas] - NotImplementedError: Linearization of integer multiplication ((IV58) * (IV63)) == (IV67) is not supported
FAILED tests/test_transf_comp.py::TestTransfComp::test_only_numexpr_eq[pindakaas] - AssertionError: Items in the first set but not the second:
FAILED tests/test_transf_comp.py::TestTransfComp::test_only_numexpr_eq[pumpkin] - AssertionError: Items in the first set but not the second:
FAILED tests/test_transf_reif.py::TestTransfReif::test_reif_element[pysat] - ImportError: The model contains a PB constraint, for which PySAT needs an additional dependency (PBLib). To install it, run `pip install pypblib`.
FAILED tests/test_trans_safen.py::TestTransLinearize::test_multiple_partial_functions[pindakaas] - NotImplementedError: Linearization of integer multiplication ((IV34) * (IV37)) == (IV39) is not supported
FAILED tests/test_trans_safen.py::TestTransLinearize::test_element_out_of_bounds[pysat] - ImportError: The model contains a PB constraint, for which PySAT needs an additional dependency (PBLib). To install it, run `pip install pypblib`.
FAILED tests/test_variables.py::TestSolvers::test_clear[pysat] - ImportError: The model contains a PB constraint, for which PySAT needs an additional dependency (PBLib). To install it, run `pip install pypblib`.
FAILED tests/test_globalconstraints.py::TestGlobal::test_not_circuit[exact] - ValueError: Unsupported boolexpr alldifferent(IV0,IV1,IV2,IV3) in reification, run a suitable decomposition transformation from `cpmpy.transformations.decompose_global` to decompose unsup...
FAILED tests/test_globalconstraints.py::TestGlobal::test_not_circuit[pindakaas] - ValueError: Linear decomposition of AllDifferent does not work reified. Ensure 'alldifferent' is not in the 'supported_nested' set of 'decompose_in_tree'
FAILED tests/test_globalconstraints.py::TestGlobal::test_not_circuit[pumpkin] - ValueError: Unsupported boolexpr alldifferent(IV0,IV1,IV2,IV3) in reification, run a suitable decomposition transformation from `cpmpy.transformations.decompose_global` to decompose unsup...

Instead of redefining "all", I can also just put the following in the girhub runner:

python -m pytest -n auto tests/ --solver z3,exact,pysat,choco,minizinc,pindakaas,pumpkin

@tias
Copy link
Collaborator

tias commented Jan 13, 2026

I see.

I am unsure what the best way forward is...

The tests pass with the default option of only testing with ortools. So lets just keep using that on github, and then we fix these things after the release?

@ThomSerg
Copy link
Collaborator Author

Tried fixing some of the tests on branch parametrised_fix_tests, but there are still some less trivial ones left (21 remaining). For example, we would need a good mechanism to skip tests which have an objective function when run on solvers that only support satisfaction, without having to put a decorator on all tests with objective functions.

@ThomSerg
Copy link
Collaborator Author

This PR became a bit too large to manage. Started with much smaller essentials in #817.

@tias tias removed this from the v.0.10.0 milestone Jan 14, 2026
@tias tias marked this pull request as draft February 4, 2026 21:02
@tias
Copy link
Collaborator

tias commented Feb 4, 2026

Marked as draft; merged the pytestify one.

Next nice thing to have from this is the 'import *' fixes. I leave it to you to judge if this is better done afresh or by rebasing this on master...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants