diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 977b882177edbd76dcc810c58abf9cccedafe0f9..b9dbe574a70359bd7a0d75df2885269d4b286b33 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -50,3 +50,16 @@ include:
   - component: $CI_SERVER_FQDN/computing/gitlab/components/sphinx/build@1
     inputs:
       requirements: ".[docs]"
+
+
+# -- customisations ------------------
+
+redhat_test_el8:
+  before_script:
+    - !reference [redhat_test, before_script]
+    # install a newer version of pytest and friends on EL8
+    - dnf install -y -q python3-pip &&
+      python3 -m pip install
+        --upgrade-strategy=only-if-needed
+        "coverage[toml]==5.3"
+        "pytest==3.9.3"
diff --git a/debian/control b/debian/control
index 5e2c44def5ae3929316c5b0b7908aee20ef1537d..608f44cdd3bc59931dbc19ed497dd505ccead706 100644
--- a/debian/control
+++ b/debian/control
@@ -14,7 +14,7 @@ Build-Depends:
  python3-argparse-manpage,
  python3-igwn-auth-utils (>= 0.3.1),
  python3-igwn-segments,
- python3-pytest (>= 2.8.0),
+ python3-pytest (>= 3.9.3),
  python3-requests-mock,
  python3-setuptools,
 
diff --git a/gwdatafind/conftest.py b/gwdatafind/conftest.py
deleted file mode 100644
index e772c6e53c239e4480761a18f666e4c25ce04ce4..0000000000000000000000000000000000000000
--- a/gwdatafind/conftest.py
+++ /dev/null
@@ -1,26 +0,0 @@
-# Copyright (C) Cardiff University (2018-2022)
-#
-# This file is part of GWDataFind.
-#
-# GWDataFind is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# GWDataFind is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with GWDataFind.  If not, see <http://www.gnu.org/licenses/>.
-
-"""Pytest hooks for gwdatafind.
-"""
-
-import warnings
-
-__author__ = "Duncan Macleod <duncan.macleod@ligo.org>"
-
-# always present warnings during testing
-warnings.simplefilter("always")
diff --git a/gwdatafind/tests/__init__.py b/gwdatafind/tests/__init__.py
index 326a540735d65b712700a4293bf7ee464b08384a..8675b09e078a29593aa96962c34f69010931f698 100644
--- a/gwdatafind/tests/__init__.py
+++ b/gwdatafind/tests/__init__.py
@@ -15,14 +15,6 @@
 # You should have received a copy of the GNU General Public License
 # along with GWDataFind.  If not, see <http://www.gnu.org/licenses/>.
 
-"""Tests for :mod:`gwdatafind`.
-"""
+"""Test :mod:`gwdatafind`."""
 
 __author__ = "Duncan Macleod <duncan.macleod@ligo.org>"
-
-import pytest
-
-if pytest.__version__ < "3.0":
-    yield_fixture = pytest.yield_fixture
-else:
-    yield_fixture = pytest.fixture
diff --git a/gwdatafind/tests/conftest.py b/gwdatafind/tests/conftest.py
deleted file mode 100644
index fd2e1dd9bf14bd8764491b9701b01b75e314a5f5..0000000000000000000000000000000000000000
--- a/gwdatafind/tests/conftest.py
+++ /dev/null
@@ -1,36 +0,0 @@
-# Copyright (C) Cardiff University (2018-2022)
-#
-# This program is free software; you can redistribute it and/or modify it
-# under the terms of the GNU General Public License as published by the
-# Free Software Foundation; either version 2 of the License, or (at your
-# option) any later version.
-#
-# This program is distributed in the hope that it will be useful, but
-# WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General
-# Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along
-# with this program; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
-
-"""Test utilities.
-"""
-
-import os
-import tempfile
-
-from . import yield_fixture
-
-
-@yield_fixture
-def tmpname():
-    """Return a temporary file name, cleaning up after the method returns.
-    """
-    name = tempfile.mktemp()
-    open(name, "w").close()
-    try:
-        yield name
-    finally:
-        if os.path.isfile(name):
-            os.remove(name)
diff --git a/gwdatafind/tests/test_api.py b/gwdatafind/tests/test_api.py
index fbcad765d3d29291c20437bde31e56edbe9b1820..ee5e825fd4d29cb7fe2db4682ef592571eb76666 100644
--- a/gwdatafind/tests/test_api.py
+++ b/gwdatafind/tests/test_api.py
@@ -23,56 +23,63 @@ from the v1 API for gwdatfind_server.
 
 import pytest
 
