Skip to content

Commit 466a4f7

Browse files
authored
Merge pull request #86 from cropsinsilico/topic/testing
Topic/testing
2 parents e43d8c1 + 29ee014 commit 466a4f7

372 files changed

Lines changed: 14694 additions & 14735 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test-install.yml

Lines changed: 85 additions & 196 deletions
Large diffs are not rendered by default.

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
# Copies of test input files
3-
yggdrasil/tests/scripts/gcc_model.out
4-
yggdrasil/tests/scripts/Makefile
3+
tests/scripts/gcc_model.out
4+
tests/scripts/Makefile
55

66
# Jupyter notebook things
77
.ipynb_checkpoints

HISTORY.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22
History
33
=======
44

5+
1.8.2 (2021-XX-XX) Migrate tests out of package & into pytest fixtures
6+
------------------
7+
8+
* Move tests out of package to take advantage of pytest conftest.py structure and reduce the size of the package
9+
* Refactor tests to use pytest fixtures instead of the unittest setup/teardown structure
10+
* Remove the yggtest CLI and migrate options into pytest CLI options
11+
* Update the GHA workflow to use the new pytest based CLI
12+
* Use lock to prevent parallel compilation for all compiled languages
13+
* Remove 'initial_state' parameter from Transform and Filter schemas as it is unused
14+
* Remove unused yggdrasil.communication.cleanup_comms method
15+
516
1.8.1 (2021-10-15) Minor updates to support model submission form development
617
------------------
718

console_scripts.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ yggcctool=yggdrasil.command_line:cc_toolname
88
yggldtool=yggdrasil.command_line:ld_toolname
99
yggccflags=yggdrasil.command_line:cc_flags
1010
yggldflags=yggdrasil.command_line:ld_flags
11-
yggtest=yggdrasil.command_line:run_tsts
1211
yggmetaschema=yggdrasil.command_line:regen_metaschema
1312
yggschema=yggdrasil.command_line:regen_schema
1413
yggbuildapi_c=yggdrasil.command_line:rebuild_c_api

recipe/meta.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ build:
2323
- yggldtool = yggdrasil.command_line:ld_toolname
2424
- yggccflags = yggdrasil.command_line:cc_flags
2525
- yggldflags = yggdrasil.command_line:ld_flags
26-
- yggtest = yggdrasil.command_line:run_tsts
2726
- yggmetaschema = yggdrasil.command_line:regen_metaschema
2827
- yggschema = yggdrasil.command_line:regen_schema
2928
- yggbuildapi_c = yggdrasil.command_line:rebuild_c_api

scripts/test_wofost.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import numpy as np
2+
import pprint
23
from yggdrasil import units
3-
from yggdrasil.tests import assert_equal
44
from yggdrasil.communication.NetCDFFileComm import NetCDFFileComm
55
from yggdrasil.serialize.WOFOSTParamSerialize import WOFOSTParamSerialize
66

@@ -26,9 +26,9 @@
2626
in_file = NetCDFFileComm('test_recv', address=fname_netcdf, direction='recv')
2727
flag, data_recv = in_file.recv()
2828
assert(flag)
29-
assert_equal(data_recv, data)
29+
assert(data_recv == data)
3030

31-
import pprint; pprint.pprint(data)
31+
pprint.pprint(data)
3232

3333
with open(fname_netcdf, 'rb') as fd:
3434
print(fd.read())

setup.cfg

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ cover-package=yggdrasil
66
with-coverage=1
77

88
[tool:pytest]
9-
norecursedirs = .* build dist CVS _darcs {arch} *.egg rapidjson
9+
norecursedirs = .* build dist CVS _darcs {arch} *.egg rapidjson tests/helpers scripts
1010
python_files = test_*.py __init__.py
1111

1212
[coverage:run]
@@ -26,11 +26,10 @@ omit =
2626
*/yggdrasil/_version.py
2727
*/yggdrasil/rapidjson/*
2828
*/yggdrasil/examples/*/src/*.py
29+
*/yggdrasil/examples/transforms/transforms.py
2930
*/yggdrasil/doctools.py
3031
*/yggdrasil/demos/*
3132
*/yggdrasil/__main__.py
32-
*/yggdrasil/communication/tests/conftest.py
33-
*/yggdrasil/examples/tests/conftest.py
3433

3534
[coverage:report]
3635
sort = Cover

