Repository: scverse/scanpy

Issue:
Separate testing utils from tests
Pytest supports two test layouts, in-package and outside-of-package. I prefer the outside-of-package mode outlined here: https://docs.pytest.org/en/6.2.x/goodpractices.html

Scanpy currently mixes test utils with tests, but pytest’s test files (`test_*.py` and `conftest.py`) aren’t Python modules one is supposed to import from.

To clean things up, we can refactor scanpy to a in-package structure:

- `pyproject.toml`: add `addopts = ['--import-mode=importlib']` to `[tool.pytest.ini_options]`
- `scanpy/tests/__init__.py` during implementation, make it throw an error on import so we can make sure nobody imports things from there, then delete
- `scanpy/tests/**/__init__.py` delete
- `scanpy/test_utils/` or `scanpy/testing/`
  - `__init__.py`: leave empty for now, later add public, documented test utils
  - `_private.py` add private test utils that can be imported in our tests, such as the `@needs_somepackage` decorators

Later we can decide if we want to keep the in-package layout or switch to the outside-of-package layout


Discussion:
@flying-sheep, do you know of a large package (ideally in our dependencies) which uses the directory structure you're advocating for? I'd ideally like to have another repo to look at/ crib from for test organization strategies.
Most things?

- pandas has https://github.com/pandas-dev/pandas/tree/main/pandas/_testing
- numpy has https://github.com/numpy/numpy/tree/main/numpy/testing

  It also literally has `_private.py` which is awesome since I came up with that on the fly!

They both have `__init__.py` files in their `tests` directory for which I can forgive them since they probably didn’t know about `--import-mode=importlib` (or it didn’t exist) when they created their test suites. They probably ran into some problem about test files having identical names and hacked their way around it. But we can do better since we know better: `--import-mode=importlib` just fixes problems like that, no caveats.
Oh, I specifically meant `tests` not `{package}/tests`

Though looking through the pandas test it does look like there are fewer internal imports than I recall.
As said:

> Later we can decide if we want to keep the in-package layout or switch to the outside-of-package layout
Could you point some projects who's testing layout you'd like to emulate? I'd ideally like to have something to look at for reference. I would also like something I could try out, since I recall "acceptable test discovery arguments" can be a bit fiddly with pytest.

For modules of test utils, I think I'd go `scanpy.testing` and `scanpy.testing._marks`/ `scanpy.testing._pytest` or something like that.
I literally never had problems with test discovery, so idk what to look for.

As said: Numpy and pandas have separated their testing utils from their tests. For the time being I want just that, no change to where the tests are. Would you accept a PR that simply moves the test utils into private submodules of `scanpy.testing` and switches the import mode to (future default, drawback-less) `importlib`?

Any change to the test layout can come later or never. I’d like to follow pytest’s recommendation (`/src/scanpy/` and `/tests/`) but this issue is orthogonal to that.
Can you point to a package whose test organization you would like our tests to emulate?

