tests: refactor tests to avoid persistent global state

Previously, fixtures like `config`, `database`, and `store` were
module-scoped, but frequently used as test function arguments.  These
fixtures swap out global on setup and restore them on teardown.  As
function arguments, they would do the right set-up, but they'd leave the
global changes in place for the whole module the function lived in.  This
meant that if you use `config` once, other functions in the same module
would inadvertently inherit the mock Spack configuration, as it would
only be torn down once all tests in the module were complete.

In general, we should module- or session-scope the *STATE* required for
these global objects (as it's expensive to create0, but we shouldn't
module-or session scope the activation/use of them, or things can get
really confusing.

- [x] Make generic context managers for global-modifying fixtures.

- [x] Make session- and module-scoped fixtures that ONLY build filesystem
  state and create objects, but do not swap out any variables.

- [x] Make seeparate function-scoped fixtures that *use* the session
  scoped fixtures and actually swap out (and back in) the global
  variables like `config`, `database`, and `store`.

These changes make it so that global changes are *only* ever alive for a
singlee test function, and we don't get weird dependencies because a
global fixture hasn't been destroyed.
This commit is contained in:
Todd Gamblin 2019-12-29 02:04:28 -08:00
parent e839432472
commit e7dc8a2bea
16 changed files with 143 additions and 92 deletions

View File

@ -26,7 +26,7 @@
# everything here uses the mock_env_path
pytestmark = pytest.mark.usefixtures(
'mutable_mock_env_path', 'config', 'mutable_mock_packages')
'mutable_mock_env_path', 'config', 'mutable_mock_repo')
env = SpackCommand('env')
install = SpackCommand('install')

View File