tests/__init__.py

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
"""Testing things."""
2+
import pytest
3+
import os
4+
import sys
5+
import uuid
6+
import importlib
7+
import copy
8+
import types
9+
import functools
10+
import subprocess
11+
from yggdrasil import platform
12+
13+
14+
# Test data
15+
data_dir = os.path.join(os.path.dirname(__file__), 'data')
16+
data_list = [
17+
('txt', 'ascii_file.txt'),
18+
('table', 'ascii_table.txt')]
19+
data = {k: os.path.join(data_dir, v) for k, v in data_list}
20+
21+
22+
def get_timeout_args(args0=None):
23+
r"""Determine which arguments should be passed to the timeout subprocess.
24+
25+
Args:
26+
args0 (list, optional): Initial set of arguments. Defaults to sys.argv[1:].
27+
28+
Returns:
29+
dict: Argument information.
30+
31+
"""
32+
if args0 is None:
33+
args0 = sys.argv[1:]
34+
args_preserve_path = ['-c']
35+
args_remove = ['--clear-cache']
36+
args_remove_value = []
37+
args_add = ['--cov-append']
38+
if int(pytest.__version__.split('.')[0]) >= 6:
39+
args_add.append('--import-mode=importlib')
40+
args_remove_value_match = tuple([k + '=' for k in args_remove_value])
41+
out = {'args': [], 'rootdir': None}
42+
args = out['args']
43+
i = 0
44+
for i in range(1, len(args0)):
45+
v = args0[i]
46+
if v in args_remove: # pragma: testing
47+
pass
48+
elif v in args_remove_value: # pragma: testing
49+
i += 1
50+
elif v.startswith(args_remove_value_match): # pragma: testing
51+
pass
52+
elif v in args_preserve_path:
53+
args.append(v)
54+
i += 1
55+
args.append(args0[i])
56+
elif os.path.isfile(v) or os.path.isdir(v):
57+
pass
58+
else:
59+
if v.startswith('--rootdir='):
60+
out['rootdir'] = v.split('=')[-1]
61+
elif v == '--rootdir': # pragma: testing
62+
out['rootdir'] = args0[i + 1]
63+
i += 1
64+
args.append(v)
65+
i += 1
66+
for v in args_add:
67+
if '=' in v:
68+
if not any(vv.startswith(v.split('=')[0]) for vv in args):
69+
args.append(v)
70+
elif v not in args: # pragma: testing
71+
args.append(v)
72+
return out
73+
74+
75+
def timeout_decorator(*args, allow_arguments=False, **kwargs):
76+
r"""Patch for pytest timeout on windows to allow pytest to cache.
77+
78+
Args:
79+
*args: Arguments are passed on to the pytest.mark.timeout decorator.
80+
**kwargs: Keyword arguments are passed on to the pytest.mark.timeout
81+
decorator.
82+
allow_arguments (bool, optional): If True, decoration with the timeout
83+
decorator will allow arguments to be passed to the test function.
84+
Defaults to False.
85+
86+
Source:
87+
https://stackoverflow.com/questions/21827874/timeout-a-function-windows/
88+
48980413#48980413
89+
90+
91+
"""
92+
env_flag = 'YGG_IN_TIMEOUT_PROCESS'
93+
if platform._is_win:
94+
kwargs['method'] = 'thread'
95+
method = kwargs.get('method', 'signal')
96+
pytest_deco = pytest.mark.timeout(*args, **kwargs)
97+
if (((method == 'thread') and (not os.environ.get(env_flag, False))
98+
and (not allow_arguments))):
99+
arginfo = get_timeout_args()
100+
testargs = arginfo['args']
101+
rootdir = arginfo['rootdir']
102+
103+
def deco(func):
104+
if getattr(func, '_timeout_wrapped', False):
105+
return func
106+
if isinstance(func, type):
107+
for k in dir(func):
108+
v = getattr(func, k)
109+
if k.startswith('test_') and isinstance(v,
110+
types.MethodType):
111+
setattr(func, k, deco(v)) # pragma: testing
112+
func._timeout_wrapped = True
113+
return func
114+
115+
@functools.wraps(func)
116+
def wrapper(*args, **kwargs): # pragma: testing
117+
testname = os.environ['PYTEST_CURRENT_TEST'].split()[0]
118+
if rootdir:
119+
testname = os.path.join(rootdir, testname)
120+
max_args = testname.count('::') - 1
121+
if (len(args) > max_args) or kwargs: # pragma: debug
122+
raise Exception("Arguments not compatible with forked "
123+
"timeout, add 'allow_arguments=True' to "
124+
"the decorator")
125+
env = copy.deepcopy(os.environ)
126+
env[env_flag] = '1'
127+
out = None
128+
try:
129+
out = subprocess.run(['pytest'] + testargs + [testname],
130+
env=env, check=True,
131+
stdout=subprocess.PIPE,
132+
stderr=subprocess.PIPE)
133+
except subprocess.CalledProcessError as e: # pragma: debug
134+
out = e
135+
print(out.stdout.decode('utf-8'))
136+
print(out.stderr.decode('utf-8'), file=sys.stderr)
137+
if b'========= 1 skipped in' in out.stdout:
138+
pytest.skip('')
139+
if out.returncode != 0: # pragma: debug
140+
raise RuntimeError("Error in test subprocess. "
141+
"See above output.")
142+
wrapper._timeout_wrapped = True
143+
return wrapper
144+
return deco
145+
else:
146+
return pytest_deco
147+
148+
149+
@pytest.mark.usefixtures("verify_count_threads",
150+
"verify_count_comms",
151+
"verify_count_fds")
152+
class TestBase:
153+
r"""Base class for tests."""
154+
155+
@pytest.fixture(scope="class", autouse=True)
156+
def reset_test(self):
157+
self.__class__._first_test = True
158+
159+
@pytest.fixture(autouse=True)
160+
def first_test(self):
161+
yield self.__class__._first_test
162+
self.__class__._first_test = False
163+
164+
@pytest.fixture(scope="class")
165+
def global_uuid(self):
166+
r"""Unique ID for test."""
167+
return str(uuid.uuid4()).split('-')[0]
168+
169+
@pytest.fixture
170+
def uuid(self):
171+
r"""Unique ID for test."""
172+
return str(uuid.uuid4()).split('-')[0]
173+
174+
175+
class TestClassBase(TestBase):
176+
r"""Base class for testing classes."""
177+
178+
@pytest.fixture(scope="class", autouse=True)
179+
def reset_test(self, module_name, class_name):
180+
self.__class__._first_test = True
181+
182+
@pytest.fixture(scope="class")
183+
def module_name(self):
184+
r"""Name of the module containing the class being tested."""
185+
return self._mod
186+
187+
@pytest.fixture(scope="class")
188+
def class_name(self):
189+
r"""Name of class that will be tested."""
190+
return self._cls
191+
192+
@pytest.fixture(scope="class", autouse=True)
193+
def python_module(self, module_name):
194+
r"""Python module that is being tested."""
195+
return importlib.import_module(module_name)
196+
197+
@pytest.fixture(scope="class", autouse=True)
198+
def python_class(self, python_module, class_name):
199+
r"""Python class that is being tested."""
200+
return getattr(python_module, class_name)
201+
202+
@pytest.fixture(scope="class")
203+
def python_class_installed(self):
204+
r"""bool: True if the python class is installed."""
205+
return True
206+
207+
@pytest.fixture(scope="class")
208+
def is_installed(self, class_name, python_class_installed):
209+
r"""Skip unless the component is installed."""
210+
if not python_class_installed:
211+
pytest.skip(f"{class_name} not installed")
212+
213+
@pytest.fixture(scope="class")
214+
def is_not_installed(self, class_name, python_class_installed):
215+
r"""Skip unless the language is NOT installed."""
216+
if python_class_installed:
217+
pytest.skip(f"{class_name} installed")
218+
219+
@pytest.fixture
220+
def instance_args(self):
221+
r"""Arguments for a new instance of the tested class."""
222+
return tuple([])
223+
224+
@pytest.fixture
225+
def instance_kwargs(self):
226+
r"""Keyword arguments for a new instance of the tested class."""
227+
return dict()
228+
229+
@pytest.fixture
230+
def instance(self, python_class, instance_args, instance_kwargs,
231+
verify_count_threads, verify_count_comms, verify_count_fds,
232+
is_installed):
233+
r"""New instance of the python class for testing."""
234+
out = python_class(*instance_args, **instance_kwargs)
235+
yield out
236+
del out
237+
238+
239+
class TestComponentBase(TestClassBase):
240+
241+
_component_type = None
242+
243+
@pytest.fixture(scope="class", autouse=True)
244+
def reset_test(self, component_subtype):
245+
self.__class__._first_test = True
246+
247+
@pytest.fixture(scope="class", autouse=True)
248+
def component_type(self):
249+
r"""Type of component being tested."""
250+
return self._component_type
251+
252+
@pytest.fixture(scope="class", autouse=True, params=[])
253+
def component_subtype(self, request):
254+
r"""Subtype of component being tested."""
255+
return request.param
256+
257+
@pytest.fixture(scope="class")
258+
def module_name(self, python_class):
259+
r"""Name of the module containing the class being tested."""
260+
return importlib.import_module(python_class.__module__)
261+
262+
@pytest.fixture(scope="class")
263+
def class_name(self, python_class):
264+
r"""Name of class that will be tested."""
265+
return python_class.__name__
266+
267+
@pytest.fixture(scope="class", autouse=True)
268+
def python_module(self, python_class):
269+
r"""Python module that is being tested."""
270+
return importlib.import_module(python_class.__module__)
271+
272+
@pytest.fixture(scope="class", autouse=True)
273+
def python_class(self, component_type, component_subtype):
274+
r"""Python class that is being tested."""
275+
from yggdrasil.components import import_component
276+
cls = import_component(component_type, component_subtype)
277+
return cls
278+
279+
@pytest.fixture(scope="class", autouse=True)
280+
def options(self):
281+
r"""Arguments that should be provided when getting testing options."""
282+
return {}
283+
284+
@pytest.fixture(scope="class")
285+
def testing_options(self, python_class, options):
286+
r"""Testing options."""
287+
if 'explicit_testing_options' in options:
288+
return copy.deepcopy(options['explicit_testing_options'])
289+
return python_class.get_testing_options(**options)
290+
291+
@pytest.fixture
292+
def instance_kwargs(self, testing_options):
293+
r"""Keyword arguments for a new instance of the tested class."""
294+
return copy.deepcopy(testing_options.get('kwargs', {}))

0 commit comments

Comments
 (0)