-from .. import api
+from gwdatafind import api
 
 __author__ = "Duncan Macleod <duncan.macleod@ligo.org>"
 
 
 def test_ping_path():
+    """Test `ping_path()`."""
     assert api.ping_path() == "LDR/services/data/v1/gwf/H/R/1,2"
 
 
 def test_find_observatories_path():
+    """Test `find_observatories_path()`."""
     assert api.find_observatories_path() == "LDR/services/data/v1/gwf.json"
 
 
-@pytest.mark.parametrize(("site", "result"), (
+@pytest.mark.parametrize(("site", "result"), [
     (None, "LDR/services/data/v1/gwf/all.json"),
     ("X", "LDR/services/data/v1/gwf/X.json"),
     ("XY", "LDR/services/data/v1/gwf/XY.json"),
-))
+])
 def test_find_types_path(site, result):
+    """Test `find_types_path()`."""
     assert api.find_types_path(site) == result
 
 
 def test_find_times_path():
+    """Test `find_times_path()`."""
     assert api.find_times_path("X", "TEST", 0, 1) == (
         "LDR/services/data/v1/gwf/X/TEST/segments/0,1.json"
     )
 
 
 def test_find_url_path():
+    """Test `find_url_path()`."""
     assert api.find_url_path("/data/X-TEST-0-1.gwf") == (
         "LDR/services/data/v1/gwf/X/TEST/X-TEST-0-1.gwf.json"
     )
 
 
-@pytest.mark.parametrize(("urltype", "result"), (
+@pytest.mark.parametrize(("urltype", "result"), [
     (None, "LDR/services/data/v1/gwf/X/TEST/latest.json"),
     ("file", "LDR/services/data/v1/gwf/X/TEST/latest/file.json"),
-))
+])
 def test_find_latest_path(urltype, result):
+    """Test `find_latest_path()`."""
     assert api.find_latest_path("X", "TEST", urltype) == result
 
 
-@pytest.mark.parametrize(("urltype", "match", "result"), (
+@pytest.mark.parametrize(("urltype", "match", "result"), [
     (None, None, "LDR/services/data/v1/gwf/X/TEST/0,1.json"),
     ("gsiftp", None, "LDR/services/data/v1/gwf/X/TEST/0,1/gsiftp.json"),
     (None, "test", "LDR/services/data/v1/gwf/X/TEST/0,1.json?match=test"),
     ("file", "test",
      "LDR/services/data/v1/gwf/X/TEST/0,1/file.json?match=test"),
-))
+])
 def find_urls_path(urltype, match, result):