I find pytests docs rather hard to navigate and would really prefer to see an example of what you're advocating for. From your description above I had thought you didn't want to emulate [pandas use of conftest](https://github.com/pandas-dev/pandas/blob/main/pandas/conftest.py).

-----------

> Would you accept a PR that simply moves the test utils into private submodules of scanpy.testing

I'd lean towards it, but I fully expect issues like #685 to come up. This is why I'd like to see a working example of what you want to work towards.

------------

> switches the import mode to (future default, drawback-less) importlib?

Is it definitely the future default? It looks like they are walking that back. Current versions of pytest docs say:

> [We intend to make importlib the default in future releases, depending on feedback.](https://docs.pytest.org/en/latest/explanation/pythonpath.html#import-modes)

Where it previously said:

> [We intend to make importlib the default in future releases.](https://docs.pytest.org/en/6.2.x/pythonpath.html#import-modes)
> Can you point to a package whose test organization you would like our tests to emulate?

- pytest: https://github.com/pytest-dev/pytest/tree/main/testing
- loompy: https://github.com/linnarsson-lab/loompy/tree/master/tests

The others have their tests in the package, and just have that useless `__init__.py` in the tests directory because of either cargo culting it or becaue they know that makes setuptools’ `discover_packages` or so find it.

> From your description above I had thought you didn't want to emulate [pandas use of conftest](https://github.com/pandas-dev/pandas/blob/main/pandas/conftest.py?rgh-link-date=2022-04-12T13%3A19%3A30Z).

What do you mean specifically?

Pandas are defining special pytest functions/variables there and fixtures, which is what it’s for. I’d probably judge that we don’t need all those fixtures for our complete test suite and move some of them to a smaller scope (e.g. `tests/io/conftest.py` or so)

> I'd lean towards it, but I fully expect issues like https://github.com/scverse/scanpy/pull/685 to come up. This is why I'd like to see a working example of what you want to work towards.

Actually I think we can fix that: [the docs for `pytest_addoption`](https://doc.pytest.org/en/latest/reference/reference.html#pytest.hookspec.pytest_addoption) say it has to be defined at the *tests root directory* which can be configured using the [`rootpath`](https://doc.pytest.org/en/latest/reference/reference.html?highlight=root#pytest.Config.rootpath) config option.

> Is it definitely the future default? It looks like they are walking that back.

The question is if they remove the others or not, I think: https://github.com/pytest-dev/pytest/issues/7245

---

My intention here is to make clear which code lives under which laws. Pytest world is very different from Python module world. The presence of `__init__.py` fools people into thinking that we’re dealing with python packages/modules here, but that’s not true. The way pytest works is pretty simple:

1. it collects all test modules (`test_*.py` files, no directories) and determines which `conftest.py` files, plugins, … apply to which test module
2. it collects all tests in those modules and checks which fixtures they need
3. it sets up and tears down fixtures according to the needs of each test and executes the tests

accepting that makes it easier to reason about how our test suite works.

Resolving patch:
diff --git a/conftest.py b/conftest.py
deleted file mode 100644
--- a/conftest.py
+++ /dev/null
@@ -1,55 +0,0 @@
-import pytest
-
-# In case pytest-nunit is not installed, defines a dummy fixture
-try:
-    import pytest_nunit
-except ModuleNotFoundError:
-
-    @pytest.fixture
-    def add_nunit_attachment(request):
-        def noop(file, description):
-            pass
-
-        return noop
-
-
-# Command line options for pytest must be added from conftest.py from where
-# `pytest` is called.
-def pytest_addoption(parser):
-    parser.addoption(
-        "--internet-tests",
-        action="store_true",
-        default=False,
-        help="Run tests that retrieve stuff from the internet. This increases test time.",
-    )
-
-
-def pytest_collection_modifyitems(config, items):
-    run_internet = config.getoption("--internet-tests")
-    skip_internet = pytest.mark.skip(reason="need --internet-tests option to run")
-    for item in items:
-        # All tests marked with `pytest.mark.internet` get skipped unless
-        # `--run-internet` passed
-        if not run_internet and ("internet" in item.keywords):
-            item.add_marker(skip_internet)
-
-
-# These fixtures provide a per test new copy of pbmc3k with some preprocessing run on it,
-# without having to hit the disk or recompute normalization.
-# The private fixture creates the object while the public one returns a deep copy.
-@pytest.fixture(scope="session")
-def _pbmc3k_normalized():
-    import scanpy as sc
-
-    pbmc = sc.datasets.pbmc3k()
-    pbmc.X = pbmc.X.astype("float64")  # For better accuracy
-    sc.pp.filter_genes(pbmc, min_counts=1)
-    sc.pp.log1p(pbmc)
-    sc.pp.normalize_total(pbmc)
-    sc.pp.highly_variable_genes(pbmc)
-    return pbmc
-
-
-@pytest.fixture
-def pbmc3k_normalized(_pbmc3k_normalized):
-    return _pbmc3k_normalized.copy()
diff --git a/scanpy/plotting/_tools/__init__.py b/scanpy/plotting/_tools/__init__.py
--- a/scanpy/plotting/_tools/__init__.py
+++ b/scanpy/plotting/_tools/__init__.py
@@ -7,7 +7,7 @@
 from matplotlib.figure import Figure
 from matplotlib.colors import Normalize
 from matplotlib import pyplot as pl
-from matplotlib import rcParams, cm
+from matplotlib import rcParams, colormaps
 from anndata import AnnData
 from typing import Union, Optional, List, Sequence, Iterable, Mapping, Literal
 
@@ -1446,7 +1446,7 @@ def embedding_density(
 
     # Make the color map
     if isinstance(color_map, str):
-        color_map = copy(cm.get_cmap(color_map))
+        color_map = copy(colormaps.get_cmap(color_map))
 
     color_map.set_over('black')
     color_map.set_under('lightgray')
diff --git a/scanpy/plotting/_tools/scatterplots.py b/scanpy/plotting/_tools/scatterplots.py
--- a/scanpy/plotting/_tools/scatterplots.py
+++ b/scanpy/plotting/_tools/scatterplots.py
@@ -22,8 +22,7 @@
 from matplotlib.axes import Axes
 from matplotlib.figure import Figure
 from pandas.api.types import is_categorical_dtype
-from matplotlib import pyplot as pl, colors
-from matplotlib.cm import get_cmap
+from matplotlib import pyplot as pl, colors, colormaps
 from matplotlib import rcParams
 from matplotlib import patheffects
 from matplotlib.colors import Colormap, Normalize
@@ -160,7 +159,7 @@ def embedding(
             raise ValueError("Cannot specify both `color_map` and `cmap`.")
         else:
             cmap = color_map
-    cmap = copy(get_cmap(cmap))
+    cmap = copy(colormaps.get_cmap(cmap))
     cmap.set_bad(na_color)
     kwargs["cmap"] = cmap
     # Prevents warnings during legend creation


Test patch:
diff --git a/scanpy/testing/__init__.py b/scanpy/testing/__init__.py
new file mode 100644
diff --git a/scanpy/tests/helpers.py b/scanpy/testing/_helpers/__init__.py
similarity index 74%
rename from scanpy/tests/helpers.py
rename to scanpy/testing/_helpers/__init__.py
--- a/scanpy/tests/helpers.py
+++ b/scanpy/testing/_helpers/__init__.py
@@ -7,9 +7,8 @@
 import scanpy as sc
 import numpy as np
 import warnings
-import pytest
 from anndata.tests.helpers import asarray, assert_equal
-from scanpy.tests._data._cached_datasets import pbmc3k
+
 
 # TODO: Report more context on the fields being compared on error
 # TODO: Allow specifying paths to ignore on comparison
@@ -20,7 +19,7 @@
 # These functions can be used to check that functions are correctly using arugments like `layers`, `obsm`, etc.
 
 
-def check_rep_mutation(func, X, *, fields=["layer", "obsm"], **kwargs):
+def check_rep_mutation(func, X, *, fields=("layer", "obsm"), **kwargs):
     """Check that only the array meant to be modified is modified."""
     adata = sc.AnnData(X=X.copy(), dtype=X.dtype)
     for field in fields:
@@ -87,32 +86,12 @@ def check_rep_results(func, X, *, fields=["layer", "obsm"], **kwargs):
         assert_equal(adata_X, adatas_proc[field])
 
 
-def _prepare_pbmc_testdata(sparsity_func, dtype, small=False):
-    """Prepares 3k PBMC dataset with batch key `batch` and defined datatype/sparsity.
-
-    Params
-    ------
-    sparsity_func
-        sparsity function applied to adata.X (e.g. csr_matrix.toarray for dense or csr_matrix for sparse)
-    dtype
-        numpy dtype applied to adata.X (e.g. 'float32' or 'int64')
-    small
-        False (default) returns full data, True returns small subset of the data."""
-
-    adata = pbmc3k().copy()
-
-    if small:
-        adata = adata[:1000, :500]
-        sc.pp.filter_cells(adata, min_genes=1)
-    np.random.seed(42)
-    adata.obs['batch'] = np.random.randint(0, 3, size=adata.shape[0])
-    sc.pp.filter_genes(adata, min_cells=1)
-    adata.X = sparsity_func(adata.X.astype(dtype))
-    return adata
-
-
 def _check_check_values_warnings(function, adata, expected_warning, kwargs={}):
-    '''Runs `function` on `adata` with provided arguments `kwargs` twice: once with `check_values=True` and once with `check_values=False`. Checks that the `expected_warning` is only raised whtn `check_values=True`.'''
+    """
+    Runs `function` on `adata` with provided arguments `kwargs` twice:
+    once with `check_values=True` and once with `check_values=False`.
+    Checks that the `expected_warning` is only raised whtn `check_values=True`.
+    """
 
     # expecting 0 no-int warnings
     with warnings.catch_warnings(record=True) as record:
diff --git a/scanpy/testing/_helpers/data.py b/scanpy/testing/_helpers/data.py
new file mode 100644
--- /dev/null
+++ b/scanpy/testing/_helpers/data.py
@@ -0,0 +1,69 @@
+"""
+Functions returning copies of datasets as cheaply as possible,
+i.e. without having to hit the disk or (in case of ``_pbmc3k_normalized``) recomputing normalization.
+"""
+
+from __future__ import annotations
+
+try:
+    from functools import cache
+except ImportError:  # Python < 3.9
+    from functools import lru_cache
+
+    def cache(func):
+        return lru_cache(maxsize=None)(func)
+
+
+from anndata import AnnData
+import scanpy as sc
+
+
+# Functions returning the same objects (easy to misuse)
+
+
+_pbmc3k = cache(sc.datasets.pbmc3k)
+_pbmc3k_processed = cache(sc.datasets.pbmc3k_processed)
+_pbmc68k_reduced = cache(sc.datasets.pbmc68k_reduced)
+_krumsiek11 = cache(sc.datasets.krumsiek11)
+_paul15 = cache(sc.datasets.paul15)
+
+
+# Functions returning copies
+
+
+def pbmc3k() -> AnnData:
+    return _pbmc3k().copy()
+
+
+def pbmc3k_processed() -> AnnData:
+    return _pbmc3k_processed().copy()
+
+
+def pbmc68k_reduced() -> AnnData:
+    return _pbmc68k_reduced().copy()
+
+
+def krumsiek11() -> AnnData:
+    return _krumsiek11().copy()
+
+
+def paul15() -> AnnData:
+    return _paul15().copy()
+
+
+# Derived datasets
+
+
+@cache
+def _pbmc3k_normalized() -> AnnData:
+    pbmc = pbmc3k()
+    pbmc.X = pbmc.X.astype("float64")  # For better accuracy
+    sc.pp.filter_genes(pbmc, min_counts=1)
+    sc.pp.log1p(pbmc)
+    sc.pp.normalize_total(pbmc)
+    sc.pp.highly_variable_genes(pbmc)
+    return pbmc
+
+
+def pbmc3k_normalized() -> AnnData:
+    return _pbmc3k_normalized().copy()
diff --git a/scanpy/testing/_pytest/__init__.py b/scanpy/testing/_pytest/__init__.py
new file mode 100644
--- /dev/null
+++ b/scanpy/testing/_pytest/__init__.py
@@ -0,0 +1,48 @@
+"""A private pytest plugin"""
+import pytest
+
+from .fixtures import *  # noqa: F403
+
+
+# In case pytest-nunit is not installed, defines a dummy fixture
+try:
+    import pytest_nunit
+except ModuleNotFoundError:
+
+    @pytest.fixture
+    def add_nunit_attachment(request):
+        def noop(file, description):
+            pass
+
+        return noop
+
+    def pytest_addoption(parser):
+        add_internet_tests_option(parser)
+        parser.addini("nunit_attach_on", "Dummy nunit replacement", default="any")
+
+else:
+
+    def pytest_addoption(parser):
+        add_internet_tests_option(parser)
+
+
+def add_internet_tests_option(parser):
+    parser.addoption(
+        "--internet-tests",
+        action="store_true",
+        default=False,
+        help=(
+            "Run tests that retrieve stuff from the internet. "
+            "This increases test time."
+        ),
+    )
+
+
+def pytest_collection_modifyitems(config, items):
+    run_internet = config.getoption("--internet-tests")
+    skip_internet = pytest.mark.skip(reason="need --internet-tests option to run")
+    for item in items:
+        # All tests marked with `pytest.mark.internet` get skipped unless
+        # `--run-internet` passed
+        if not run_internet and ("internet" in item.keywords):
+            item.add_marker(skip_internet)
diff --git a/scanpy/tests/fixtures.py b/scanpy/testing/_pytest/fixtures/__init__.py
similarity index 69%
rename from scanpy/tests/fixtures.py
rename to scanpy/testing/_pytest/fixtures/__init__.py
--- a/scanpy/tests/fixtures.py
+++ b/scanpy/testing/_pytest/fixtures/__init__.py
@@ -7,6 +7,20 @@
 from scipy import sparse
 
 from anndata.tests.helpers import asarray
+from .data import (
+    _pbmc3ks_parametrized_session,
+    pbmc3k_parametrized,
+    pbmc3k_parametrized_small,
+)
+
+
+__all__ = [
+    'array_type',
+    'float_dtype',
+    '_pbmc3ks_parametrized_session',
+    'pbmc3k_parametrized',
+    'pbmc3k_parametrized_small',
+]
 
 
 @pytest.fixture(
diff --git a/scanpy/testing/_pytest/fixtures/data.py b/scanpy/testing/_pytest/fixtures/data.py
new file mode 100644
--- /dev/null
+++ b/scanpy/testing/_pytest/fixtures/data.py
@@ -0,0 +1,70 @@
+"""Fixtures for parametrized datasets."""
+
+from __future__ import annotations
+
+from itertools import product
+from collections.abc import Callable
+import pytest
+import numpy as np
+from scipy import sparse
+from anndata import AnnData
+
+from ..._helpers.data import pbmc3k
+
+
+@pytest.fixture(
+    scope='session',
+    params=list(
+        product([sparse.csr_matrix.toarray, sparse.csr_matrix], ['float32', 'int64'])
+    ),
+    ids=lambda x: f'{x[0].__name__}-{x[1]}',
+)
+def _pbmc3ks_parametrized_session(request) -> dict[bool, AnnData]:
+    sparsity_func, dtype = request.param
+    return {
+        small: _prepare_pbmc_testdata(pbmc3k(), sparsity_func, dtype, small=small)
+        for small in [True, False]
+    }
+
+
+@pytest.fixture
+def pbmc3k_parametrized(_pbmc3ks_parametrized_session) -> Callable[[], AnnData]:
+    return _pbmc3ks_parametrized_session[False].copy
+
+
+@pytest.fixture
+def pbmc3k_parametrized_small(_pbmc3ks_parametrized_session) -> Callable[[], AnnData]:
+    return _pbmc3ks_parametrized_session[True].copy
+
+
+def _prepare_pbmc_testdata(
+    adata: AnnData,
+    sparsity_func: Callable[
+        [np.ndarray | sparse.spmatrix], np.ndarray | sparse.spmatrix
+    ],
+    dtype: str | np.dtype,
+    *,
+    small: bool,
+) -> AnnData:
+    """Prepares 3k PBMC dataset with batch key `batch` and defined datatype/sparsity.
+
+    Params
+    ------
+    sparsity_func
+        sparsity function applied to adata.X (e.g. csr_matrix.toarray for dense or csr_matrix for sparse)
+    dtype
+        numpy dtype applied to adata.X (e.g. 'float32' or 'int64')
+    small
+        False (default) returns full data, True returns small subset of the data.
+    """
+
+    import scanpy as sc
+
+    if small:
+        adata = adata[:1000, :500]
+        sc.pp.filter_cells(adata, min_genes=1)
+    np.random.seed(42)
+    adata.obs['batch'] = np.random.randint(0, 3, size=adata.shape[0])
+    sc.pp.filter_genes(adata, min_cells=1)
+    adata.X = sparsity_func(adata.X.astype(dtype))
+    return adata
diff --git a/scanpy/testing/_pytest/marks.py b/scanpy/testing/_pytest/marks.py
new file mode 100644
--- /dev/null
+++ b/scanpy/testing/_pytest/marks.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+from importlib.util import find_spec
+
+import pytest
+
+
+# Mapping from module name to PyPI name
+KNOWN = dict(
+    leidenalg="leidenalg",
+    louvain="louvain",
+    skmisc="scikit-misc",
+    fa2="fa2",
+    igraph="python-igraph",
+    dask="dask",
+    zarr="zarr",
+    zappy="zappy",
+    # external
+    scanorama="scanorama",
+    scrublet="scrublet",
+    harmony="harmonyTS",
+    harmonypy="harmonypy",
+    magic="magic-impute",
+    palantir="palantir",
+    phenograph="PhenoGraph",
+    samalg="sam-algorithm",
+    wishbone="wishbone-dev",
+)
+
+
+def needs(mod: str):
+    """
+    Returns a pytest skip marker evaluated at module import.
+
+    This allows us to see the amount of skipped tests at the start of a test run.
+    :func:`pytest.importorskip` skips tests after they started running.
+    """
+    try:
+        dist = KNOWN[mod]
+    except KeyError:
+        raise ValueError(
+            f"Unknown import {mod}. Please add to KNOWN in this file."
+        ) from None
+    reason = f"needs module `{mod}`"
+    if mod != dist.lower().replace("-", "_"):
+        reason = f"{reason} (`pip install {dist}`)"
+    return pytest.mark.skipif(not find_spec(mod), reason=reason)
diff --git a/scanpy/tests/_data/_cached_datasets.py b/scanpy/tests/_data/_cached_datasets.py
deleted file mode 100644
--- a/scanpy/tests/_data/_cached_datasets.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from functools import wraps
-import scanpy as sc
-
-
-def cached_dataset(func):
-    store = []
-
-    @wraps(func)
-    def wrapper():
-        if len(store) < 1:
-            store.append(func())
-        return store[0].copy()
-
-    return wrapper
-
-
-pbmc3k = cached_dataset(sc.datasets.pbmc3k)
-pbmc68k_reduced = cached_dataset(sc.datasets.pbmc68k_reduced)
-pbmc3k_processed = cached_dataset(sc.datasets.pbmc3k_processed)
-krumsiek11 = cached_dataset(sc.datasets.krumsiek11)
-paul15 = cached_dataset(sc.datasets.paul15)
diff --git a/scanpy/tests/conftest.py b/scanpy/tests/conftest.py
--- a/scanpy/tests/conftest.py
+++ b/scanpy/tests/conftest.py
@@ -1,5 +1,6 @@
 import sys
 from pathlib import Path
+from typing import TYPE_CHECKING
 
 import matplotlib as mpl
 
@@ -9,7 +10,10 @@
 import pytest
 
 import scanpy
-from scanpy.tests.fixtures import array_type, float_dtype  # noqa: F401
+
+if TYPE_CHECKING:  # So editors understand that we’re using those fixtures
+    from scanpy.testing._pytest.fixtures import *  # noqa: F403
+
 
 scanpy.settings.verbosity = "hint"
 
diff --git a/scanpy/tests/external/test_harmony_integrate.py b/scanpy/tests/external/test_harmony_integrate.py
--- a/scanpy/tests/external/test_harmony_integrate.py
+++ b/scanpy/tests/external/test_harmony_integrate.py
@@ -1,11 +1,10 @@
-import pytest
-
 import scanpy as sc
 import scanpy.external as sce
-
-pytest.importorskip("harmonypy")
+from scanpy.testing._helpers.data import pbmc3k
+from scanpy.testing._pytest.marks import needs
 
 
+@needs("harmonypy")
 def test_harmony_integrate():
     """
     Test that Harmony integrate works.
@@ -14,7 +13,7 @@ def test_harmony_integrate():
     integrate wrapper succesfully added a new field to ``adata.obsm``
     and makes sure it has the same dimensions as the original PCA table.
     """
-    adata = sc.datasets.pbmc3k()
+    adata = pbmc3k()
     sc.pp.recipe_zheng17(adata)
     sc.tl.pca(adata)
     adata.obs['batch'] = 1350 * ['a'] + 1350 * ['b']
diff --git a/scanpy/tests/external/test_harmony_timeseries.py b/scanpy/tests/external/test_harmony_timeseries.py
--- a/scanpy/tests/external/test_harmony_timeseries.py
+++ b/scanpy/tests/external/test_harmony_timeseries.py
@@ -1,16 +1,16 @@
 from itertools import product
 
-import pytest
 from anndata import AnnData
 
 import scanpy as sc
 import scanpy.external as sce
-
-pytest.importorskip("harmony")
+from scanpy.testing._helpers.data import pbmc3k
+from scanpy.testing._pytest.marks import needs
 
 
+@needs("harmony")
 def test_load_timepoints_from_anndata_list():
-    adata_ref = sc.datasets.pbmc3k()
+    adata_ref = pbmc3k()
     start = [596, 615, 1682, 1663, 1409, 1432]
     adata = AnnData.concatenate(
         *(adata_ref[i : i + 1000] for i in start),
diff --git a/scanpy/tests/external/test_magic.py b/scanpy/tests/external/test_magic.py
--- a/scanpy/tests/external/test_magic.py
+++ b/scanpy/tests/external/test_magic.py
@@ -1,10 +1,9 @@
-import pytest
 import numpy as np
 from anndata import AnnData
 
 import scanpy as sc
+from scanpy.testing._pytest.marks import needs
 
-pytest.importorskip("magic", minversion=sc.external.pp._magic.MIN_VERSION)
 
 A_list = [
     [0, 0, 7, 0, 0],
@@ -16,6 +15,7 @@
 ]
 
 
+@needs("magic")
 def test_magic_default():
     A = np.array(A_list, dtype='float32')
     adata = AnnData(A)
@@ -28,6 +28,7 @@ def test_magic_default():
     assert adata.X.shape == A.shape
 
 
+@needs("magic")
 def test_magic_pca_only():
     A = np.array(A_list, dtype='float32')
     # pca only
@@ -40,6 +41,7 @@ def test_magic_pca_only():
     assert adata.obsm['X_magic'].shape == (A.shape[0], n_pca)
 
 
+@needs("magic")
 def test_magic_copy():
     A = np.array(A_list, dtype='float32')
     adata = AnnData(A)
diff --git a/scanpy/tests/external/test_palantir.py b/scanpy/tests/external/test_palantir.py
--- a/scanpy/tests/external/test_palantir.py
+++ b/scanpy/tests/external/test_palantir.py
@@ -2,12 +2,13 @@
 
 import scanpy as sc
 import scanpy.external as sce
-
-pytest.importorskip("palantir")
+from scanpy.testing._helpers.data import pbmc3k_processed
+from scanpy.testing._pytest.marks import needs
 
 
+@needs("palantir")
 def test_palantir_core():
-    adata = sc.datasets.pbmc3k_processed()
+    adata = pbmc3k_processed()
 
     sce.tl.palantir(adata=adata, n_components=5, knn=30)
     assert adata.layers['palantir_imp'].shape[0], "palantir_imp matrix Error!"
diff --git a/scanpy/tests/external/test_phenograph.py b/scanpy/tests/external/test_phenograph.py
--- a/scanpy/tests/external/test_phenograph.py
+++ b/scanpy/tests/external/test_phenograph.py
@@ -6,10 +6,10 @@
 
 import scanpy as sc
 import scanpy.external as sce
-
-pytest.importorskip("phenograph")
+from scanpy.testing._pytest.marks import needs
 
 
+@needs("phenograph")
 def test_phenograph():
     df = np.random.rand(1000, 40)
     dframe = pd.DataFrame(df)
diff --git a/scanpy/tests/external/test_sam.py b/scanpy/tests/external/test_sam.py
--- a/scanpy/tests/external/test_sam.py
+++ b/scanpy/tests/external/test_sam.py
@@ -3,11 +3,13 @@
 import scanpy.external as sce
 import numpy as np
 
-pytest.importorskip("samalg")
+from scanpy.testing._helpers.data import pbmc3k
+from scanpy.testing._pytest.marks import needs
 
 
+@needs("samalg")
 def test_sam():
-    adata_ref = sc.datasets.pbmc3k()
+    adata_ref = pbmc3k()
     ix = np.random.choice(adata_ref.shape[0], size=200, replace=False)
     adata = adata_ref[ix, :].copy()
     sc.pp.normalize_total(adata, target_sum=10000)
diff --git a/scanpy/tests/external/test_scanorama_integrate.py b/scanpy/tests/external/test_scanorama_integrate.py
--- a/scanpy/tests/external/test_scanorama_integrate.py
+++ b/scanpy/tests/external/test_scanorama_integrate.py
@@ -1,12 +1,10 @@
-from importlib.util import find_spec
-
-import pytest
-
 import scanpy as sc
 import scanpy.external as sce
+from scanpy.testing._helpers.data import pbmc68k_reduced
+from scanpy.testing._pytest.marks import needs
 
 
-@pytest.mark.skipif(not find_spec("scanorama"), reason="needs module `scanorama`")
+@needs("scanorama")
 def test_scanorama_integrate():
     """
     Test that Scanorama integration works.
@@ -15,7 +13,7 @@ def test_scanorama_integrate():
     integrate wrapper succesfully added a new field to ``adata.obsm``
     and makes sure it has the same dimensions as the original PCA table.
     """
-    adata = sc.datasets.pbmc68k_reduced()
+    adata = pbmc68k_reduced()
     sc.tl.pca(adata)
     adata.obs['batch'] = 350 * ['a'] + 350 * ['b']
     sce.pp.scanorama_integrate(adata, 'batch', approx=False)
diff --git a/scanpy/tests/external/test_scrublet.py b/scanpy/tests/external/test_scrublet.py
--- a/scanpy/tests/external/test_scrublet.py
+++ b/scanpy/tests/external/test_scrublet.py
@@ -10,16 +10,18 @@
 import scanpy.preprocessing as pp
 import scipy.sparse as sparse
 
+from scanpy.testing._helpers.data import paul15, pbmc3k
+from scanpy.testing._pytest.marks import needs
 
+
+@needs("scrublet")
 def test_scrublet():
     """
     Test that Scrublet run works.
 
     Check that scrublet runs and detects some doublets.
     """
-    pytest.importorskip("scrublet")
-
-    adata = sc.datasets.pbmc3k()
+    adata = pbmc3k()
     sce.pp.scrublet(adata, use_approx_neighbors=False)
 
     # replace assertions by conditions
@@ -29,15 +31,14 @@ def test_scrublet():
     assert adata.obs["predicted_doublet"].any(), "Expect some doublets to be identified"
 
 
+@needs("scrublet")
 def test_scrublet_batched():
     """
     Test that Scrublet run works with batched data.
 
     Check that scrublet runs and detects some doublets.
     """
-    pytest.importorskip("scrublet")
-
-    adata = sc.datasets.pbmc3k()
+    adata = pbmc3k()
     adata.obs['batch'] = 1350 * ['a'] + 1350 * ['b']
     split = [adata[adata.obs["batch"] == x].copy() for x in ("a", "b")]
 
@@ -60,19 +61,18 @@ def test_scrublet_batched():
     pd.testing.assert_frame_equal(adata.obs[merged.obs.columns], merged.obs)
 
 
+@needs("scrublet")
 def test_scrublet_data():
     """
     Test that Scrublet processing is arranged correctly.
 
     Check that simulations run on raw data.
     """
-    pytest.importorskip("scrublet")
-
     random_state = 1234
 
     # Run Scrublet and let the main function run simulations
     adata_scrublet_auto_sim = sce.pp.scrublet(
-        sc.datasets.pbmc3k(),
+        pbmc3k(),
         use_approx_neighbors=False,
         copy=True,
         random_state=random_state,
@@ -120,7 +120,7 @@ def create_sim_from_parents(adata, parents):
 
     # Preprocess the data and make the simulated doublets
 
-    adata_obs = preprocess_for_scrublet(sc.datasets.pbmc3k())
+    adata_obs = preprocess_for_scrublet(pbmc3k())
     adata_sim = create_sim_from_parents(
         adata_obs, adata_scrublet_auto_sim.uns['scrublet']['doublet_parents']
     )
@@ -147,15 +147,14 @@ def create_sim_from_parents(adata, parents):
     ).all()
 
 
+@needs("scrublet")
 def test_scrublet_dense():
     """
     Test that Scrublet works for dense matrices.
 
     Check that scrublet runs and detects some doublets when a dense matrix is supplied.
     """
-    pytest.importorskip("scrublet")
-
-    adata = sc.datasets.paul15()[:500].copy()
+    adata = paul15()[:500].copy()
     sce.pp.scrublet(adata, use_approx_neighbors=False)
 
     # replace assertions by conditions
@@ -165,16 +164,15 @@ def test_scrublet_dense():
     assert adata.obs["predicted_doublet"].any(), "Expect some doublets to be identified"
 
 
+@needs("scrublet")
 def test_scrublet_params():
     """
     Test that Scrublet args are passed.
 
     Check that changes to parameters change scrublet results.
     """
-    pytest.importorskip("scrublet")
-
     # Reduce size of input for faster test
-    adata = sc.datasets.pbmc3k()[:500].copy()
+    adata = pbmc3k()[:500].copy()
     sc.pp.filter_genes(adata, min_counts=100)
 
     # Get the default output
@@ -207,15 +205,14 @@ def test_scrublet_params():
             assert_equal(default, curr)
 
 
+@needs("scrublet")
 def test_scrublet_simulate_doublets():
     """
     Test that standalone Scrublet doublet simulation works.
 
     Check that doublet simulation runs and simulates some doublets..
     """
-    pytest.importorskip("scrublet")
-
-    adata_obs = sc.datasets.pbmc3k()
+    adata_obs = pbmc3k()
     sc.pp.filter_genes(adata_obs, min_cells=3)
     sc.pp.filter_cells(adata_obs, min_genes=3)
     adata_obs.layers['raw'] = adata_obs.X
diff --git a/scanpy/tests/external/test_wishbone.py b/scanpy/tests/external/test_wishbone.py
--- a/scanpy/tests/external/test_wishbone.py
+++ b/scanpy/tests/external/test_wishbone.py
@@ -2,12 +2,13 @@
 
 import scanpy as sc
 import scanpy.external as sce
-
-pytest.importorskip("wishbone")
+from scanpy.testing._helpers.data import pbmc3k
+from scanpy.testing._pytest.marks import needs
 
 
+@needs("wishbone")
 def test_run_wishbone():
-    adata = sc.datasets.pbmc3k()
+    adata = pbmc3k()
     sc.pp.normalize_per_cell(adata)
     sc.pp.neighbors(adata, n_pcs=15, n_neighbors=10)
     sc.pp.pca(adata)
diff --git a/scanpy/tests/notebooks/test_paga_paul15_subsampled.py b/scanpy/tests/notebooks/test_paga_paul15_subsampled.py
--- a/scanpy/tests/notebooks/test_paga_paul15_subsampled.py
+++ b/scanpy/tests/notebooks/test_paga_paul15_subsampled.py
@@ -2,7 +2,6 @@
 # Hematopoiesis: trace myeloid and erythroid differentiation for data of [Paul *et al.* (2015)](http://doi.org/10.1016/j.cell.2015.11.013).
 #
 # This is the subsampled notebook for testing.
-from importlib.util import find_spec
 from pathlib import Path
 
 import numpy as np
@@ -12,6 +11,8 @@
 setup()
 
 import scanpy as sc
+from scanpy.testing._pytest.marks import needs
+from scanpy.testing._helpers.data import paul15
 
 
 HERE: Path = Path(__file__).parent
@@ -19,14 +20,12 @@
 FIGS = HERE / 'figures'
 
 
-@pytest.mark.skipif(
-    not find_spec("igraph"),
-    reason="needs module `igraph` (`pip install python-igraph`)",
-)
+@needs("igraph")
+@needs("louvain")
 def test_paga_paul15_subsampled(image_comparer, plt):
     save_and_compare_images = image_comparer(ROOT, FIGS, tol=25)
 
-    adata = sc.datasets.paul15()
+    adata = paul15()
     sc.pp.subsample(adata, n_obs=200)
     del adata.uns['iroot']
     adata.X = adata.X.astype('float64')
@@ -45,8 +44,7 @@ def test_paga_paul15_subsampled(image_comparer, plt):
 
     sc.pl.draw_graph(adata, color='paul15_clusters', legend_loc='on data')
 
-    # TODO: skip if louvain isn't installed, needs major rework
-    pytest.importorskip("louvain")
+    # TODO: currently needs skip if louvain isn't installed, do major rework
 
     # Clustering and PAGA
     sc.tl.louvain(adata, resolution=1.0)
diff --git a/scanpy/tests/notebooks/test_pbmc3k.py b/scanpy/tests/notebooks/test_pbmc3k.py
--- a/scanpy/tests/notebooks/test_pbmc3k.py
+++ b/scanpy/tests/notebooks/test_pbmc3k.py
@@ -9,28 +9,24 @@
 # The data consists in *3k PBMCs from a Healthy Donor* and is freely available from 10x Genomics
 # ([here](http://cf.10xgenomics.com/samples/cell-exp/1.1.0/pbmc3k/pbmc3k_filtered_gene_bc_matrices.tar.gz)
 # from this [webpage](https://support.10xgenomics.com/single-cell-gene-expression/datasets/1.1.0/pbmc3k)).
-from importlib.util import find_spec
 from pathlib import Path
 
 import numpy as np
-import pytest
 
 from matplotlib.testing import setup
 
 setup()
 
 import scanpy as sc
+from scanpy.testing._pytest.marks import needs
 
 
 HERE: Path = Path(__file__).parent
 ROOT = HERE / 'pbmc3k_images'
 FIGS = HERE / 'figures'
 
-# TODO: Fix for newly varying clustering results
 
-
-@pytest.mark.xfail
-@pytest.mark.skipif(not find_spec("leidenalg"), reason="needs module `leidenalg`")
+@needs("leidenalg")
 def test_pbmc3k(image_comparer):
     save_and_compare_images = image_comparer(ROOT, FIGS, tol=20)
 
diff --git a/scanpy/tests/test_clustering.py b/scanpy/tests/test_clustering.py
--- a/scanpy/tests/test_clustering.py
+++ b/scanpy/tests/test_clustering.py
@@ -1,8 +1,7 @@
-from importlib.util import find_spec
-
 import pytest
 import scanpy as sc
-from scanpy.tests._data._cached_datasets import pbmc68k_reduced
+from scanpy.testing._helpers.data import pbmc68k_reduced
+from scanpy.testing._pytest.marks import needs
 
 
 @pytest.fixture
@@ -10,15 +9,7 @@ def adata_neighbors():
     return pbmc68k_reduced()
 
 
-needs_louvain = pytest.mark.skipif(
-    not find_spec("louvain"), reason="needs module `louvain`"
-)
-needs_leiden = pytest.mark.skipif(
-    not find_spec("leidenalg"), reason="needs module `leidenalg`"
-)
-
-
-@needs_leiden
+@needs("leidenalg")
 def test_leiden_basic(adata_neighbors):
     sc.tl.leiden(adata_neighbors)
 
@@ -26,8 +17,8 @@ def test_leiden_basic(adata_neighbors):
 @pytest.mark.parametrize(
     'clustering,key',
     [
-        pytest.param(sc.tl.louvain, 'louvain', marks=needs_louvain),
-        pytest.param(sc.tl.leiden, 'leiden', marks=needs_leiden),
+        pytest.param(sc.tl.louvain, 'louvain', marks=needs("louvain")),
+        pytest.param(sc.tl.leiden, 'leiden', marks=needs("leidenalg")),
     ],
 )
 def test_clustering_subset(adata_neighbors, clustering, key):
@@ -57,17 +48,17 @@ def test_clustering_subset(adata_neighbors, clustering, key):
         assert len(common_cat) == 0
 
 
+@needs("louvain")
 def test_louvain_basic(adata_neighbors):
-    pytest.importorskip("louvain")
-
     sc.tl.louvain(adata_neighbors)
     sc.tl.louvain(adata_neighbors, use_weights=True)
     sc.tl.louvain(adata_neighbors, use_weights=True, flavor="igraph")
     sc.tl.louvain(adata_neighbors, flavor="igraph")
 
 
+@needs("louvain")
 def test_partition_type(adata_neighbors):
-    louvain = pytest.importorskip("louvain")
+    import louvain
 
     sc.tl.louvain(adata_neighbors, partition_type=louvain.RBERVertexPartition)
     sc.tl.louvain(adata_neighbors, partition_type=louvain.SurpriseVertexPartition)
diff --git a/scanpy/tests/test_dendrogram_key_added.py b/scanpy/tests/test_dendrogram_key_added.py
--- a/scanpy/tests/test_dendrogram_key_added.py
+++ b/scanpy/tests/test_dendrogram_key_added.py
@@ -1,20 +1,17 @@
 import scanpy as sc
-import numpy as np
 import pytest
-from scanpy.tests._data._cached_datasets import pbmc68k_reduced
 
-n_neighbors = 5
-key = 'test'
+from scanpy.testing._helpers.data import pbmc68k_reduced
 
 
-@pytest.fixture
-def adata():
-    return pbmc68k_reduced()
+n_neighbors = 5
+key = 'test'
 
 
 @pytest.mark.parametrize('groupby', ['bulk_labels', ['bulk_labels', 'phase']])
 @pytest.mark.parametrize('key_added', [None, "custom_key"])
-def test_dendrogram_key_added(adata, groupby, key_added):
+def test_dendrogram_key_added(groupby, key_added):
+    adata = pbmc68k_reduced()
     sc.tl.dendrogram(adata, groupby=groupby, key_added=key_added, use_rep="X_pca")
     if isinstance(groupby, list):
         dendrogram_key = f'dendrogram_{"_".join(groupby)}'
diff --git a/scanpy/tests/test_deprecations.py b/scanpy/tests/test_deprecations.py
--- a/scanpy/tests/test_deprecations.py
+++ b/scanpy/tests/test_deprecations.py
@@ -1,8 +1,8 @@
-import scanpy as sc
-from scanpy.tests._data._cached_datasets import pbmc68k_reduced
-
 import pytest
 
+import scanpy as sc
+from scanpy.testing._helpers.data import pbmc68k_reduced
+
 
 def test_deprecate_multicore_tsne():
     pbmc = pbmc68k_reduced()
diff --git a/scanpy/tests/test_embedding.py b/scanpy/tests/test_embedding.py
--- a/scanpy/tests/test_embedding.py
+++ b/scanpy/tests/test_embedding.py
@@ -1,12 +1,11 @@
-from importlib.util import find_spec
 from unittest.mock import patch
-import warnings
-from scanpy.tests._data._cached_datasets import pbmc68k_reduced
 import numpy as np
 import pytest
 from numpy.testing import assert_array_almost_equal, assert_array_equal, assert_raises
 
 import scanpy as sc
+from scanpy.testing._helpers.data import pbmc68k_reduced
+from scanpy.testing._pytest.marks import needs
 
 
 def test_tsne():
@@ -32,8 +31,7 @@ def test_tsne():
 
 
 def test_umap_init_dtype():
-    pbmc = pbmc68k_reduced()
-    pbmc = pbmc[:100, :].copy()
+    pbmc = pbmc68k_reduced()[:100, :].copy()
     sc.tl.umap(pbmc, init_pos=pbmc.obsm["X_pca"][:, :2].astype(np.float32))
     embed1 = pbmc.obsm["X_umap"].copy()
     sc.tl.umap(pbmc, init_pos=pbmc.obsm["X_pca"][:, :2].astype(np.float64))
@@ -42,23 +40,15 @@ def test_umap_init_dtype():
     assert_array_almost_equal(embed1, embed2)
 
 
-needs_fa2 = pytest.mark.skipif(not find_spec("fa2"), reason="needs module `fa2`")
-needs_igraph = pytest.mark.skipif(
-    not find_spec("igraph"),
-    reason="needs module `igraph` (`pip install python-igraph`)",
-)
-
-
 @pytest.mark.parametrize(
     "layout",
     [
-        pytest.param("fa", marks=needs_fa2),
-        pytest.param("fr", marks=needs_igraph),
+        pytest.param("fa", marks=needs("fa2")),
+        pytest.param("fr", marks=needs("igraph")),
     ],
 )
 def test_umap_init_paga(layout):
-    pbmc = pbmc68k_reduced()
-    pbmc = pbmc[:100, :].copy()
+    pbmc = pbmc68k_reduced()[:100, :].copy()
     sc.tl.paga(pbmc)
     sc.pl.paga(pbmc, layout=layout, show=False)
     sc.tl.umap(pbmc, init_pos="paga")
diff --git a/scanpy/tests/test_embedding_density.py b/scanpy/tests/test_embedding_density.py
--- a/scanpy/tests/test_embedding_density.py
+++ b/scanpy/tests/test_embedding_density.py
@@ -1,7 +1,7 @@
 import numpy as np
 from anndata import AnnData
-from scanpy.tests._data._cached_datasets import pbmc68k_reduced
 import scanpy as sc
+from scanpy.testing._helpers.data import pbmc68k_reduced
 
 
 def test_embedding_density():
diff --git a/scanpy/tests/test_embedding_plots.py b/scanpy/tests/test_embedding_plots.py
--- a/scanpy/tests/test_embedding_plots.py
+++ b/scanpy/tests/test_embedding_plots.py
@@ -11,9 +11,12 @@
 import seaborn as sns
 
 import scanpy as sc
+from scanpy.testing._helpers.data import pbmc3k_processed
 
-from scanpy.tests.test_plotting import ROOT, FIGS, HERE
-import scanpy.tests._data._cached_datasets as datasets
+
+HERE: Path = Path(__file__).parent
+ROOT = HERE / '_images'
+FIGS = HERE / 'figures'
 
 MISSING_VALUES_ROOT = ROOT / "embedding-missing-values"
 MISSING_VALUES_FIGS = FIGS / "embedding-missing-values"
@@ -32,9 +35,7 @@ def adata():
     from sklearn.cluster import DBSCAN
 
     empty_pixel = np.array([1.0, 1.0, 1.0, 0]).reshape(1, 1, -1)
-    image = imread(
-        Path(sc.__file__).parent.parent / "docs/_static/img/Scanpy_Logo_RGB.png"
-    )
+    image = imread(HERE.parent.parent / "docs/_static/img/Scanpy_Logo_RGB.png")
     x, y = np.where(np.logical_and.reduce(~np.equal(image, empty_pixel), axis=2))
 
     # Just using to calculate the hex coords
@@ -276,7 +277,7 @@ def test_dimensions_same_as_components(adata, tmpdir, check_same_image):
 
 def test_embedding_colorbar_location(image_comparer):
     save_and_compare_images = image_comparer(ROOT, FIGS, tol=15)
-    adata = datasets.pbmc3k_processed().raw.to_adata()
+    adata = pbmc3k_processed().raw.to_adata()
 
     sc.pl.pca(adata, color="LDHB", colorbar_loc=None)
 
diff --git a/scanpy/tests/test_filter_rank_genes_groups.py b/scanpy/tests/test_filter_rank_genes_groups.py
--- a/scanpy/tests/test_filter_rank_genes_groups.py
+++ b/scanpy/tests/test_filter_rank_genes_groups.py
@@ -1,6 +1,6 @@
 import numpy as np
+from scanpy.testing._helpers.data import pbmc68k_reduced
 from scanpy.tools import rank_genes_groups, filter_rank_genes_groups
-from scanpy.tests._data._cached_datasets import pbmc68k_reduced
 
 
 names_no_reference = np.array(
diff --git a/scanpy/tests/test_get.py b/scanpy/tests/test_get.py
--- a/scanpy/tests/test_get.py
+++ b/scanpy/tests/test_get.py
@@ -9,7 +9,8 @@
 
 import scanpy as sc
 from scanpy.datasets._utils import filter_oldformatwarning
-from scanpy.tests._data._cached_datasets import pbmc68k_reduced
+from scanpy.testing._helpers.data import pbmc68k_reduced
+
 
 TRANSPOSE_PARAMS = pytest.mark.parametrize(
     "dim,transform,func",
@@ -176,7 +177,7 @@ def test_repeated_gene_symbols():
 
 @filter_oldformatwarning
 def test_backed_vs_memory():
-    "compares backed vs. memory"
+    """compares backed vs. memory"""
     from pathlib import Path
 
     # get location test h5ad file in datasets
@@ -202,7 +203,7 @@ def test_backed_vs_memory():
 
 
 def test_column_content():
-    "uses a larger dataset to test column order and content"
+    """uses a larger dataset to test column order and content"""
     adata = pbmc68k_reduced()
 
     # test that columns content is correct for obs_df
diff --git a/scanpy/tests/test_highly_variable_genes.py b/scanpy/tests/test_highly_variable_genes.py
--- a/scanpy/tests/test_highly_variable_genes.py
+++ b/scanpy/tests/test_highly_variable_genes.py
@@ -1,27 +1,16 @@
-from importlib.util import find_spec
-
 import pytest
 import pandas as pd
 import numpy as np
 import scanpy as sc
 from pathlib import Path
-from scipy.sparse import csr_matrix
-from scanpy.tests.helpers import (
-    _prepare_pbmc_testdata,
-    _check_check_values_warnings,
-)
-import warnings
-
-from scanpy.tests._data._cached_datasets import pbmc3k, pbmc68k_reduced
+from scanpy.testing._helpers import _check_check_values_warnings
+from scanpy.testing._helpers.data import pbmc3k, pbmc68k_reduced
+from scanpy.testing._pytest.marks import needs
 
 FILE = Path(__file__).parent / Path('_scripts/seurat_hvg.csv')
 FILE_V3 = Path(__file__).parent / Path('_scripts/seurat_hvg_v3.csv.gz')
 FILE_V3_BATCH = Path(__file__).parent / Path('_scripts/seurat_hvg_v3_batch.csv')
 
-needs_skmisc = pytest.mark.skipif(
-    not find_spec('skmisc'), reason="needs module `skmisc`"
-)
-
 
 def test_highly_variable_genes_basic():
     adata = sc.datasets.blobs()
@@ -92,15 +81,11 @@ def _check_pearson_hvg_columns(output_df, n_top_genes):
     assert np.nanmax(output_df['highly_variable_rank'].values) <= n_top_genes - 1
 
 
-@pytest.mark.parametrize(
-    'sparsity_func', [csr_matrix.toarray, csr_matrix], ids=lambda x: x.__name__
-)
-@pytest.mark.parametrize('dtype', ['float32', 'int64'])
-def test_highly_variable_genes_pearson_residuals_inputchecks(sparsity_func, dtype):
-    adata = _prepare_pbmc_testdata(sparsity_func, dtype, small=True)
+def test_highly_variable_genes_pearson_residuals_inputchecks(pbmc3k_parametrized_small):
+    adata = pbmc3k_parametrized_small()
 
     # depending on check_values, warnings should be raised for non-integer data
-    if dtype == 'float32':
+    if adata.X.dtype == 'float32':
         adata_noninteger = adata.copy()
         x, y = np.nonzero(adata_noninteger.X)
         adata_noninteger.X[x[0], y[0]] = 0.5
@@ -130,18 +115,14 @@ def test_highly_variable_genes_pearson_residuals_inputchecks(sparsity_func, dtyp
         )
 
 
-@pytest.mark.parametrize(
-    'sparsity_func', [csr_matrix.toarray, csr_matrix], ids=lambda x: x.__name__
-)
-@pytest.mark.parametrize('dtype', ['float32', 'int64'])
 @pytest.mark.parametrize('subset', [True, False])
 @pytest.mark.parametrize('clip', [None, np.Inf, 30])
 @pytest.mark.parametrize('theta', [100, np.Inf])
 @pytest.mark.parametrize('n_top_genes', [100, 200])
 def test_highly_variable_genes_pearson_residuals_general(
-    subset, sparsity_func, dtype, clip, theta, n_top_genes
+    pbmc3k_parametrized_small, subset, clip, theta, n_top_genes
 ):
-    adata = _prepare_pbmc_testdata(sparsity_func, dtype, small=True)
+    adata = pbmc3k_parametrized_small()
     # cleanup var
     del adata.var
 
@@ -218,16 +199,12 @@ def test_highly_variable_genes_pearson_residuals_general(
     _check_pearson_hvg_columns(output_df, n_top_genes)
 
 
-@pytest.mark.parametrize(
-    'sparsity_func', [csr_matrix.toarray, csr_matrix], ids=lambda x: x.__name__
-)
-@pytest.mark.parametrize('dtype', ['float32', 'int64'])
 @pytest.mark.parametrize('subset', [True, False])
 @pytest.mark.parametrize('n_top_genes', [100, 200])
 def test_highly_variable_genes_pearson_residuals_batch(
-    subset, n_top_genes, sparsity_func, dtype
+    pbmc3k_parametrized_small, subset, n_top_genes
 ):
-    adata = _prepare_pbmc_testdata(sparsity_func, dtype, small=True)
+    adata = pbmc3k_parametrized_small()
     # cleanup var
     del adata.var
     n_genes = adata.shape[1]
@@ -328,7 +305,7 @@ def test_higly_variable_genes_compare_to_seurat():
     )
 
 
-@needs_skmisc
+@needs("skmisc")
 def test_higly_variable_genes_compare_to_seurat_v3():
     seurat_hvg_info = pd.read_csv(
         FILE_V3, sep=' ', dtype={"variances_norm": np.float64}
@@ -484,7 +461,7 @@ def test_highly_variable_genes_batches():
     assert np.all(np.isin(colnames, hvg1.columns))
 
 
-@needs_skmisc
+@needs("skmisc")
 def test_seurat_v3_mean_var_output_with_batchkey():
     pbmc = pbmc3k()
     pbmc.var_names_make_unique()
diff --git a/scanpy/tests/test_ingest.py b/scanpy/tests/test_ingest.py
--- a/scanpy/tests/test_ingest.py
+++ b/scanpy/tests/test_ingest.py
@@ -7,7 +7,8 @@
 import scanpy as sc
 from scanpy import settings
 from scanpy._compat import pkg_version
-from scanpy.tests._data._cached_datasets import pbmc68k_reduced
+from scanpy.testing._helpers.data import pbmc68k_reduced
+
 
 X = np.array(
     [
diff --git a/scanpy/tests/test_metrics.py b/scanpy/tests/test_metrics.py
--- a/scanpy/tests/test_metrics.py
+++ b/scanpy/tests/test_metrics.py
@@ -9,7 +9,8 @@
 
 from anndata.tests.helpers import asarray
 import pytest
-from scanpy.tests._data._cached_datasets import pbmc68k_reduced
+
+from scanpy.testing._helpers.data import pbmc68k_reduced
 
 
 def test_gearys_c_consistency():
diff --git a/scanpy/tests/test_neighbors_key_added.py b/scanpy/tests/test_neighbors_key_added.py
--- a/scanpy/tests/test_neighbors_key_added.py
+++ b/scanpy/tests/test_neighbors_key_added.py
@@ -1,14 +1,13 @@
-from importlib.util import find_spec
-
 import scanpy as sc
 import numpy as np
 import pytest
 
+from scanpy.testing._pytest.marks import needs
+from scanpy.testing._helpers.data import pbmc68k_reduced
+
 n_neighbors = 5
 key = 'test'
 
-from scanpy.tests._data._cached_datasets import pbmc68k_reduced
-
 
 @pytest.fixture
 def adata():
@@ -32,10 +31,8 @@ def test_neighbors_key_added(adata):
 
 
 # test functions with neighbors_key and obsp
-@pytest.mark.skipif(
-    not find_spec("igraph"),
-    reason="needs module `igraph` (`pip install python-igraph`)",
-)
+@needs("igraph")
+@needs("leidenalg")
 @pytest.mark.parametrize('field', ['neighbors_key', 'obsp'])
 def test_neighbors_key_obsp(adata, field):
     adata1 = adata.copy()
@@ -81,9 +78,9 @@ def test_neighbors_key_obsp(adata, field):
         )
 
 
+@needs("louvain")
 @pytest.mark.parametrize('field', ['neighbors_key', 'obsp'])
 def test_neighbors_key_obsp_louvain(adata, field):
-    pytest.importorskip("louvain")
     adata1 = adata.copy()
 
     sc.pp.neighbors(adata, n_neighbors=n_neighbors, random_state=0)
diff --git a/scanpy/tests/test_normalization.py b/scanpy/tests/test_normalization.py
--- a/scanpy/tests/test_normalization.py
+++ b/scanpy/tests/test_normalization.py
@@ -4,16 +4,17 @@
 from scipy.sparse import csr_matrix
 from scipy import sparse
 
+from scanpy.testing._pytest.marks import needs
+
 try:
     import dask.array as da
 except ImportError:
     da = None
 
 import scanpy as sc
-from scanpy.tests.helpers import (
+from scanpy.testing._helpers import (
     check_rep_mutation,
     check_rep_results,
-    _prepare_pbmc_testdata,
     _check_check_values_warnings,
 )
 from anndata.tests.helpers import assert_equal, asarray
@@ -24,13 +25,15 @@
 
 
 @pytest.fixture(
-    params=[np.array, csr_matrix, da.from_array if da else None],
+    params=[
+        lambda: np.array,
+        lambda: csr_matrix,
+        pytest.param(lambda: da.from_array, marks=[needs("dask")]),
+    ],
     ids=["numpy-array", "sparse-csr", "dask-array"],
 )
 def typ(request):
-    if request.param is None:
-        pytest.importorskip('dask.array', reason='dask.array required')
-    return request.param
+    return request.param()
 
 
 @pytest.mark.parametrize('dtype', ['float32', 'int64'])
@@ -76,15 +79,11 @@ def test_normalize_total_view(typ, dtype):
     assert_equal(adata, v)
 
 
-@pytest.mark.parametrize(
-    'sparsity_func', [csr_matrix.toarray, csr_matrix], ids=lambda x: x.__name__
-)
-@pytest.mark.parametrize('dtype', ['float32', 'int64'])
-def test_normalize_pearson_residuals_inputchecks(sparsity_func, dtype):
-    adata = _prepare_pbmc_testdata(sparsity_func, dtype)
+def test_normalize_pearson_residuals_inputchecks(pbmc3k_parametrized):
+    adata = pbmc3k_parametrized()
 
     # depending on check_values, warnings should be raised for non-integer data
-    if dtype == 'float32':
+    if adata.X.dtype == 'float32':
         adata_noninteger = adata.copy()
         x, y = np.nonzero(adata_noninteger.X)
         adata_noninteger.X[x[0], y[0]] = 0.5
@@ -184,14 +183,10 @@ def _check_pearson_pca_fields(ad, n_cells, n_comps):
     ), 'Wrong shape of PCA output in `X_pca`'
 
 
-@pytest.mark.parametrize(
-    'sparsity_func', [csr_matrix.toarray, csr_matrix], ids=lambda x: x.__name__
-)
-@pytest.mark.parametrize('dtype', ['float32', 'int64'])
 @pytest.mark.parametrize('n_hvgs', [100, 200])
 @pytest.mark.parametrize('n_comps', [30, 50])
-def test_normalize_pearson_residuals_pca(sparsity_func, dtype, n_hvgs, n_comps):
-    adata = _prepare_pbmc_testdata(sparsity_func, dtype, small=True)
+def test_normalize_pearson_residuals_pca(pbmc3k_parametrized_small, n_hvgs, n_comps):
+    adata = pbmc3k_parametrized_small()
     n_cells, n_genes = adata.shape
 
     adata_with_hvgs = adata.copy()
@@ -284,14 +279,10 @@ def test_normalize_pearson_residuals_pca(sparsity_func, dtype, n_hvgs, n_comps):
         )
 
 
-@pytest.mark.parametrize(
-    'sparsity_func', [csr_matrix.toarray, csr_matrix], ids=lambda x: x.__name__
-)
-@pytest.mark.parametrize('dtype', ['float32', 'int64'])
 @pytest.mark.parametrize('n_hvgs', [100, 200])
 @pytest.mark.parametrize('n_comps', [30, 50])
-def test_normalize_pearson_residuals_recipe(sparsity_func, dtype, n_hvgs, n_comps):
-    adata = _prepare_pbmc_testdata(sparsity_func, dtype, small=True)
+def test_normalize_pearson_residuals_recipe(pbmc3k_parametrized_small, n_hvgs, n_comps):
+    adata = pbmc3k_parametrized_small()
     n_cells, n_genes = adata.shape
 
     ### inplace = False ###
diff --git a/scanpy/tests/test_paga.py b/scanpy/tests/test_paga.py
--- a/scanpy/tests/test_paga.py
+++ b/scanpy/tests/test_paga.py
@@ -1,51 +1,50 @@
 from functools import partial
-from importlib.util import find_spec
 from pathlib import Path
 
-from matplotlib import cm
+import pytest
 import numpy as np
+from matplotlib import cm
 
 import scanpy as sc
-from scanpy.tests._data._cached_datasets import pbmc68k_reduced, pbmc3k_processed
-import pytest
+from scanpy.testing._helpers.data import pbmc3k_processed, pbmc68k_reduced
+from scanpy.testing._pytest.marks import needs
+
 
 HERE: Path = Path(__file__).parent
 ROOT = HERE / '_images'
 FIGS = HERE / 'figures'
 
 
-needs_igraph = pytest.mark.skipif(
-    not find_spec("igraph"),
-    reason="needs module `igraph` (`pip install python-igraph`)",
-)
-
-
 @pytest.fixture(scope="module")
-def pbmc():
+def _pbmc_session():
     pbmc = pbmc68k_reduced()
     sc.tl.paga(pbmc, groups='bulk_labels')
     pbmc.obs['cool_feature'] = pbmc[:, 'CST3'].X.squeeze().copy()
+    assert not pbmc.obs['cool_feature'].isna().all()
     return pbmc
 
 
-@needs_igraph
+@pytest.fixture
+def pbmc(_pbmc_session):
+    return _pbmc_session.copy()
+
+
+@needs("igraph")
 @pytest.mark.parametrize(
     "test_id,func",
     [
-        ("master_paga", sc.pl.paga),
-        ("master_paga_continuous", partial(sc.pl.paga, color="CST3")),
-        ("master_paga_continuous_obs", partial(sc.pl.paga, color="cool_feature")),
-        (
-            "master_paga_continuous_multiple",
-            partial(sc.pl.paga, color=['CST3', 'GATA2']),
-        ),
-        ("master_paga_compare", partial(sc.pl.paga_compare, legend_fontoutline=2)),
-        (
-            "master_paga_compare_continuous",
+        ("", sc.pl.paga),
+        ("continuous", partial(sc.pl.paga, color="CST3")),
+        ("continuous_obs", partial(sc.pl.paga, color="cool_feature")),
+        ("continuous_multiple", partial(sc.pl.paga, color=['CST3', 'GATA2'])),
+        ("compare", partial(sc.pl.paga_compare, legend_fontoutline=2)),
+        pytest.param(
+            "compare_continuous",
             partial(sc.pl.paga_compare, color='CST3', legend_fontsize=5),
+            marks=pytest.mark.xfail(reason="expects .uns['paga']['pos']"),
         ),
         (
-            "master_paga_compare_pca",
+            "compare_pca",
             partial(sc.pl.paga_compare, basis='X_pca', legend_fontweight='normal'),
         ),
     ],
@@ -55,10 +54,10 @@ def test_paga_plots(image_comparer, pbmc, test_id, func):
     common = dict(threshold=0.5, max_edge_width=1.0, random_state=0, show=False)
 
     func(pbmc, **common)
-    save_and_compare_images(test_id)
+    save_and_compare_images(f"master_paga_{test_id}" if test_id else "master_paga")
 
 
-@needs_igraph
+@needs("igraph")
 def test_paga_pie(image_comparer, pbmc):
     save_and_compare_images = image_comparer(ROOT, FIGS, tol=30)
 
@@ -72,7 +71,7 @@ def test_paga_pie(image_comparer, pbmc):
     save_and_compare_images('master_paga_pie')
 
 
-@needs_igraph
+@needs("igraph")
 def test_paga_path(image_comparer, pbmc):
     save_and_compare_images = image_comparer(ROOT, FIGS, tol=15)
 
@@ -87,7 +86,7 @@ def test_paga_path(image_comparer, pbmc):
     save_and_compare_images('master_paga_path')
 
 
-@needs_igraph
+@needs("igraph")
 def test_paga_compare(image_comparer):
     # Tests that https://github.com/scverse/scanpy/issues/1887 is fixed
     save_and_compare_images = image_comparer(ROOT, FIGS, tol=15)
@@ -100,7 +99,7 @@ def test_paga_compare(image_comparer):
     save_and_compare_images('master_paga_compare_pbmc3k')
 
 
-@needs_igraph
+@needs("igraph")
 def test_paga_positions_reproducible():
     """Check exact reproducibility and effect of random_state on paga positions"""
     # https://github.com/scverse/scanpy/issues/1859
diff --git a/scanpy/tests/test_pca.py b/scanpy/tests/test_pca.py
--- a/scanpy/tests/test_pca.py
+++ b/scanpy/tests/test_pca.py
@@ -5,6 +5,8 @@
 import scanpy as sc
 from anndata.tests.helpers import assert_equal
 
+from scanpy.testing._helpers.data import pbmc3k_normalized
+
 A_list = [
     [0, 0, 7, 0, 0],
     [8, 5, 0, 2, 0],
@@ -77,12 +79,12 @@ def test_pca_shapes():
         sc.pp.pca(adata, n_comps=100)
 
 
-def test_pca_sparse(pbmc3k_normalized):
+def test_pca_sparse():
     """
     Tests that implicitly centered pca on sparse arrays returns equivalent results to
     explicit centering on dense arrays.
     """
-    pbmc = pbmc3k_normalized
+    pbmc = pbmc3k_normalized()
 
     pbmc_dense = pbmc.copy()
     pbmc_dense.X = pbmc_dense.X.toarray()
@@ -98,8 +100,8 @@ def test_pca_sparse(pbmc3k_normalized):
     assert np.allclose(implicit.varm['PCs'], explicit.varm['PCs'])
 
 
-def test_pca_reproducible(pbmc3k_normalized, array_type):
-    pbmc = pbmc3k_normalized
+def test_pca_reproducible(array_type):
+    pbmc = pbmc3k_normalized()
     pbmc.X = array_type(pbmc.X)
 
     a = sc.pp.pca(pbmc, copy=True, dtype=np.float64, random_state=42)
@@ -112,15 +114,16 @@ def test_pca_reproducible(pbmc3k_normalized, array_type):
     assert not np.array_equal(a.obsm["X_pca"], c.obsm["X_pca"])
 
 
-def test_pca_chunked(pbmc3k_normalized):
+def test_pca_chunked():
     # https://github.com/scverse/scanpy/issues/1590
     # But also a more general test
 
     # Subsetting for speed of test
-    pbmc = pbmc3k_normalized[::6].copy()
+    pbmc_full = pbmc3k_normalized()
+    pbmc = pbmc_full[::6].copy()
     pbmc.X = pbmc.X.astype(np.float64)
-    chunked = sc.pp.pca(pbmc3k_normalized, chunked=True, copy=True)
-    default = sc.pp.pca(pbmc3k_normalized, copy=True)
+    chunked = sc.pp.pca(pbmc_full, chunked=True, copy=True)
+    default = sc.pp.pca(pbmc_full, copy=True)
 
     # Taking absolute value since sometimes dimensions are flipped
     np.testing.assert_allclose(
@@ -136,12 +139,12 @@ def test_pca_chunked(pbmc3k_normalized):
     )
 
 
-def test_pca_n_pcs(pbmc3k_normalized):
+def test_pca_n_pcs():
     """
     Tests that the n_pcs parameter also works for
     representations not called "X_pca"
     """
-    pbmc = pbmc3k_normalized
+    pbmc = pbmc3k_normalized()
     sc.pp.pca(pbmc, dtype=np.float64)
     pbmc.obsm["X_pca_test"] = pbmc.obsm["X_pca"]
     original = sc.pp.neighbors(pbmc, n_pcs=5, use_rep="X_pca", copy=True)
diff --git a/scanpy/tests/test_plotting.py b/scanpy/tests/test_plotting.py
--- a/scanpy/tests/test_plotting.py
+++ b/scanpy/tests/test_plotting.py
@@ -1,18 +1,13 @@
+from __future__ import annotations
+
 from functools import partial
-from importlib.util import find_spec
 from pathlib import Path
 from itertools import repeat, chain, combinations
+from collections.abc import Callable
 
 import pytest
 from matplotlib.testing import setup
 from packaging import version
-from scanpy.tests._data._cached_datasets import (
-    pbmc3k,
-    pbmc3k_processed,
-    pbmc68k_reduced,
-    krumsiek11,
-)
-from scanpy._compat import pkg_version
 
 setup()
 
@@ -25,6 +20,15 @@
 from anndata import AnnData
 
 import scanpy as sc
+from scanpy._compat import pkg_version
+from scanpy.testing._pytest.marks import needs
+from scanpy.testing._helpers.data import (
+    pbmc3k,
+    pbmc3k_processed,
+    krumsiek11,
+    pbmc68k_reduced,
+)
+
 
 HERE: Path = Path(__file__).parent
 ROOT = HERE / '_images'
@@ -39,7 +43,7 @@
 # the ./figures folder to ./_images/
 
 
-@pytest.mark.skipif(not find_spec("leidenalg"), reason="needs module `leidenalg`")
+@needs("leidenalg")
 def test_heatmap(image_comparer):
     save_and_compare_images = image_comparer(ROOT, FIGS, tol=15)
 
@@ -521,12 +525,10 @@ def test_violin(image_comparer):
 
 
 # TODO: Generalize test to more plotting types
-def test_violin_without_raw(tmpdir):
+def test_violin_without_raw(tmp_path):
     # https://github.com/scverse/scanpy/issues/1546
-    TESTDIR = Path(tmpdir)
-
-    has_raw_pth = TESTDIR / "has_raw.png"
-    no_raw_pth = TESTDIR / "no_raw.png"
+    has_raw_pth = tmp_path / "has_raw.png"
+    no_raw_pth = tmp_path / "no_raw.png"
 
     pbmc = pbmc68k_reduced()
     pbmc_no_raw = pbmc.raw.to_adata().copy()
@@ -790,7 +792,7 @@ def test_rank_genes_groups(image_comparer, name, fn):
 
 
 @pytest.fixture(scope="session")
-def gene_symbols_adatas():
+def _gene_symbols_adatas():
     """Create two anndata objects which are equivalent except for var_names
 
     Both have ensembl ids and hgnc symbols as columns in var. The first has ensembl
@@ -822,6 +824,12 @@ def gene_symbols_adatas():
     return a, b
 
 
+@pytest.fixture
+def gene_symbols_adatas(_gene_symbols_adatas):
+    a, b = _gene_symbols_adatas
+    return a.copy(), b.copy()
+
+
 @pytest.mark.parametrize(
     "func",
     (
@@ -876,7 +884,7 @@ def test_plot_rank_genes_groups_gene_symbols(
         # TODO: add other rank_genes_groups plots here once they work
     ),
 )
-def test_rank_genes_groups_plots_n_genes_vs_var_names(tmpdir, func, check_same_image):
+def test_rank_genes_groups_plots_n_genes_vs_var_names(tmp_path, func, check_same_image):
     """\
     Checks that passing a negative value for n_genes works, and that passing
     var_names as a dict works.
@@ -894,10 +902,10 @@ def test_rank_genes_groups_plots_n_genes_vs_var_names(tmpdir, func, check_same_i
         top_genes[g] = list(subdf["names"].head(N))
         bottom_genes[g] = list(subdf["names"].tail(N))
 
-    positive_n_pth = tmpdir / f"{func.__name__}_positive_n.png"
-    top_genes_pth = tmpdir / f"{func.__name__}_top_genes.png"
-    negative_n_pth = tmpdir / f"{func.__name__}_negative_n.png"
-    bottom_genes_pth = tmpdir / f"{func.__name__}_bottom_genes.png"
+    positive_n_pth = tmp_path / f"{func.__name__}_positive_n.png"
+    top_genes_pth = tmp_path / f"{func.__name__}_top_genes.png"
+    negative_n_pth = tmp_path / f"{func.__name__}_negative_n.png"
+    bottom_genes_pth = tmp_path / f"{func.__name__}_bottom_genes.png"
 
     def wrapped(pth, **kwargs):
         func(pbmc, groupby="louvain", dendrogram=False, **kwargs)
@@ -918,7 +926,7 @@ def wrapped(pth, **kwargs):
     with pytest.raises(
         ValueError, match="n_genes and var_names are mutually exclusive"
     ):
-        wrapped(tmpdir / "not_written.png", n_genes=N, var_names=top_genes)
+        wrapped(tmp_path / "not_written.png", n_genes=N, var_names=top_genes)
 
 
 @pytest.mark.parametrize(
@@ -945,7 +953,7 @@ def test_genes_symbols(image_comparer, id, fn):
 
 
 @pytest.fixture(scope="module")
-def _pbmc_scatterplots():
+def _pbmc_scatterplots_session():
     # Wrapped in another fixture to avoid mutation
     pbmc = pbmc68k_reduced()
     pbmc.layers["sparse"] = pbmc.raw.X / 2
@@ -958,8 +966,8 @@ def _pbmc_scatterplots():
 
 
 @pytest.fixture
-def pbmc_scatterplots(_pbmc_scatterplots):
-    return _pbmc_scatterplots.copy()
+def pbmc_scatterplots(_pbmc_scatterplots_session):
+    return _pbmc_scatterplots_session.copy()
 
 
 @pytest.mark.parametrize(
@@ -1253,29 +1261,31 @@ def test_scatter_no_basis_per_var(image_comparer):
 
 
 @pytest.fixture
-def pbmc_filtered():
+def pbmc_filtered() -> Callable[[], AnnData]:
     pbmc = pbmc68k_reduced()
     sc.pp.filter_genes(pbmc, min_cells=10)
-    return pbmc
+    return pbmc.copy
 
 
 def test_scatter_no_basis_raw(check_same_image, pbmc_filtered, tmpdir):
+    adata = pbmc_filtered()
+
     """Test scatterplots of raw layer with no basis."""
     path1 = tmpdir / "scatter_EGFL7_F12_FAM185A_rawNone.png"
     path2 = tmpdir / "scatter_EGFL7_F12_FAM185A_rawTrue.png"
     path3 = tmpdir / "scatter_EGFL7_F12_FAM185A_rawToAdata.png"
 
-    sc.pl.scatter(pbmc_filtered, x='EGFL7', y='F12', color='FAM185A', use_raw=None)
+    sc.pl.scatter(adata, x='EGFL7', y='F12', color='FAM185A', use_raw=None)
     plt.savefig(path1)
     plt.close()
 
     # is equivalent to:
-    sc.pl.scatter(pbmc_filtered, x='EGFL7', y='F12', color='FAM185A', use_raw=True)
+    sc.pl.scatter(adata, x='EGFL7', y='F12', color='FAM185A', use_raw=True)
     plt.savefig(path2)
     plt.close()
 
     # and also to:
-    sc.pl.scatter(pbmc_filtered.raw.to_adata(), x='EGFL7', y='F12', color='FAM185A')
+    sc.pl.scatter(adata.raw.to_adata(), x='EGFL7', y='F12', color='FAM185A')
     plt.savefig(path3)
 
     check_same_image(path1, path2, tol=15)
@@ -1303,7 +1313,7 @@ def test_scatter_no_basis_value_error(pbmc_filtered, x, y, color, use_raw):
     expected.
     """
     with pytest.raises(ValueError):
-        sc.pl.scatter(pbmc_filtered, x=x, y=y, color=color, use_raw=use_raw)
+        sc.pl.scatter(pbmc_filtered(), x=x, y=y, color=color, use_raw=use_raw)
 
 
 def test_rankings(image_comparer):
@@ -1506,8 +1516,7 @@ def test_repeated_colors_w_missing_value():
         # TODO: add other rank_genes_groups plots here once they work
     ),
 )
-def test_filter_rank_genes_groups_plots(tmpdir, plot, check_same_image):
-    TESTDIR = Path(tmpdir)
+def test_filter_rank_genes_groups_plots(tmp_path, plot, check_same_image):
     N_GENES = 4
 
     adata = pbmc68k_reduced()
@@ -1528,8 +1537,8 @@ def test_filter_rank_genes_groups_plots(tmpdir, plot, check_same_image):
 
     var_names = {k: v.head(N_GENES).tolist() for k, v in df.groupby("group")["names"]}
 
-    pth_a = TESTDIR / f"{plot.__name__}_filter_a.png"
-    pth_b = TESTDIR / f"{plot.__name__}_filter_b.png"
+    pth_a = tmp_path / f"{plot.__name__}_filter_a.png"
+    pth_b = tmp_path / f"{plot.__name__}_filter_b.png"
 
     plot(adata, key='rank_genes_groups_filtered', n_genes=N_GENES)
     plt.savefig(pth_a)
@@ -1542,7 +1551,7 @@ def test_filter_rank_genes_groups_plots(tmpdir, plot, check_same_image):
     check_same_image(pth_a, pth_b, tol=1)
 
 
-@pytest.mark.skipif(not find_spec("scrublet"), reason="needs module `scrublet`")
+@needs("scrublet")
 def test_scrublet_plots(image_comparer, plt):
     save_and_compare_images = image_comparer(ROOT, FIGS, tol=30)
 
diff --git a/scanpy/tests/test_preprocessing.py b/scanpy/tests/test_preprocessing.py
--- a/scanpy/tests/test_preprocessing.py
+++ b/scanpy/tests/test_preprocessing.py
@@ -1,5 +1,4 @@
 from itertools import product
-from pathlib import Path
 
 import numpy as np
 import pandas as pd
@@ -10,8 +9,8 @@
 from anndata import AnnData
 from anndata.tests.helpers import assert_equal, asarray
 
-from scanpy.tests.helpers import check_rep_mutation, check_rep_results
-from scanpy.tests._data._cached_datasets import pbmc68k_reduced
+from scanpy.testing._helpers import check_rep_mutation, check_rep_results
+from scanpy.testing._helpers.data import pbmc68k_reduced
 
 
 def test_log1p(tmp_path):
diff --git a/scanpy/tests/test_preprocessing_distributed.py b/scanpy/tests/test_preprocessing_distributed.py
--- a/scanpy/tests/test_preprocessing_distributed.py
+++ b/scanpy/tests/test_preprocessing_distributed.py
@@ -8,17 +8,14 @@
 from scanpy.preprocessing import normalize_total, filter_genes
 from scanpy.preprocessing import log1p, normalize_per_cell, filter_cells
 from scanpy.preprocessing._distributed import materialize_as_ndarray
+from scanpy.testing._pytest.marks import needs
+
 
 HERE = Path(__file__).parent / Path('_data/')
 input_file = str(Path(HERE, "10x-10k-subset.zarr"))
 
-required = ['dask', 'zappy', 'zarr']
-installed = {mod: bool(find_spec(mod)) for mod in required}
-
 
-@pytest.mark.skipif(
-    not all(installed.values()), reason=f'{required} all required: {installed}'
-)
+@needs("zarr")
 class TestPreprocessingDistributed:
     @pytest.fixture()
     def adata(self):
@@ -26,11 +23,17 @@ def adata(self):
         a.X = a.X[:]  # convert to numpy array
         return a
 
-    @pytest.fixture(params=["direct", "dask"])
+    @pytest.fixture(
+        params=[
+            pytest.param("direct", marks=[needs("zappy")]),
+            pytest.param("dask", marks=[needs("dask")]),
+        ]
+    )
     def adata_dist(self, request):
         # regular anndata except for X, which we replace on the next line
         a = ad.read_zarr(input_file)
-        input_file_X = input_file + "/X"
+        a.uns["dist-mode"] = request.param
+        input_file_X = f"{input_file}/X"
         if request.param == "direct":
             import zappy.direct
 
@@ -51,6 +54,8 @@ def test_log1p(self, adata, adata_dist):
         npt.assert_allclose(result, adata.X)
 
     def test_normalize_per_cell(self, adata, adata_dist):
+        if adata_dist.uns["dist-mode"] == "dask":
+            pytest.xfail("TODO: Test broken for dask")
         normalize_per_cell(adata_dist)
         result = materialize_as_ndarray(adata_dist.X)
         normalize_per_cell(adata)
@@ -83,8 +88,7 @@ def test_filter_genes(self, adata, adata_dist):
         npt.assert_allclose(result, adata.X)
 
     def test_write_zarr(self, adata, adata_dist):
-        da = pytest.importorskip("dask.array")
-        zarr = pytest.importorskip("zarr")
+        import zarr
 
         log1p(adata_dist)
         temp_store = zarr.TempStore()
@@ -93,10 +97,12 @@ def test_write_zarr(self, adata, adata_dist):
             chunks = (chunks[0][0],) + chunks[1]
         # write metadata using regular anndata
         adata.write_zarr(temp_store, chunks)
-        if isinstance(adata_dist.X, da.Array):
+        if adata_dist.uns["dist-mode"] == "dask":
             adata_dist.X.to_zarr(temp_store.dir_path("X"), overwrite=True)
-        else:
+        elif adata_dist.uns["dist-mode"] == "direct":
             adata_dist.X.to_zarr(temp_store.dir_path("X"), chunks)
+        else:
+            assert False, "add branch for new dist-mode"
         # read back as zarr directly and check it is the same as adata.X
         adata_log1p = ad.read_zarr(temp_store)
         log1p(adata)
diff --git a/scanpy/tests/test_queries.py b/scanpy/tests/test_queries.py
--- a/scanpy/tests/test_queries.py
+++ b/scanpy/tests/test_queries.py
@@ -1,7 +1,7 @@
 import pandas as pd
 import pytest
 import scanpy as sc
-from scanpy.tests._data._cached_datasets import pbmc68k_reduced
+from scanpy.testing._helpers.data import pbmc68k_reduced
 
 
 @pytest.mark.internet
diff --git a/scanpy/tests/test_rank_genes_groups.py b/scanpy/tests/test_rank_genes_groups.py
--- a/scanpy/tests/test_rank_genes_groups.py
+++ b/scanpy/tests/test_rank_genes_groups.py
@@ -13,10 +13,10 @@
 
 import scanpy as sc
 from anndata import AnnData
+from scanpy.testing._helpers.data import pbmc68k_reduced
 from scanpy.tools import rank_genes_groups
 from scanpy.tools._rank_genes_groups import _RankGenes
 from scanpy.get import rank_genes_groups_df
-from scanpy.tests._data._cached_datasets import pbmc68k_reduced
 from scanpy._utils import select_groups
 
 
diff --git a/scanpy/tests/test_score_genes.py b/scanpy/tests/test_score_genes.py
--- a/scanpy/tests/test_score_genes.py
+++ b/scanpy/tests/test_score_genes.py
@@ -5,7 +5,9 @@
 import pytest
 import pickle
 from pathlib import Path
-from scanpy.tests._data._cached_datasets import paul15
+
+from scanpy.testing._helpers.data import paul15
+
 
 HERE = Path(__file__).parent / Path('_data/')