@ -10,9 +10,21 @@
import spack.main
import spack.modules
from spack.test.conftest import use_store, use_configuration, use_repo
module = spack.main.SpackCommand('module')
#: make sure module files are generated for all the tests here
@pytest.fixture(scope='module', autouse=True)
def ensure_module_files_are_there(
mock_repo_path, mock_store, mock_configuration):
"""Generate module files for module tests."""
module = spack.main.SpackCommand('module')
with use_store(mock_store):
with use_configuration(mock_configuration):
with use_repo(mock_repo_path):
module('tcl', 'refresh', '-y')
def _module_files(module_type, *specs):
specs = [spack.spec.Spec(x).concretized() for x in specs]
@ -20,11 +32,6 @@ def _module_files(module_type, *specs):
return [writer_cls(spec).layout.filename for spec in specs]
@pytest.fixture(scope='module', autouse=True)
def ensure_module_files_are_there(database):
module('tcl', 'refresh', '-y')
@pytest.fixture(
params=[
['rm', 'doesnotexist'], # Try to remove a non existing module

View File

@ -9,7 +9,7 @@
import spack.spec
from spack.main import SpackCommand
pytestmark = pytest.mark.usefixtures('config', 'mutable_mock_packages')
pytestmark = pytest.mark.usefixtures('config', 'mutable_mock_repo')
spec = SpackCommand('spec')

View File

@ -276,7 +276,7 @@ def test_concretize_two_virtuals(self):
Spec('hypre').concretize()
def test_concretize_two_virtuals_with_one_bound(
self, mutable_mock_packages
self, mutable_mock_repo
):
"""Test a package with multiple virtual dependencies and one preset."""
Spec('hypre ^openblas').concretize()

View File

@ -83,7 +83,7 @@ def test_preferred_variants(self):
'mpileaks', debug=True, opt=True, shared=False, static=False
)
def test_preferred_compilers(self, mutable_mock_packages):
def test_preferred_compilers(self, mutable_mock_repo):
"""Test preferred compilers are applied correctly
"""
update_packages('mpileaks', 'compiler', ['clang@3.3'])
@ -94,7 +94,7 @@ def test_preferred_compilers(self, mutable_mock_packages):
spec = concretize('mpileaks')
assert spec.compiler == spack.spec.CompilerSpec('gcc@4.5.0')
def test_preferred_target(self, mutable_mock_packages):
def test_preferred_target(self, mutable_mock_repo):
"""Test preferred compilers are applied correctly
"""
spec = concretize('mpich')

View File

@ -4,7 +4,7 @@
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
import collections
import copy
import contextlib
import errno
import inspect
import itertools
@ -270,31 +270,75 @@ def _skip_if_missing_executables(request):
spack.architecture.real_platform = spack.architecture.platform
spack.architecture.platform = lambda: spack.platforms.test.Test()
##########
# Test-specific fixtures
##########
#
# Context managers used by fixtures
#
# Because these context managers modify global state, they should really
# ONLY be used persistently (i.e., around yield statements) in
# function-scoped fixtures, OR in autouse session- or module-scoped
# fixtures.
#
# If they're used in regular tests or in module-scoped fixtures that are
# then injected as function arguments, weird things can happen, because
# the original state won't be restored until *after* the fixture is
# destroyed. This makes sense for an autouse fixture, where you know
# everything in the module/session is going to need the modified
# behavior, but modifying global state for one function in a way that
# won't be restored until after the module or session is done essentially
# leaves garbage behind for other tests.
#
# In general, we should module- or session-scope the *STATE* required for
# these global objects, but we shouldn't module- or session-scope their
# *USE*, or things can get really confusing.
#
@contextlib.contextmanager
def use_configuration(config):
"""Context manager to swap out the global Spack configuration."""
saved = spack.config.config
spack.config.config = config
yield
spack.config.config = saved
@pytest.fixture(scope='session')
def repo_path():
"""Session scoped RepoPath object pointing to the mock repository"""
return spack.repo.RepoPath(spack.paths.mock_packages_path)
@contextlib.contextmanager
def use_store(store):
"""Context manager to swap out the global Spack store."""
saved = spack.store.store
spack.store.store = store
yield
spack.store.store = saved
@pytest.fixture(scope='module')
def mock_packages(repo_path):
"""Use the 'builtin.mock' repository instead of 'builtin'"""
mock_repo = copy.deepcopy(repo_path)
with spack.repo.swap(mock_repo):
@contextlib.contextmanager
def use_repo(repo):
"""Context manager to swap out the global Spack repo path."""
with spack.repo.swap(repo):
yield
#
# Test-specific fixtures
#
@pytest.fixture(scope='session')
def mock_repo_path():
yield spack.repo.RepoPath(spack.paths.mock_packages_path)
@pytest.fixture(scope='function')
def mutable_mock_packages(mock_packages, repo_path):
def mock_packages(mock_repo_path):
"""Use the 'builtin.mock' repository instead of 'builtin'"""
with use_repo(mock_repo_path):
yield mock_repo_path
@pytest.fixture(scope='function')
def mutable_mock_repo(mock_repo_path):
"""Function-scoped mock packages, for tests that need to modify them."""
mock_repo = copy.deepcopy(repo_path)
with spack.repo.swap(mock_repo):
yield
mock_repo_path = spack.repo.RepoPath(spack.paths.mock_packages_path)
with use_repo(mock_repo_path):
yield mock_repo_path
@pytest.fixture(scope='session')
@ -345,12 +389,9 @@ def configuration_dir(tmpdir_factory, linux_os):
shutil.rmtree(str(tmpdir))
@pytest.fixture(scope='module')
def config(configuration_dir):
"""Hooks the mock configuration files into spack.config"""
# Set up a mock config scope
real_configuration = spack.config.config
@pytest.fixture(scope='session')
def mock_configuration(configuration_dir):
"""Create a persistent Configuration object from the configuration_dir."""
defaults = spack.config.InternalConfigScope(
'_builtin', spack.config.config_defaults
)
@ -360,11 +401,14 @@ def config(configuration_dir):
for name in ['site', 'system', 'user']]
test_scopes.append(spack.config.InternalConfigScope('command_line'))
spack.config.config = spack.config.Configuration(*test_scopes)
yield spack.config.Configuration(*test_scopes)
yield spack.config.config
spack.config.config = real_configuration
@pytest.fixture(scope='function')
def config(mock_configuration):
"""This fixture activates/deactivates the mock configuration."""
with use_configuration(mock_configuration):
yield mock_configuration
@pytest.fixture(scope='function')
@ -376,27 +420,24 @@ def mutable_config(tmpdir_factory, configuration_dir, monkeypatch):
cfg = spack.config.Configuration(
*[spack.config.ConfigScope(name, str(mutable_dir))
for name in ['site', 'system', 'user']])
monkeypatch.setattr(spack.config, 'config', cfg)
# This is essential, otherwise the cache will create weird side effects
# that will compromise subsequent tests if compilers.yaml is modified
monkeypatch.setattr(spack.compilers, '_cache_config_file', [])
yield spack.config.config
with use_configuration(cfg):
yield cfg
@pytest.fixture()
def mock_config(tmpdir):
"""Mocks two configuration scopes: 'low' and 'high'."""
real_configuration = spack.config.config
spack.config.config = spack.config.Configuration(
config = spack.config.Configuration(
*[spack.config.ConfigScope(name, str(tmpdir.join(name)))
for name in ['low', 'high']])
yield spack.config.config
spack.config.config = real_configuration
with use_configuration(config):
yield config
def _populate(mock_db):
@ -443,30 +484,41 @@ def _store_dir_and_cache(tmpdir_factory):
return store, cache
@pytest.fixture(scope='module')
def database(tmpdir_factory, mock_packages, config, _store_dir_and_cache):
@pytest.fixture(scope='session')
def mock_store(tmpdir_factory, mock_repo_path, mock_configuration,
_store_dir_and_cache):
"""Creates a read-only mock database with some packages installed note
that the ref count for dyninst here will be 3, as it's recycled
across each install.
"""
real_store = spack.store.store
store_path, store_cache = _store_dir_and_cache
mock_store = spack.store.Store(str(store_path))
spack.store.store = mock_store
This does not actually activate the store for use by Spack -- see the
``database`` fixture for that.
"""
store_path, store_cache = _store_dir_and_cache
store = spack.store.Store(str(store_path))
# If the cache does not exist populate the store and create it
if not os.path.exists(str(store_cache.join('.spack-db'))):
_populate(mock_store.db)
with use_configuration(mock_configuration):
with use_store(store):
with use_repo(mock_repo_path):
_populate(store.db)
store_path.copy(store_cache, mode=True, stat=True)
# Make the database read-only to ensure we can't modify entries
# Make the DB filesystem read-only to ensure we can't modify entries
store_path.join('.spack-db').chmod(mode=0o555, rec=1)
yield mock_store.db
yield store
store_path.join('.spack-db').chmod(mode=0o755, rec=1)
spack.store.store = real_store
@pytest.fixture(scope='function')
def database(mock_store, mock_packages, config):
"""This activates the mock store, packages, AND config."""
with use_store(mock_store):
yield mock_store.db
@pytest.fixture(scope='function')

View File

@ -88,7 +88,7 @@ def test_fetch(type_of_test,
secure,
mock_git_repository,
config,
mutable_mock_packages,
mutable_mock_repo,
git_version):
"""Tries to:
@ -137,7 +137,7 @@ def test_fetch(type_of_test,
@pytest.mark.parametrize("type_of_test", ['branch', 'commit'])
def test_debug_fetch(type_of_test, mock_git_repository, config):
def test_debug_fetch(mock_packages, type_of_test, mock_git_repository, config):
"""Fetch the repo with debug enabled."""
# Retrieve the right test parameters
t = mock_git_repository.checks[type_of_test]
@ -176,7 +176,7 @@ def test_needs_stage():
@pytest.mark.parametrize("get_full_repo", [True, False])
def test_get_full_repo(get_full_repo, git_version, mock_git_repository,
config, mutable_mock_packages):
config, mutable_mock_repo):
"""Ensure that we can clone a full repository."""
if git_version < ver('1.7.1'):

View File

@ -29,7 +29,7 @@ def test_fetch(
secure,
mock_hg_repository,
config,
mutable_mock_packages
mutable_mock_repo
):
"""Tries to:

View File

@ -128,7 +128,7 @@ def test_dont_add_patches_to_installed_package(install_mockery, mock_fetch):
def test_installed_dependency_request_conflicts(
install_mockery, mock_fetch, mutable_mock_packages):
install_mockery, mock_fetch, mutable_mock_repo):
dependency = Spec('dependency-install')
dependency.concretize()
dependency.package.do_install()
@ -141,7 +141,7 @@ def test_installed_dependency_request_conflicts(
def test_install_dependency_symlinks_pkg(
install_mockery, mock_fetch, mutable_mock_packages):
install_mockery, mock_fetch, mutable_mock_repo):
"""Test dependency flattening/symlinks mock package."""
spec = Spec('flatten-deps')
spec.concretize()
@ -154,7 +154,7 @@ def test_install_dependency_symlinks_pkg(
def test_flatten_deps(
install_mockery, mock_fetch, mutable_mock_packages):
install_mockery, mock_fetch, mutable_mock_repo):
"""Explicitly test the flattening code for coverage purposes."""
# Unfortunately, executing the 'flatten-deps' spec's installation does
# not affect code coverage results, so be explicit here.

View File

@ -16,7 +16,7 @@
from llnl.util.filesystem import resolve_link_target_relative_to_the_link
pytestmark = pytest.mark.usefixtures('config', 'mutable_mock_packages')
pytestmark = pytest.mark.usefixtures('config', 'mutable_mock_repo')
# paths in repos that shouldn't be in the mirror tarballs.
exclude = ['.hg', '.git', '.svn']

View File

@ -182,7 +182,7 @@ def test_urls_for_versions(mock_packages, config):
assert url == 'http://www.doesnotexist.org/url_override-0.8.1.tar.gz'
def test_url_for_version_with_no_urls():
def test_url_for_version_with_no_urls(mock_packages, config):
pkg = spack.repo.get('git-test')
with pytest.raises(spack.package.NoURLError):
pkg.url_for_version('1.0')

View File

@ -10,14 +10,6 @@
import spack.paths
# Unlike the repo_path fixture defined in conftest, this has a test-level
# scope rather than a session level scope, since we want to edit the
# given RepoPath
@pytest.fixture()
def repo_for_test():
return spack.repo.RepoPath(spack.paths.mock_packages_path)
@pytest.fixture()
def extra_repo(tmpdir_factory):
repo_namespace = 'extra_test_repo'
@ -32,31 +24,31 @@ def extra_repo(tmpdir_factory):
return spack.repo.Repo(str(repo_dir))
def test_repo_getpkg(repo_for_test):
repo_for_test.get('a')
repo_for_test.get('builtin.mock.a')
def test_repo_getpkg(mutable_mock_repo):
mutable_mock_repo.get('a')
mutable_mock_repo.get('builtin.mock.a')
def test_repo_multi_getpkg(repo_for_test, extra_repo):
repo_for_test.put_first(extra_repo)
repo_for_test.get('a')
repo_for_test.get('builtin.mock.a')
def test_repo_multi_getpkg(mutable_mock_repo, extra_repo):
mutable_mock_repo.put_first(extra_repo)
mutable_mock_repo.get('a')
mutable_mock_repo.get('builtin.mock.a')
def test_repo_multi_getpkgclass(repo_for_test, extra_repo):
repo_for_test.put_first(extra_repo)
repo_for_test.get_pkg_class('a')
repo_for_test.get_pkg_class('builtin.mock.a')
def test_repo_multi_getpkgclass(mutable_mock_repo, extra_repo):
mutable_mock_repo.put_first(extra_repo)
mutable_mock_repo.get_pkg_class('a')
mutable_mock_repo.get_pkg_class('builtin.mock.a')
def test_repo_pkg_with_unknown_namespace(repo_for_test):
def test_repo_pkg_with_unknown_namespace(mutable_mock_repo):
with pytest.raises(spack.repo.UnknownNamespaceError):
repo_for_test.get('unknown.a')
mutable_mock_repo.get('unknown.a')
def test_repo_unknown_pkg(repo_for_test):
def test_repo_unknown_pkg(mutable_mock_repo):
with pytest.raises(spack.repo.UnknownPackageError):
repo_for_test.get('builtin.mock.nonexistentpackage')
mutable_mock_repo.get('builtin.mock.nonexistentpackage')
@pytest.mark.maybeslow
@ -66,7 +58,7 @@ def test_repo_last_mtime():
assert spack.repo.path.last_mtime() == latest_mtime
def test_repo_invisibles(repo_for_test, extra_repo):
def test_repo_invisibles(mutable_mock_repo, extra_repo):
with open(os.path.join(extra_repo.root, 'packages', '.invisible'), 'w'):
pass
extra_repo.all_package_names()

View File

@ -189,7 +189,7 @@ def test_conditional_dep_with_user_constraints():
assert ('y@3' in spec)
@pytest.mark.usefixtures('mutable_mock_packages')
@pytest.mark.usefixtures('mutable_mock_repo')
class TestSpecDag(object):
def test_conflicting_package_constraints(self, set_dependency):

View File

@ -61,7 +61,7 @@ def test_concrete_spec(config, mock_packages):
check_yaml_round_trip(spec)
def test_yaml_multivalue():
def test_yaml_multivalue(config, mock_packages):
spec = Spec('multivalue_variant foo="bar,baz"')
spec.concretize()
check_yaml_round_trip(spec)

View File

@ -30,7 +30,7 @@ def test_fetch(
secure,
mock_svn_repository,
config,
mutable_mock_packages
mutable_mock_repo
):
"""Tries to:

View File

@ -75,7 +75,7 @@ def test_fetch(
secure,
checksum_type,
config,
mutable_mock_packages
mutable_mock_repo
):
"""Fetch an archive and make sure we can checksum it."""
mock_archive.url