+    """Test `find_urls_path()`."""
     assert api.find_urls_path(
         "X",
         "TEST",
diff --git a/gwdatafind/tests/test_main.py b/gwdatafind/tests/test_main.py
index 7d2caa132b7c1ab397813be81fb2bac5829e95c3..f6a88d40e822f59139f627f3a1347aa911cd45c5 100644
--- a/gwdatafind/tests/test_main.py
+++ b/gwdatafind/tests/test_main.py
@@ -15,8 +15,7 @@
 # You should have received a copy of the GNU General Public License
 # along with GWDataFind.  If not, see <http://www.gnu.org/licenses/>.
 
-"""Tests for :mod:`gwdatafind.__main__` (the CLI).
-"""
+"""Tests for :mod:`gwdatafind.__main__` (the CLI)."""
 
 import argparse
 import os
@@ -26,7 +25,7 @@ from unittest import mock
 import pytest
 from igwn_segments import segment
 
-from .. import __main__ as main
+from gwdatafind import __main__ as main
 
 __author__ = "Duncan Macleod <duncan.macleod@ligo.org>"
 
@@ -67,6 +66,7 @@ GAPS = [(3, 7)]
 
 @mock.patch.dict(os.environ, {"GWDATAFIND_SERVER": "something"})
 def test_command_line():
+    """Test `command_line()`."""
     parser = main.command_line()
     assert isinstance(parser, argparse.ArgumentParser)
     assert parser.description == main.__doc__
@@ -87,17 +87,19 @@ def test_command_line():
 
 
 @mock.patch.dict("os.environ", clear=True)
-@pytest.mark.parametrize("defserv", (None, "test.datafind.com:443"))
+@pytest.mark.parametrize("defserv", [None, "test.datafind.com:443"])
 def test_command_line_server(defserv):
+    """Test default setting for ``-r/--server``."""
     if defserv:
         os.environ["GWDATAFIND_SERVER"] = defserv
     parser = main.command_line()
-    serveract = [act for act in parser._actions if act.dest == "server"][0]
+    serveract = next(act for act in parser._actions if act.dest == "server")
     assert serveract.required is (not defserv)
 
 
 @mock.patch.dict(os.environ, {"GWDATAFIND_SERVER": "something"})
 def test_sanity_check_pass():
+    """Test `DataFindArgumentParser.sanity_check()`."""
     parser = main.command_line()
     parser.parse_args(["-o", "X", "-t", "test", "-s", "0", "-e", "1"])
 
@@ -110,13 +112,18 @@ def test_sanity_check_pass():
     ("--gaps", "--show-observatories"),
 ])
 def test_sanity_check_fail(clargs):
+    """Test `DataFindArgumentParser.sanity_check()` error reporting."""
     parser = main.command_line()
-    with pytest.raises(SystemExit):
+    with pytest.raises(
+        SystemExit,
+        match="2",
+    ):
         parser.parse_args(clargs)
 
 
 @mock.patch("gwdatafind.ui.ping")
 def test_ping(mping):
