Skip to content

Commit c0244f3

Browse files
black-sliverqwint
andauthored
Tests: unroll 2 player gen, add parametrization helper, add docs (#4648)
* Tests: unroll test_multiworlds.TestTwoPlayerMulti Also adds a helper function that other tests can use to unroll tests. * Docs: add more details to docs/tests.md * Explain parametrization, subtests and link to the new helper * Mention some performance details and work-arounds * Mention multithreading / pytest-xdist * Tests: make param.classvar_matrix accept sets * CI: add test/param.py to type checking * Tests: add missing typing to test/param.py * Tests: fix typo in test/param.py doc comment Co-authored-by: qwint <qwint.42@gmail.com> * update docs * Docs: reword note on performance --------- Co-authored-by: qwint <qwint.42@gmail.com>
1 parent 8af8502 commit c0244f3

File tree

4 files changed

+101
-10
lines changed

4 files changed

+101
-10
lines changed

.github/pyright-config.json

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"include": [
33
"../BizHawkClient.py",
44
"../Patch.py",
5+
"../test/param.py",
56
"../test/general/test_groups.py",
67
"../test/general/test_helpers.py",
78
"../test/general/test_memory.py",

docs/tests.md

+40
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,38 @@ Unit tests can also be created using [TestBase](/test/bases.py#L16) or
8282
may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
8383
testing portions of your code that can be tested without relying on a multiworld to be created first.
8484

85+
#### Parametrization
86+
87+
When defining a test that needs to cover a range of inputs it is useful to parameterize (to run the same test
88+
for multiple inputs) the base test. Some important things to consider when attempting to parametrize your test are:
89+
90+
* [Subtests](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests)
91+
can be used to have parametrized assertions that show up similar to individual tests but without the overhead
92+
of needing to instantiate multiple tests; however, subtests can not be multithreaded and do not have individual
93+
timing data, so they are not suitable for slow tests.
94+
95+
* Archipelago's tests are test-runner-agnostic. That means tests are not allowed to use e.g. `@pytest.mark.parametrize`.
96+
Instead, we define our own parametrization helpers in [test.param](/test/param.py).
97+
98+
* Classes inheriting from `WorldTestBase`, including those created by the helpers in `test.param`, will run all
99+
base tests by default, make sure the produced tests actually do what you aim for and do not waste a lot of
100+
extra CPU time. Consider using `TestBase` or `unittest.TestCase` directly
101+
or setting `WorldTestBase.run_default_tests` to False.
102+
103+
#### Performance Considerations
104+
105+
Archipelago is big enough that the runtime of unittests can have an impact on productivity.
106+
107+
Individual tests should take less than a second, so they can be properly multithreaded.
108+
109+
Ideally, thorough tests are directed at actual code/functionality. Do not just create and/or fill a ton of individual
110+
Multiworlds that spend most of the test time outside what you actually want to test.
111+
112+
Consider generating/validating "random" games as part of your APWorld release workflow rather than having that be part
113+
of continuous integration, and add minimal reproducers to the "normal" tests for problems that were found.
114+
You can use [@unittest.skipIf](https://docs.python.org/3/library/unittest.html#unittest.skipIf) with an environment
115+
variable to keep all the benefits of the test framework while not running the marked tests by default.
116+
85117
## Running Tests
86118

87119
#### Using Pycharm
@@ -100,3 +132,11 @@ next to the run and debug buttons.
100132
#### Running Tests without Pycharm
101133

102134
Run `pip install pytest pytest-subtests`, then use your IDE to run tests or run `pytest` from the source folder.
135+
136+
#### Running Tests Multithreaded
137+
138+
pytest can run multiple test runners in parallel with the pytest-xdist extension.
139+
140+
Install with `pip install pytest-xdist`.
141+
142+
Run with `pytest -n12` to spawn 12 process that each run 1/12th of the tests.

test/multiworld/test_multiworlds.py

+14-10
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import unittest
2-
from typing import List, Tuple
2+
from typing import ClassVar, List, Tuple
33
from unittest import TestCase
44

55
from BaseClasses import CollectionState, Location, MultiWorld
66
from Fill import distribute_items_restrictive
77
from Options import Accessibility
88
from worlds.AutoWorld import AutoWorldRegister, call_all, call_single
99
from ..general import gen_steps, setup_multiworld
10+
from ..param import classvar_matrix
1011

1112

1213
class MultiworldTestBase(TestCase):
@@ -63,15 +64,18 @@ def test_fills(self) -> None:
6364
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
6465

6566

67+
@classvar_matrix(game=AutoWorldRegister.world_types.keys())
6668
class TestTwoPlayerMulti(MultiworldTestBase):
69+
game: ClassVar[str]
70+
6771
def test_two_player_single_game_fills(self) -> None:
6872
"""Tests that a multiworld of two players for each registered game world can generate."""
69-
for world_type in AutoWorldRegister.world_types.values():
70-
self.multiworld = setup_multiworld([world_type, world_type], ())
71-
for world in self.multiworld.worlds.values():
72-
world.options.accessibility.value = Accessibility.option_full
73-
self.assertSteps(gen_steps)
74-
with self.subTest("filling multiworld", games=world_type.game, seed=self.multiworld.seed):
75-
distribute_items_restrictive(self.multiworld)
76-
call_all(self.multiworld, "post_fill")
77-
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
73+
world_type = AutoWorldRegister.world_types[self.game]
74+
self.multiworld = setup_multiworld([world_type, world_type], ())
75+
for world in self.multiworld.worlds.values():
76+
world.options.accessibility.value = Accessibility.option_full
77+
self.assertSteps(gen_steps)
78+
with self.subTest("filling multiworld", games=world_type.game, seed=self.multiworld.seed):
79+
distribute_items_restrictive(self.multiworld)
80+
call_all(self.multiworld, "post_fill")
81+
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")

test/param.py

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import itertools
2+
import sys
3+
from typing import Any, Callable, Iterable
4+
5+
6+
def classvar_matrix(**kwargs: Iterable[Any]) -> Callable[[type], None]:
7+
"""
8+
Create a new class for each variation of input, allowing to generate a TestCase matrix / parametrization that
9+
supports multi-threading and has better reporting for ``unittest --durations=...`` and ``pytest --durations=...``
10+
than subtests.
11+
12+
The kwargs will be set as ClassVars in the newly created classes. Use as ::
13+
14+
@classvar_matrix(var_name=[value1, value2])
15+
class MyTestCase(unittest.TestCase):
16+
var_name: typing.ClassVar[...]
17+
18+
:param kwargs: A dict of ClassVars to set, where key is the variable name and value is a list of all values.
19+
:return: A decorator to be applied to a class.
20+
"""
21+
keys: tuple[str]
22+
values: Iterable[Iterable[Any]]
23+
keys, values = zip(*kwargs.items())
24+
values = map(lambda v: sorted(v) if isinstance(v, (set, frozenset)) else v, values)
25+
permutations_dicts = [dict(zip(keys, v)) for v in itertools.product(*values)]
26+
27+
def decorator(cls: type) -> None:
28+
mod = sys.modules[cls.__module__]
29+
30+
for permutation in permutations_dicts:
31+
32+
class Unrolled(cls): # type: ignore
33+
pass
34+
35+
for k, v in permutation.items():
36+
setattr(Unrolled, k, v)
37+
params = ", ".join([f"{k}={repr(v)}" for k, v in permutation.items()])
38+
params = f"{{{params}}}"
39+
40+
Unrolled.__module__ = cls.__module__
41+
Unrolled.__qualname__ = f"{cls.__qualname__}{params}"
42+
setattr(mod, f"{cls.__name__}{params}", Unrolled)
43+
44+
return None
45+
46+
return decorator

0 commit comments

Comments
 (0)