+    """Test `ping()."""
     args = argparse.Namespace(
         server="test.datafind.com:443",
         extension="gwf",
@@ -134,6 +141,7 @@ def test_ping(mping):
 
 @mock.patch("gwdatafind.ui.find_observatories")
 def test_show_observatories(mfindobs):
+    """Test `show_observatories()."""
     mfindobs.return_value = ["A", "B", "C"]
     args = argparse.Namespace(
         server="test.datafind.com:443",
@@ -153,6 +161,7 @@ def test_show_observatories(mfindobs):
 
 @mock.patch("gwdatafind.ui.find_types")
 def test_show_types(mfindtypes):
+    """Test `show_types()."""
     mfindtypes.return_value = ["A", "B", "C"]
     args = argparse.Namespace(
         server="test.datafind.com:443",
@@ -174,6 +183,7 @@ def test_show_types(mfindtypes):
 
 @mock.patch("gwdatafind.ui.find_times")
 def test_show_times(mfindtimes):
+    """Test `show_times()."""
     mfindtimes.return_value = [segment(0, 1), segment(1, 2), segment(3, 4)]
     args = argparse.Namespace(
         server="test.datafind.com:443",
@@ -201,6 +211,7 @@ def test_show_times(mfindtimes):
 
 @mock.patch("gwdatafind.ui.find_latest")
 def test_latest(mlatest):
+    """Test `latest()`."""
     mlatest.return_value = ["file:///test/X-test-0-10.gwf"]
     args = argparse.Namespace(
         server="test.datafind.com:443",
@@ -227,6 +238,7 @@ def test_latest(mlatest):
 
 @mock.patch("gwdatafind.ui.find_url")
 def test_filename(mfindurl):
+    """Test `filename()`."""
     mfindurl.return_value = ["file:///test/X-test-0-10.gwf"]
     args = argparse.Namespace(
         server="test.datafind.com:443",
@@ -254,6 +266,7 @@ def test_filename(mfindurl):
     "h5",
 ])
 def test_show_urls(mfindurls, ext):
+    """Test `show_urls()`."""
     urls = [x for x in URLS if x.endswith(f".{ext}")]
     mfindurls.return_value = urls
     args = argparse.Namespace(
@@ -285,13 +298,14 @@ def test_show_urls(mfindurls, ext):
     assert list(map(str.rstrip, out.readlines())) == urls
 
 
-@pytest.mark.parametrize("fmt,result", [
+@pytest.mark.parametrize(("fmt", "result"), [
     ("urls", GWF_OUTPUT_URLS),
     ("lal", GWF_OUTPUT_LAL_CACHE),
     ("names", GWF_OUTPUT_NAMES_ONLY),
     ("omega", GWF_OUTPUT_OMEGA_CACHE),
 ])
 def test_postprocess_cache_format(fmt, result):
+    """Test `postprocess_cache` with ``--format``."""
     # create namespace for parsing
     args = argparse.Namespace(
         type=None,
@@ -309,6 +323,7 @@ def test_postprocess_cache_format(fmt, result):
 
 
 def test_postprocess_cache_sft():
+    """Test `postprocess_cache` for SFTs."""
     args = argparse.Namespace(
         type="TEST_1800SFT",
         format=None,
@@ -321,6 +336,7 @@ def test_postprocess_cache_sft():
 
 
 def test_postprocess_cache_gaps(capsys):
+    """Test `postprocess_cache()`."""
     args = argparse.Namespace(
         gpsstart=0,
         gpsend=10,
@@ -340,7 +356,7 @@ def test_postprocess_cache_gaps(capsys):
 
 
 @mock.patch.dict(os.environ, {"GWDATAFIND_SERVER": "something"})
-@pytest.mark.parametrize("args,patch", [
+@pytest.mark.parametrize(("args", "patch"), [
     (["--ping"], "ping"),
     (["--show-observatories"], "show_observatories"),
     (["--show-types"], "show_types"),
@@ -349,12 +365,15 @@ def test_postprocess_cache_gaps(capsys):
     (["--filename", "X-test-0-1.gwf"], "filename"),
     (["-o", "X", "-t", "test", "-s", "0", "-e", "10"], "show_urls"),
 ])
-def test_main(args, patch, tmpname):
+def test_main(args, patch, tmp_path):
+    """Test `main()`."""
+    outfile = tmp_path / "out"
+
     with mock.patch(f"gwdatafind.__main__.{patch}") as mocked:
         main.main(args)
         assert mocked.call_count == 1
     # call again with output file
-    args.extend(("--output-file", tmpname))
+    args.extend(("--output-file", str(outfile)))
     with mock.patch(f"gwdatafind.__main__.{patch}") as mocked:
         main.main(args)
         assert mocked.call_count == 1
diff --git a/gwdatafind/tests/test_ui.py b/gwdatafind/tests/test_ui.py
index ef1d523f197052bf5ab877ddf8b6924f23c79eaa..c38bab42c09c858ab9bf8cea4565aa5f4a0a8ef9 100644
--- a/gwdatafind/tests/test_ui.py
+++ b/gwdatafind/tests/test_ui.py
@@ -15,18 +15,24 @@
 # You should have received a copy of the GNU General Public License
 # along with GWDataFind.  If not, see <http://www.gnu.org/licenses/>.
 
+"""Test :mod:`gwdatafind.ui`."""
+
 import warnings
+from contextlib import contextmanager
 from functools import partial
+from math import (
+    ceil,
+    floor,
+)
 from unittest import mock
 
 import igwn_segments as segments
 import pytest
 
-from .. import (
+from gwdatafind import (
     api,
     ui,
 )
-from . import yield_fixture
 
 __author__ = "Duncan Macleod <duncan.macleod@ligo.org>"
 
@@ -47,12 +53,22 @@ TEST_DATA = {
 }
 
 
+@contextmanager
+def no_warning():
+    """Context manager to ensure no warnings are emitted."""
+    with warnings.catch_warnings() as ctx:
+        warnings.simplefilter("error")
+        yield ctx
+
+
 def _url(suffix):
+    """Return a fully-qualified URL with the given suffix."""
     return f"{TEST_URL_BASE}/{suffix}"
 
 
-@yield_fixture(autouse=True)
+@pytest.fixture(autouse=True)
 def gwdatafind_server_env():
+    """Patch `os.environ` with our value for ``GWDATAFIND_SERVER``."""
     with mock.patch.dict(
         "os.environ",
         {"GWDATAFIND_SERVER": TEST_SERVER},
@@ -60,45 +76,77 @@ def gwdatafind_server_env():
         yield
 
 
-@yield_fixture(autouse=True, scope="module")
+@pytest.fixture(autouse=True, scope="module")
 def noauth():
     """Force the underlying _get() function to use no authentication.
 
     So that the tests don't fall over if the test runner has bad creds.
     """
-    _get_noauth = partial(ui._get, cert=False, token=False)
-    with mock.patch("gwdatafind.ui._get", _get_noauth):
+    with mock.patch(
+        "gwdatafind.ui._get",
+        partial(ui._get, cert=False, token=False),  # noqa: SLF001
+    ):
         yield
 
 
-@pytest.mark.parametrize(("in_", "url"), (
+@pytest.mark.parametrize(("in_", "url"), [
     # no scheme and no port, default to https
-    ("datafind.example.com", "https://datafind.example.com"),
+    pytest.param(
+        "datafind.example.com",
+        "https://datafind.example.com",
+        id="scheme-default",
+    ),
     # scheme specified, do nothing
-    ("test://datafind.example.com", "test://datafind.example.com"),
-    ("test://datafind.example.com:1234", "test://datafind.example.com:1234"),
-    ("https://datafind.example.com:80", "https://datafind.example.com:80"),
+    pytest.param(
+        "test://datafind.example.com",
+        "test://datafind.example.com",
+        id="scheme-noop",
+    ),
+    pytest.param(
+        "test://datafind.example.com:1234",
+        "test://datafind.example.com:1234",
+        id="port-noop",
+    ),
+    pytest.param(
+        "https://datafind.example.com:80",
+        "https://datafind.example.com:80",
+        id="scheme-port-noop",
+    ),
     # no scheme and port 80, use http
-    ("datafind.example.com:80", "http://datafind.example.com:80"),
+    pytest.param(
+        "datafind.example.com:80",
+        "http://datafind.example.com:80",
+        id="scheme-port-http",
+    ),
     # no scheme and port != 80, use https
-    ("datafind.example.com:443", "https://datafind.example.com:443"),
+    pytest.param(
+        "datafind.example.com:443",
+        "https://datafind.example.com:443",
+        id="scheme-port-https",
+    ),
     # default host
-    (None, TEST_URL_BASE),
-))
+    pytest.param(
+        None,
+        TEST_URL_BASE,
+    ),
+])
 def test_url_scheme_handling(in_, url):
-    assert ui._url(in_, lambda: "test") == f"{url}/test"
+    """Test URL scheme handling in `_url()`."""
+    assert ui._url(in_, lambda: "test") == f"{url}/test"  # noqa: SLF001
 
 
 def test_ping(requests_mock):
+    """Test `ping()`."""
     requests_mock.get(_url(api.ping_path()), status_code=200)
     ui.ping()
 
 
-@pytest.mark.parametrize(("match", "result"), (
-    (None, ("A", "B", "C")),
-    ("[AB]", ("A", "B")),
-))
+@pytest.mark.parametrize(("match", "result"), [
+    pytest.param(None, ("A", "B", "C"), id="all"),
+    pytest.param("[AB]", ("A", "B"), id="regex"),
+])
 def test_find_observatories(match, result, requests_mock):
+    """Test `find_observatories()`."""
     requests_mock.get(
         _url(api.find_observatories_path()),
         json=list(TEST_DATA),
@@ -106,12 +154,28 @@ def test_find_observatories(match, result, requests_mock):
     assert ui.find_observatories(match=match) == list(set(result))
 
 
-@pytest.mark.parametrize(("site", "match", "result"), (
-    (None, None, [ft for site in TEST_DATA for ft in TEST_DATA[site]]),
-    ("A", None, list(TEST_DATA["A"])),
-    ("A", "PROD", ["A1_PROD"]),
-))
+@pytest.mark.parametrize(("site", "match", "result"), [
+    pytest.param(
+        None,
+        None,
+        [ft for site in TEST_DATA for ft in TEST_DATA[site]],
+        id="all",
+    ),
+    pytest.param(
+        "A",
+        None,
+        list(TEST_DATA["A"]),
+        id="site",
+    ),
+    pytest.param(
+        "A",
+        "PROD",
+        ["A1_PROD"],
+        id="site-match",
+    ),
+])
 def test_find_types(site, match, result, requests_mock):
+    """Test `find_types()`."""
     if site:
         respdata = list(TEST_DATA[site])
     else:
@@ -127,6 +191,7 @@ def test_find_types(site, match, result, requests_mock):
 
 
 def test_find_times(requests_mock):
+    """Test `find_times()`."""
     site = "A"
     frametype = "A1_TEST"
     requests_mock.get(
@@ -141,6 +206,7 @@ def test_find_times(requests_mock):
 
 
 def test_find_url(requests_mock):
+    """Test `find_url()`."""
     urls = [
         "file:///data/A/A1_TEST/A-A1_TEST-0-1.gwf",
         "gsiftp://localhost:2811/data/A/A1_TEST/A-A1_TEST-0-1.gwf",
@@ -157,27 +223,45 @@ def test_find_url(requests_mock):
     ) == urls[1:]
 
 
-def test_find_url_on_missing(requests_mock):
+@pytest.mark.parametrize(("on_missing", "ctx"), [
+    # no warnings, no errors
+    pytest.param("ignore", no_warning(), id="ignore"),
+    # a warning about validation
+    pytest.param(
+        "warn",
+        pytest.warns(
+            UserWarning,
+            match="no files found",
+        ),
+        id="warn",
+    ),
+    # an exception about validation
+    pytest.param(
+        "raise",
+        pytest.raises(
+            RuntimeError,
+            match="no files found",
+        ),
+        id="raise",
+    ),
+])
+def test_find_url_on_missing(requests_mock, on_missing, ctx):
+    """Test `find_url` handling of missing data."""
+    # mock the request
     requests_mock.get(
         _url(api.find_url_path("A-A1_TEST-0-1.gwf")),
         json=[],
     )
 
-    # on_missing="ignore"
-    with warnings.catch_warnings():
-        warnings.simplefilter("error")
-        assert ui.find_url("A-A1_TEST-0-1.gwf", on_missing="ignore") == []
-
-    # on_missing="warn"
-    with pytest.warns(UserWarning):
-        assert ui.find_url("A-A1_TEST-0-1.gwf", on_missing="warn") == []
-
-    # on_missing="error"
-    with pytest.raises(RuntimeError):
-        ui.find_url("A-A1_TEST-0-1.gwf", on_missing="error")
+    with ctx:
+        assert ui.find_url(
+            "A-A1_TEST-0-1.gwf",
+            on_missing=on_missing,
+        ) == []
 
 
 def test_find_latest(requests_mock):
+    """Test `find_latest()`."""
     # NOTE: the target function is essentially identical to
     #       find_url, so we just do a minimal smoke test here
     urls = [
@@ -192,10 +276,12 @@ def test_find_latest(requests_mock):
 
 
 def _file_url(seg):
-    return f"file:///data/A/A1_TEST/A-A1_TEST-{seg[0]}-{seg[1]-seg[0]}.gwf"
+    seg = segments.segment(floor(seg[0]), ceil(seg[1]))
+    return f"file:///data/A/A1_TEST/A-A1_TEST-{seg[0]}-{abs(seg)}.gwf"
 
 
 def test_find_urls(requests_mock):
+    """Test `find_urls()`."""
     urls = list(map(_file_url, TEST_DATA["A"]["A1_TEST"][:2]))
     requests_mock.get(
         _url(api.find_urls_path("A", "A1_TEST", 0, 20, "file")),
@@ -204,22 +290,43 @@ def test_find_urls(requests_mock):
     assert ui.find_urls("A", "A1_TEST", 0, 20, on_gaps="error") == urls
 
 
-def test_find_urls_on_gaps(requests_mock):
+@pytest.mark.parametrize(("on_gaps", "ctx"), [
+    # no warnings, no errors
+    pytest.param("ignore", no_warning(), id="ignore"),
+    # a warning about validation
+    pytest.param(
+        "warn",
+        pytest.warns(
+            UserWarning,
+            match="^Missing segments",
+        ),
+        id="warn",
+    ),
+    # an exception about validation
+    pytest.param(
+        "raise",
+        pytest.raises(
+            RuntimeError,
+            match=r"^Missing segments",
+        ),
+        id="raise",
+    ),
+])
+def test_find_urls_on_gaps(requests_mock, on_gaps, ctx):
+    """Test `find_urls` handling of gaps with ``on_gaps``."""
+    # configure the mock request
     urls = list(map(_file_url, TEST_DATA["A"]["A1_TEST"]))
     requests_mock.get(
         _url(api.find_urls_path("A", "A1_TEST", 0, 100, "file")),
         json=urls,
     )
 
-    # on_gaps="ignore"
-    with warnings.catch_warnings():
-        warnings.simplefilter("error")
-        assert ui.find_urls("A", "A1_TEST", 0, 100, on_gaps="ignore") == urls
-
-    # on_missing="warn"
-    with pytest.warns(UserWarning):
-        assert ui.find_urls("A", "A1_TEST", 0, 100, on_gaps="warn") == urls
-
-    # on_missing="error"
-    with pytest.raises(RuntimeError):
-        ui.find_urls("A", "A1_TEST", 0, 100, on_gaps="error")
+    # make the request
+    with ctx:
+        assert ui.find_urls(
+            "A",
+            "A1_TEST",
+            0,
+            100,
+            on_gaps=on_gaps,
+        ) == urls
diff --git a/gwdatafind/tests/test_utils.py b/gwdatafind/tests/test_utils.py
index 7d008bd9fe57202e18bd7106517505f39f4a1d8d..d7a6c4056ed902ff81bb2fb9d1c0e001849521d3 100644
--- a/gwdatafind/tests/test_utils.py
+++ b/gwdatafind/tests/test_utils.py
@@ -16,14 +16,13 @@
 # with this program; if not, write to the Free Software Foundation, Inc.,
 # 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 
-"""Tests for :mod:`gwdatafind.utils`.
-"""
+"""Tests for :mod:`gwdatafind.utils`."""
 
 from unittest import mock
 
 import pytest
 
-from .. import utils
+from gwdatafind import utils
 
 
 @mock.patch.dict(
@@ -34,6 +33,7 @@ from .. import utils
     },
 )
 def test_get_default_host():
+    """Test `get_default_host()` with both environment variables."""
     assert utils.get_default_host() == "gwtest"
 
 
@@ -43,12 +43,17 @@ def test_get_default_host():
     clear=True,
 )
 def test_get_default_host_ligo():
+    """Test `get_default_host()` with ``LIGO_DATAFIND_SERVER`` env only."""
     assert utils.get_default_host() == "ligotest"
 
 
 @mock.patch.dict("os.environ", clear=True)
 def test_get_default_host_error():
-    with pytest.raises(ValueError):
+    """Test `get_default_host()` error handling."""
+    with pytest.raises(
+        ValueError,
+        match="Failed to determine default gwdatafind host",
+    ):
         utils.get_default_host()
 
 
@@ -56,7 +61,8 @@ def test_get_default_host_error():
     "igwn_auth_utils.x509.validate_certificate",
     side_effect=(None, RuntimeError),
 )
-def test_validate_proxy(_):
+def test_validate_proxy(mock_validate):
+    """Test `validate_proxy()`."""
     # check that no error ends up as 'True'
     with pytest.warns(DeprecationWarning):
         assert utils.validate_proxy("something") is True
@@ -69,7 +75,8 @@ def test_validate_proxy(_):
     "igwn_auth_utils.x509.find_credentials",
     side_effect=("cert", ("cert", "key")),
 )
-def test_find_credential(_):
+def test_find_credential(mock_find_credentials):
+    """Test `find_credential()`."""
     # check that if upstream returns a single cert, we still get a tuple
     with pytest.warns(DeprecationWarning):
         assert utils.find_credential() == ("cert", "cert")
diff --git a/pyproject.toml b/pyproject.toml
index 042ed1642873526fba69aff2698341aa5768e192..82f010be538cf28aa969a1d28e3dc0a844d83f09 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -52,7 +52,7 @@ docs = [
   "sphinx-copybutton",
 ]
 test = [
-  "pytest >= 2.8.0",
+  "pytest >= 3.9.3",
   "pytest-cov",
   "requests-mock",
 ]
@@ -86,6 +86,7 @@ precision = 1
 source = ["gwdatafind"]
 
 [tool.pytest.ini_options]
+minversion = "3.9.3"
 addopts = "-r a --color=yes"
 filterwarnings = [
   "error",
@@ -102,6 +103,8 @@ ignore = [
   "PLR0913",  # too many arguments
   "SIM108",  # if-else instead of ternary if
 ]
+# allow 'mock_...' variables to go unused in tests
+dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?)|mock_.*)$"
 
 [tool.ruff.lint.isort]
 combine-as-imports = true
diff --git a/rpm/python-gwdatafind.spec b/rpm/python-gwdatafind.spec
index 83253fd617bda949d75bee2564536da392bb41d6..45a6dc47fec07e4a759dbb6814014bc63119471c 100644
--- a/rpm/python-gwdatafind.spec
+++ b/rpm/python-gwdatafind.spec
@@ -28,7 +28,7 @@ BuildRequires: python3dist(igwn-segments)
 
 # testing dependencies
 BuildRequires: man-db
-BuildRequires: python3dist(pytest) >= 2.8.0
+BuildRequires: python3dist(pytest) >= 3.1.0
 BuildRequires: python3dist(requests-mock)
 
 # -- src.rpm
@@ -100,6 +100,12 @@ console_scripts =
 [build_manpages]
 manpages =
   man/gw_data_find.1:prog=gwdatafind:function=command_line:module=gwdatafind.__main__
+[tool:pytest]
+minversion = 3.1.0
+addopts = -r a
+filterwarnings =
+  error
+  ignore:.*pkg_resources
 SETUP_CFG
 %endif
 %if %{undefined pyproject_wheel}
@@ -136,7 +142,12 @@ export PYTHONPATH="%{buildroot}%{python3_sitelib}"
 %{__python3} -m gwdatafind --help
 %{buildroot}%{_bindir}/gw_data_find --help
 # run test suite
+%if 0%{?rhel} == 0 || 0%{?rhel} >= 9
 %{pytest} --pyargs gwdatafind
+%else
+# pytest < 3.9 (EPEL8) can't handle 'tmp_path' fixture
+%{pytest} --pyargs gwdatafind -k "not test_main["
+%endif
 # test man pages
 env MANPATH="%{buildroot}%{_mandir}" man -P cat gw_data_find