Refactor test.util into mako.testing

Fixes: #349
Change-Id: I202c252a913fb72cc328a6e7f0f33174802487d3
diff --git a/doc/build/unreleased/mako_testing.rst b/doc/build/unreleased/mako_testing.rst
new file mode 100644
index 0000000..13c51c8
--- /dev/null
+++ b/doc/build/unreleased/mako_testing.rst
@@ -0,0 +1,6 @@
+.. change::
+    :tags: changed
+    :tickets: 349
+
+    Refactored test utilities into ``mako.testing`` module. Removed
+    ``unittest.TestCase`` dependency in favor of ``pytest``.
diff --git a/mako/testing/__init__.py b/mako/testing/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/mako/testing/__init__.py
diff --git a/mako/testing/_config.py b/mako/testing/_config.py
new file mode 100644
index 0000000..b469709
--- /dev/null
+++ b/mako/testing/_config.py
@@ -0,0 +1,128 @@
+import configparser
+import dataclasses
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable
+from typing import ClassVar
+from typing import Optional
+from typing import Union
+
+from .helpers import make_path
+
+
+class ConfigError(BaseException):
+    pass
+
+
+class MissingConfig(ConfigError):
+    pass
+
+
+class MissingConfigSection(ConfigError):
+    pass
+
+
+class MissingConfigItem(ConfigError):
+    pass
+
+
+class ConfigValueTypeError(ConfigError):
+    pass
+
+
+class _GetterDispatch:
+    def __init__(self, initialdata, default_getter: Callable):
+        self.default_getter = default_getter
+        self.data = initialdata
+
+    def get_fn_for_type(self, type_):
+        return self.data.get(type_, self.default_getter)
+
+    def get_typed_value(self, type_, name):
+        get_fn = self.get_fn_for_type(type_)
+        return get_fn(name)
+
+
+def _parse_cfg_file(filespec: Union[Path, str]):
+    cfg = configparser.ConfigParser()
+    try:
+        filepath = make_path(filespec, check_exists=True)
+    except FileNotFoundError as e:
+        raise MissingConfig(f"No config file found at {filespec}") from e
+    else:
+        with open(filepath) as f:
+            cfg.read_file(f)
+        return cfg
+
+
+def _build_getter(cfg_obj, cfg_section, method, converter=None):
+    def caller(option, **kwargs):
+        try:
+            rv = getattr(cfg_obj, method)(cfg_section, option, **kwargs)
+        except configparser.NoSectionError as nse:
+            raise MissingConfigSection(
+                f"No config section named {cfg_section}"
+            ) from nse
+        except configparser.NoOptionError as noe:
+            raise MissingConfigItem(f"No config item for {option}") from noe
+        except ValueError as ve:
+            # ConfigParser.getboolean, .getint, .getfloat raise ValueError
+            # on bad types
+            raise ConfigValueTypeError(
+                f"Wrong value type for {option}"
+            ) from ve
+        else:
+            if converter:
+                try:
+                    rv = converter(rv)
+                except Exception as e:
+                    raise ConfigValueTypeError(
+                        f"Wrong value type for {option}"
+                    ) from e
+            return rv
+
+    return caller
+
+
+def _build_getter_dispatch(cfg_obj, cfg_section, converters=None):
+    converters = converters or {}
+
+    default_getter = _build_getter(cfg_obj, cfg_section, "get")
+
+    # support ConfigParser builtins
+    getters = {
+        int: _build_getter(cfg_obj, cfg_section, "getint"),
+        bool: _build_getter(cfg_obj, cfg_section, "getboolean"),
+        float: _build_getter(cfg_obj, cfg_section, "getfloat"),
+        str: default_getter,
+    }
+
+    # use ConfigParser.get and convert value
+    getters.update(
+        {
+            type_: _build_getter(
+                cfg_obj, cfg_section, "get", converter=converter_fn
+            )
+            for type_, converter_fn in converters.items()
+        }
+    )
+
+    return _GetterDispatch(getters, default_getter)
+
+
+@dataclass
+class ReadsCfg:
+    section_header: ClassVar[str]
+    converters: ClassVar[Optional[dict]] = None
+
+    @classmethod
+    def from_cfg_file(cls, filespec: Union[Path, str]):
+        cfg = _parse_cfg_file(filespec)
+        dispatch = _build_getter_dispatch(
+            cfg, cls.section_header, converters=cls.converters
+        )
+        kwargs = {
+            field.name: dispatch.get_typed_value(field.type, field.name)
+            for field in dataclasses.fields(cls)
+        }
+        return cls(**kwargs)
diff --git a/test/util/assertions.py b/mako/testing/assertions.py
similarity index 91%
rename from test/util/assertions.py
rename to mako/testing/assertions.py
index 8be154c..14ea635 100644
--- a/test/util/assertions.py
+++ b/mako/testing/assertions.py
@@ -8,6 +8,21 @@
     assert a == b, msg or "%r != %r" % (a, b)
 
 
+def ne_(a, b, msg=None):
+    """Assert a != b, with repr messaging on failure."""
+    assert a != b, msg or "%r == %r" % (a, b)
+
+
+def in_(a, b, msg=None):
+    """Assert a in b, with repr messaging on failure."""
+    assert a in b, msg or "%r not in %r" % (a, b)
+
+
+def not_in(a, b, msg=None):
+    """Assert a in not b, with repr messaging on failure."""
+    assert a not in b, msg or "%r is in %r" % (a, b)
+
+
 def _assert_proper_exception_context(exception):
     """assert that any exception we're catching does not have a __context__
     without a __cause__, and that __suppress_context__ is never set.
diff --git a/mako/testing/config.py b/mako/testing/config.py
new file mode 100644
index 0000000..b77d0c0
--- /dev/null
+++ b/mako/testing/config.py
@@ -0,0 +1,17 @@
+from dataclasses import dataclass
+from pathlib import Path
+
+from ._config import ReadsCfg
+from .helpers import make_path
+
+
+@dataclass
+class Config(ReadsCfg):
+    module_base: Path
+    template_base: Path
+
+    section_header = "mako_testing"
+    converters = {Path: make_path}
+
+
+config = Config.from_cfg_file("./setup.cfg")
diff --git a/mako/testing/exclusions.py b/mako/testing/exclusions.py
new file mode 100644
index 0000000..37b2d14
--- /dev/null
+++ b/mako/testing/exclusions.py
@@ -0,0 +1,80 @@
+import pytest
+
+from mako.ext.beaker_cache import has_beaker
+from mako.util import update_wrapper
+
+
+try:
+    import babel.messages.extract as babel
+except ImportError:
+    babel = None
+
+
+try:
+    import lingua
+except ImportError:
+    lingua = None
+
+
+try:
+    import dogpile.cache  # noqa
+except ImportError:
+    has_dogpile_cache = False
+else:
+    has_dogpile_cache = True
+
+
+requires_beaker = pytest.mark.skipif(
+    not has_beaker, reason="Beaker is required for these tests."
+)
+
+
+requires_babel = pytest.mark.skipif(
+    babel is None, reason="babel not installed: skipping babelplugin test"
+)
+
+
+requires_lingua = pytest.mark.skipif(
+    lingua is None, reason="lingua not installed: skipping linguaplugin test"
+)
+
+
+requires_dogpile_cache = pytest.mark.skipif(
+    not has_dogpile_cache,
+    reason="dogpile.cache is required to run these tests",
+)
+
+
+def _pygments_version():
+    try:
+        import pygments
+
+        version = pygments.__version__
+    except:
+        version = "0"
+    return version
+
+
+requires_pygments_14 = pytest.mark.skipif(
+    _pygments_version() < "1.4", reason="Requires pygments 1.4 or greater"
+)
+
+
+# def requires_pygments_14(fn):
+
+#     return skip_if(
+#         lambda: version < "1.4", "Requires pygments 1.4 or greater"
+#     )(fn)
+
+
+def requires_no_pygments_exceptions(fn):
+    def go(*arg, **kw):
+        from mako import exceptions
+
+        exceptions._install_fallback()
+        try:
+            return fn(*arg, **kw)
+        finally:
+            exceptions._install_highlighting()
+
+    return update_wrapper(go, fn)
diff --git a/test/util/fixtures.py b/mako/testing/fixtures.py
similarity index 75%
rename from test/util/fixtures.py
rename to mako/testing/fixtures.py
index d4b28db..c9379c0 100644
--- a/test/util/fixtures.py
+++ b/mako/testing/fixtures.py
@@ -1,49 +1,29 @@
 import os
-import unittest
 
 from mako.cache import CacheImpl
 from mako.cache import register_plugin
 from mako.template import Template
 from .assertions import eq_
+from .config import config
 
 
-def _ensure_environment_variable(key, fallback):
-    env_var = os.getenv(key)
-    if env_var is None:
-        return fallback
-    return env_var
-
-
-def _get_module_base():
-    return _ensure_environment_variable(
-        "TEST_MODULE_BASE", os.path.abspath("./test/templates/modules")
-    )
-
-
-def _get_template_base():
-    return _ensure_environment_variable(
-        "TEST_TEMPLATE_BASE", os.path.abspath("./test/templates/")
-    )
-
-
-module_base = _get_module_base()
-template_base = _get_template_base()
-
-
-class TemplateTest(unittest.TestCase):
+class TemplateTest:
     def _file_template(self, filename, **kw):
         filepath = self._file_path(filename)
         return Template(
-            uri=filename, filename=filepath, module_directory=module_base, **kw
+            uri=filename,
+            filename=filepath,
+            module_directory=config.module_base,
+            **kw,
         )
 
     def _file_path(self, filename):
         name, ext = os.path.splitext(filename)
-        py3k_path = os.path.join(template_base, name + "_py3k" + ext)
+        py3k_path = os.path.join(config.template_base, name + "_py3k" + ext)
         if os.path.exists(py3k_path):
             return py3k_path
 
-        return os.path.join(template_base, filename)
+        return os.path.join(config.template_base, filename)
 
     def _do_file_test(
         self,
diff --git a/test/util/helpers.py b/mako/testing/helpers.py
similarity index 73%
rename from test/util/helpers.py
rename to mako/testing/helpers.py
index 86402c8..77cca36 100644
--- a/test/util/helpers.py
+++ b/mako/testing/helpers.py
@@ -1,11 +1,11 @@
 import contextlib
 import pathlib
+from pathlib import Path
 import re
 import time
+from typing import Union
 from unittest import mock
 
-from test.util.fixtures import module_base
-
 
 def flatten_result(result):
     return re.sub(r"[\s\r\n]+", " ", result).strip()
@@ -19,6 +19,19 @@
     ]
 
 
+def make_path(
+    filespec: Union[Path, str],
+    make_absolute: bool = True,
+    check_exists: bool = False,
+) -> Path:
+    path = Path(filespec)
+    if make_absolute:
+        path = path.resolve(strict=check_exists)
+    if check_exists and (not path.exists()):
+        raise FileNotFoundError(f"No file or directory at {filespec}")
+    return path
+
+
 def _unlink_path(path, missing_ok=False):
     # Replicate 3.8+ functionality in 3.7
     cm = contextlib.nullcontext()
@@ -52,9 +65,3 @@
     with mock.patch("mako.codegen.time") as codegen_time:
         codegen_time.time.return_value = rewound
         yield
-
-
-def teardown():
-    import shutil
-
-    shutil.rmtree(module_base, True)
diff --git a/setup.cfg b/setup.cfg
index ab24fd7..8cbe2db 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -42,6 +42,8 @@
     examples*
 
 [options.extras_require]
+testing =
+    pytest
 babel =
     Babel
 lingua =
@@ -73,6 +75,7 @@
 [tool:pytest]
 addopts= --tb native -v -r fxX -W error
 python_files=test/*test_*.py
+python_classes=*Test
 
 [upload]
 sign = 1
@@ -92,3 +95,7 @@
 exclude = .venv,.git,.tox,dist,docs/*,*egg,build
 import-order-style = google
 application-import-names = mako,test
+
+[mako_testing]
+module_base = ./test/templates/modules
+template_base = ./test/templates/
diff --git a/test/ext/test_babelplugin.py b/test/ext/test_babelplugin.py
index be3c37a..de3e461 100644
--- a/test/ext/test_babelplugin.py
+++ b/test/ext/test_babelplugin.py
@@ -1,39 +1,25 @@
 import io
 import os
-import unittest
 
-from ..util.exclusions import skip_if
-from ..util.fixtures import template_base
-from ..util.fixtures import TemplateTest
-
-try:
-    import babel.messages.extract as babel
-    from mako.ext.babelplugin import extract
-
-except ImportError:
-    babel = None
+from mako.ext.babelplugin import extract
+from mako.testing.assertions import eq_
+from mako.testing.config import config
+from mako.testing.exclusions import requires_babel
+from mako.testing.fixtures import TemplateTest
 
 
-def skip():
-    return skip_if(
-        lambda: not babel, "babel not installed: skipping babelplugin test"
-    )
-
-
-class Test_extract(unittest.TestCase):
-    @skip()
+@requires_babel
+class PluginExtractTest:
     def test_parse_python_expression(self):
         input_ = io.BytesIO(b'<p>${_("Message")}</p>')
         messages = list(extract(input_, ["_"], [], {}))
-        self.assertEqual(messages, [(1, "_", ("Message"), [])])
+        eq_(messages, [(1, "_", ("Message"), [])])
 
-    @skip()
     def test_python_gettext_call(self):
         input_ = io.BytesIO(b'<p>${_("Message")}</p>')
         messages = list(extract(input_, ["_"], [], {}))
-        self.assertEqual(messages, [(1, "_", ("Message"), [])])
+        eq_(messages, [(1, "_", ("Message"), [])])
 
-    @skip()
     def test_translator_comment(self):
         input_ = io.BytesIO(
             b"""
@@ -43,7 +29,7 @@
         </p>"""
         )
         messages = list(extract(input_, ["_"], ["TRANSLATORS:"], {}))
-        self.assertEqual(
+        eq_(
             messages,
             [
                 (
@@ -56,65 +42,62 @@
         )
 
 
-class ExtractMakoTestCase(TemplateTest):
-    @skip()
+@requires_babel
+class MakoExtractTest(TemplateTest):
     def test_extract(self):
-        mako_tmpl = open(os.path.join(template_base, "gettext.mako"))
-        self.addCleanup(mako_tmpl.close)
-        messages = list(
-            extract(
-                mako_tmpl,
-                {"_": None, "gettext": None, "ungettext": (1, 2)},
-                ["TRANSLATOR:"],
-                {},
+        with open(
+            os.path.join(config.template_base, "gettext.mako")
+        ) as mako_tmpl:
+            messages = list(
+                extract(
+                    mako_tmpl,
+                    {"_": None, "gettext": None, "ungettext": (1, 2)},
+                    ["TRANSLATOR:"],
+                    {},
+                )
             )
-        )
-        expected = [
-            (1, "_", "Page arg 1", []),
-            (1, "_", "Page arg 2", []),
-            (10, "gettext", "Begin", []),
-            (14, "_", "Hi there!", ["TRANSLATOR: Hi there!"]),
-            (19, "_", "Hello", []),
-            (22, "_", "Welcome", []),
-            (25, "_", "Yo", []),
-            (36, "_", "The", ["TRANSLATOR: Ensure so and", "so, thanks"]),
-            (36, "ungettext", ("bunny", "bunnies", None), []),
-            (41, "_", "Goodbye", ["TRANSLATOR: Good bye"]),
-            (44, "_", "Babel", []),
-            (45, "ungettext", ("hella", "hellas", None), []),
-            (62, "_", "The", ["TRANSLATOR: Ensure so and", "so, thanks"]),
-            (62, "ungettext", ("bunny", "bunnies", None), []),
-            (68, "_", "Goodbye, really!", ["TRANSLATOR: HTML comment"]),
-            (71, "_", "P.S. byebye", []),
-            (77, "_", "Top", []),
-            (83, "_", "foo", []),
-            (83, "_", "hoho", []),
-            (85, "_", "bar", []),
-            (92, "_", "Inside a p tag", ["TRANSLATOR: <p> tag is ok?"]),
-            (95, "_", "Later in a p tag", ["TRANSLATOR: also this"]),
-            (99, "_", "No action at a distance.", []),
-        ]
-        self.assertEqual(expected, messages)
+            expected = [
+                (1, "_", "Page arg 1", []),
+                (1, "_", "Page arg 2", []),
+                (10, "gettext", "Begin", []),
+                (14, "_", "Hi there!", ["TRANSLATOR: Hi there!"]),
+                (19, "_", "Hello", []),
+                (22, "_", "Welcome", []),
+                (25, "_", "Yo", []),
+                (36, "_", "The", ["TRANSLATOR: Ensure so and", "so, thanks"]),
+                (36, "ungettext", ("bunny", "bunnies", None), []),
+                (41, "_", "Goodbye", ["TRANSLATOR: Good bye"]),
+                (44, "_", "Babel", []),
+                (45, "ungettext", ("hella", "hellas", None), []),
+                (62, "_", "The", ["TRANSLATOR: Ensure so and", "so, thanks"]),
+                (62, "ungettext", ("bunny", "bunnies", None), []),
+                (68, "_", "Goodbye, really!", ["TRANSLATOR: HTML comment"]),
+                (71, "_", "P.S. byebye", []),
+                (77, "_", "Top", []),
+                (83, "_", "foo", []),
+                (83, "_", "hoho", []),
+                (85, "_", "bar", []),
+                (92, "_", "Inside a p tag", ["TRANSLATOR: <p> tag is ok?"]),
+                (95, "_", "Later in a p tag", ["TRANSLATOR: also this"]),
+                (99, "_", "No action at a distance.", []),
+            ]
+        eq_(expected, messages)
 
-    @skip()
     def test_extract_utf8(self):
-        mako_tmpl = open(
-            os.path.join(template_base, "gettext_utf8.mako"), "rb"
-        )
-        self.addCleanup(mako_tmpl.close)
-        message = next(
-            extract(mako_tmpl, {"_", None}, [], {"encoding": "utf-8"})
-        )
-        assert message == (1, "_", "K\xf6ln", [])
+        with open(
+            os.path.join(config.template_base, "gettext_utf8.mako"), "rb"
+        ) as mako_tmpl:
+            message = next(
+                extract(mako_tmpl, {"_", None}, [], {"encoding": "utf-8"})
+            )
+            assert message == (1, "_", "K\xf6ln", [])
 
-    @skip()
     def test_extract_cp1251(self):
-        mako_tmpl = open(
-            os.path.join(template_base, "gettext_cp1251.mako"), "rb"
-        )
-        self.addCleanup(mako_tmpl.close)
-        message = next(
-            extract(mako_tmpl, {"_", None}, [], {"encoding": "cp1251"})
-        )
-        # "test" in Rusian. File encoding is cp1251 (aka "windows-1251")
-        assert message == (1, "_", "\u0442\u0435\u0441\u0442", [])
+        with open(
+            os.path.join(config.template_base, "gettext_cp1251.mako"), "rb"
+        ) as mako_tmpl:
+            message = next(
+                extract(mako_tmpl, {"_", None}, [], {"encoding": "cp1251"})
+            )
+            # "test" in Rusian. File encoding is cp1251 (aka "windows-1251")
+            assert message == (1, "_", "\u0442\u0435\u0441\u0442", [])
diff --git a/test/ext/test_linguaplugin.py b/test/ext/test_linguaplugin.py
index fa5f76d..ae24f67 100644
--- a/test/ext/test_linguaplugin.py
+++ b/test/ext/test_linguaplugin.py
@@ -1,17 +1,12 @@
 import os
 
-from ..util.exclusions import skip_if
-from ..util.fixtures import template_base
-from ..util.fixtures import TemplateTest
+import pytest
 
-try:
-    import lingua
-except:
-    lingua = None
-
-if lingua is not None:
-    from mako.ext.linguaplugin import LinguaMakoExtractor
-    from lingua.extractors import register_extractors
+from mako.ext.linguaplugin import LinguaMakoExtractor
+from mako.testing.assertions import eq_
+from mako.testing.config import config
+from mako.testing.exclusions import requires_lingua
+from mako.testing.fixtures import TemplateTest
 
 
 class MockOptions:
@@ -20,22 +15,24 @@
     comment_tag = True
 
 
-def skip():
-    return skip_if(
-        lambda: not lingua, "lingua not installed: skipping linguaplugin test"
-    )
+@requires_lingua
+class MakoExtractTest(TemplateTest):
+    @pytest.fixture(autouse=True)
+    def register_lingua_extractors(self):
+        from lingua.extractors import register_extractors
 
-
-class ExtractMakoTestCase(TemplateTest):
-    @skip()
-    def test_extract(self):
         register_extractors()
+
+    def test_extract(self):
         plugin = LinguaMakoExtractor({"comment-tags": "TRANSLATOR"})
         messages = list(
-            plugin(os.path.join(template_base, "gettext.mako"), MockOptions())
+            plugin(
+                os.path.join(config.template_base, "gettext.mako"),
+                MockOptions(),
+            )
         )
         msgids = [(m.msgid, m.msgid_plural) for m in messages]
-        self.assertEqual(
+        eq_(
             msgids,
             [
                 ("Page arg 1", None),
diff --git a/test/templates/foo/modtest.html.py b/test/templates/foo/modtest.html.py
index 7a73e55..c35420f 100644
--- a/test/templates/foo/modtest.html.py
+++ b/test/templates/foo/modtest.html.py
@@ -1,4 +1,5 @@
-from mako import runtime, filters, cache
+from mako import cache
+from mako import runtime
 
 UNDEFINED = runtime.UNDEFINED
 __M_dict_builtin = dict
diff --git a/test/templates/subdir/foo/modtest.html.py b/test/templates/subdir/foo/modtest.html.py
index 8b3a73b..9df72e0 100644
--- a/test/templates/subdir/foo/modtest.html.py
+++ b/test/templates/subdir/foo/modtest.html.py
@@ -1,4 +1,5 @@
-from mako import runtime, filters, cache
+from mako import cache
+from mako import runtime
 
 UNDEFINED = runtime.UNDEFINED
 __M_dict_builtin = dict
diff --git a/test/test_ast.py b/test/test_ast.py
index 29a8a36..6b3a3e2 100644
--- a/test/test_ast.py
+++ b/test/test_ast.py
@@ -1,14 +1,13 @@
-import unittest
-
 from mako import ast
 from mako import exceptions
 from mako import pyparser
-from .util.assertions import eq_
+from mako.testing.assertions import assert_raises
+from mako.testing.assertions import eq_
 
 exception_kwargs = {"source": "", "lineno": 0, "pos": 0, "filename": ""}
 
 
-class AstParseTest(unittest.TestCase):
+class AstParseTest:
     def test_locate_identifiers(self):
         """test the location of identifiers in a python code string"""
         code = """
@@ -228,7 +227,7 @@
 from foo import *
 import x as bar
 """
-        self.assertRaises(
+        assert_raises(
             exceptions.CompileException,
             ast.PythonCode,
             code,
diff --git a/test/test_block.py b/test/test_block.py
index a55ca89..be2fbf7 100644
--- a/test/test_block.py
+++ b/test/test_block.py
@@ -1,9 +1,9 @@
 from mako import exceptions
 from mako.lookup import TemplateLookup
 from mako.template import Template
-from .util.assertions import assert_raises_message
-from .util.fixtures import TemplateTest
-from .util.helpers import result_lines
+from mako.testing.assertions import assert_raises_message
+from mako.testing.fixtures import TemplateTest
+from mako.testing.helpers import result_lines
 
 
 class BlockTest(TemplateTest):
diff --git a/test/test_cache.py b/test/test_cache.py
index 60785f2..dd415d4 100644
--- a/test/test_cache.py
+++ b/test/test_cache.py
@@ -1,19 +1,18 @@
 import time
-import unittest
 
 from mako import lookup
 from mako.cache import CacheImpl
 from mako.cache import register_plugin
-from mako.ext import beaker_cache
 from mako.lookup import TemplateLookup
 from mako.template import Template
-from .util.assertions import eq_
-from .util.fixtures import module_base
-from .util.fixtures import TemplateTest
-from .util.helpers import result_lines
+from mako.testing.assertions import eq_
+from mako.testing.config import config
+from mako.testing.exclusions import requires_beaker
+from mako.testing.exclusions import requires_dogpile_cache
+from mako.testing.helpers import result_lines
 
-if beaker_cache.has_beaker:
-    import beaker
+
+module_base = str(config.module_base)
 
 
 class SimpleBackend:
@@ -32,9 +31,9 @@
     def get_or_create(self, key, creation_function, **kw):
         if key in self.cache:
             return self.cache[key]
-        else:
-            self.cache[key] = value = creation_function()
-            return value
+
+        self.cache[key] = value = creation_function()
+        return value
 
 
 class MockCacheImpl(CacheImpl):
@@ -80,7 +79,7 @@
 register_plugin("mock", __name__, "MockCacheImpl")
 
 
-class CacheTest(TemplateTest):
+class CacheTest:
 
     real_backend = "simple"
 
@@ -600,7 +599,7 @@
         assert m.kwargs["context"].get("x") == "bar"
 
 
-class RealBackendTest:
+class RealBackendMixin:
     def test_cache_uses_current_context(self):
         t = Template(
             """
@@ -643,19 +642,17 @@
         eq_(r3, ["short term 6", "long term 5", "none 7"])
 
 
-class BeakerCacheTest(RealBackendTest, CacheTest):
+@requires_beaker
+class BeakerCacheTest(RealBackendMixin, CacheTest):
     real_backend = "beaker"
 
-    def setUp(self):
-        if not beaker_cache.has_beaker:
-            raise unittest.SkipTest("Beaker is required for these tests.")
-
     def _install_mock_cache(self, template, implname=None):
         template.cache_args["manager"] = self._regions()
-        impl = super()._install_mock_cache(template, implname)
-        return impl
+        return super()._install_mock_cache(template, implname)
 
     def _regions(self):
+        import beaker
+
         return beaker.cache.CacheManager(
             cache_regions={
                 "short": {"expire": 1, "type": "memory"},
@@ -664,22 +661,14 @@
         )
 
 
-class DogpileCacheTest(RealBackendTest, CacheTest):
+@requires_dogpile_cache
+class DogpileCacheTest(RealBackendMixin, CacheTest):
     real_backend = "dogpile.cache"
 
-    def setUp(self):
-        try:
-            import dogpile.cache  # noqa
-        except ImportError:
-            raise unittest.SkipTest(
-                "dogpile.cache is required to run these tests"
-            )
-
     def _install_mock_cache(self, template, implname=None):
         template.cache_args["regions"] = self._regions()
         template.cache_args.setdefault("region", "short")
-        impl = super()._install_mock_cache(template, implname)
-        return impl
+        return super()._install_mock_cache(template, implname)
 
     def _regions(self):
         from dogpile.cache import make_region
diff --git a/test/test_call.py b/test/test_call.py
index 82d48da..4dea2b3 100644
--- a/test/test_call.py
+++ b/test/test_call.py
@@ -1,8 +1,8 @@
 from mako.template import Template
-from .util.assertions import eq_
-from .util.fixtures import TemplateTest
-from .util.helpers import flatten_result
-from .util.helpers import result_lines
+from mako.testing.assertions import eq_
+from mako.testing.fixtures import TemplateTest
+from mako.testing.helpers import flatten_result
+from mako.testing.helpers import result_lines
 
 
 class CallTest(TemplateTest):
diff --git a/test/test_cmd.py b/test/test_cmd.py
index 2347452..785c652 100644
--- a/test/test_cmd.py
+++ b/test/test_cmd.py
@@ -3,11 +3,11 @@
 from unittest import mock
 
 from mako.cmd import cmdline
-from .util.assertions import eq_
-from .util.assertions import expect_raises
-from .util.assertions import expect_raises_message
-from .util.fixtures import template_base
-from .util.fixtures import TemplateTest
+from mako.testing.assertions import eq_
+from mako.testing.assertions import expect_raises
+from mako.testing.assertions import expect_raises_message
+from mako.testing.config import config
+from mako.testing.fixtures import TemplateTest
 
 
 class CmdTest(TemplateTest):
@@ -53,7 +53,11 @@
     def test_file_success(self):
         with self._capture_output_fixture() as stdout:
             cmdline(
-                ["--var", "x=5", os.path.join(template_base, "cmd_good.mako")]
+                [
+                    "--var",
+                    "x=5",
+                    os.path.join(config.template_base, "cmd_good.mako"),
+                ]
             )
 
         eq_(stdout.write.mock_calls[0][1][0], "hello world 5")
@@ -65,7 +69,7 @@
                     [
                         "--var",
                         "x=5",
-                        os.path.join(template_base, "cmd_syntax.mako"),
+                        os.path.join(config.template_base, "cmd_syntax.mako"),
                     ]
                 )
 
@@ -79,7 +83,7 @@
                     [
                         "--var",
                         "x=5",
-                        os.path.join(template_base, "cmd_runtime.mako"),
+                        os.path.join(config.template_base, "cmd_runtime.mako"),
                     ]
                 )
 
diff --git a/test/test_decorators.py b/test/test_decorators.py
index 6153371..68ea903 100644
--- a/test/test_decorators.py
+++ b/test/test_decorators.py
@@ -1,10 +1,8 @@
-import unittest
-
 from mako.template import Template
-from .util.helpers import flatten_result
+from mako.testing.helpers import flatten_result
 
 
-class DecoratorTest(unittest.TestCase):
+class DecoratorTest:
     def test_toplevel(self):
         template = Template(
             """
diff --git a/test/test_def.py b/test/test_def.py
index 5f99192..fd96433 100644
--- a/test/test_def.py
+++ b/test/test_def.py
@@ -1,10 +1,10 @@
 from mako import lookup
 from mako.template import Template
-from .util.assertions import assert_raises
-from .util.assertions import eq_
-from .util.fixtures import TemplateTest
-from .util.helpers import flatten_result
-from .util.helpers import result_lines
+from mako.testing.assertions import assert_raises
+from mako.testing.assertions import eq_
+from mako.testing.fixtures import TemplateTest
+from mako.testing.helpers import flatten_result
+from mako.testing.helpers import result_lines
 
 
 class DefTest(TemplateTest):
diff --git a/test/test_exceptions.py b/test/test_exceptions.py
index a2b8cf9..b1930c5 100644
--- a/test/test_exceptions.py
+++ b/test/test_exceptions.py
@@ -3,10 +3,10 @@
 from mako import exceptions
 from mako.lookup import TemplateLookup
 from mako.template import Template
-from .util.exclusions import requires_no_pygments_exceptions
-from .util.exclusions import requires_pygments_14
-from .util.fixtures import TemplateTest
-from .util.helpers import result_lines
+from mako.testing.exclusions import requires_no_pygments_exceptions
+from mako.testing.exclusions import requires_pygments_14
+from mako.testing.fixtures import TemplateTest
+from mako.testing.helpers import result_lines
 
 
 class ExceptionsTest(TemplateTest):
diff --git a/test/test_filters.py b/test/test_filters.py
index b3b4753..726f5d7 100644
--- a/test/test_filters.py
+++ b/test/test_filters.py
@@ -1,10 +1,8 @@
-import unittest
-
 from mako.template import Template
-from .util.assertions import eq_
-from .util.fixtures import TemplateTest
-from .util.helpers import flatten_result
-from .util.helpers import result_lines
+from mako.testing.assertions import eq_
+from mako.testing.fixtures import TemplateTest
+from mako.testing.helpers import flatten_result
+from mako.testing.helpers import result_lines
 
 
 class FilterTest(TemplateTest):
@@ -363,7 +361,7 @@
         )
 
 
-class BufferTest(unittest.TestCase):
+class BufferTest:
     def test_buffered_def(self):
         t = Template(
             """
diff --git a/test/test_inheritance.py b/test/test_inheritance.py
index 7824241..15bd54b 100644
--- a/test/test_inheritance.py
+++ b/test/test_inheritance.py
@@ -1,10 +1,8 @@
-import unittest
-
 from mako import lookup
-from .util.helpers import result_lines
+from mako.testing.helpers import result_lines
 
 
-class InheritanceTest(unittest.TestCase):
+class InheritanceTest:
     def test_basic(self):
         collection = lookup.TemplateLookup()
 
diff --git a/test/test_lexer.py b/test/test_lexer.py
index 43b1e7a..255c128 100644
--- a/test/test_lexer.py
+++ b/test/test_lexer.py
@@ -6,10 +6,11 @@
 from mako import util
 from mako.lexer import Lexer
 from mako.template import Template
-from .util.assertions import assert_raises_message
-from .util.assertions import eq_
-from .util.fixtures import TemplateTest
-from .util.helpers import flatten_result
+from mako.testing.assertions import assert_raises
+from mako.testing.assertions import assert_raises_message
+from mako.testing.assertions import eq_
+from mako.testing.fixtures import TemplateTest
+from mako.testing.helpers import flatten_result
 
 # create fake parsetree classes which are constructed
 # exactly as the repr() of a real parsetree object.
@@ -137,13 +138,13 @@
 
             hi.
         """
-        self.assertRaises(exceptions.SyntaxException, Lexer(template).parse)
+        assert_raises(exceptions.SyntaxException, Lexer(template).parse)
 
     def test_noexpr_allowed(self):
         template = """
             <%namespace name="${foo}"/>
         """
-        self.assertRaises(exceptions.CompileException, Lexer(template).parse)
+        assert_raises(exceptions.CompileException, Lexer(template).parse)
 
     def test_unmatched_tag(self):
         template = """
@@ -156,13 +157,13 @@
 
         hi.
 """
-        self.assertRaises(exceptions.SyntaxException, Lexer(template).parse)
+        assert_raises(exceptions.SyntaxException, Lexer(template).parse)
 
     def test_nonexistent_tag(self):
         template = """
             <%lala x="5"/>
         """
-        self.assertRaises(exceptions.CompileException, Lexer(template).parse)
+        assert_raises(exceptions.CompileException, Lexer(template).parse)
 
     def test_wrongcase_tag(self):
         template = """
@@ -170,7 +171,7 @@
             </%def>
 
         """
-        self.assertRaises(exceptions.CompileException, Lexer(template).parse)
+        assert_raises(exceptions.CompileException, Lexer(template).parse)
 
     def test_percent_escape(self):
         template = """
@@ -273,7 +274,7 @@
             hi
         </%def>
 """
-        self.assertRaises(exceptions.CompileException, Lexer(template).parse)
+        assert_raises(exceptions.CompileException, Lexer(template).parse)
 
     def test_def_syntax_2(self):
         template = """
@@ -281,7 +282,7 @@
             hi
         </%def>
     """
-        self.assertRaises(exceptions.CompileException, Lexer(template).parse)
+        assert_raises(exceptions.CompileException, Lexer(template).parse)
 
     def test_whitespace_equals(self):
         template = """
diff --git a/test/test_lookup.py b/test/test_lookup.py
index eebb97b..6a797d7 100644
--- a/test/test_lookup.py
+++ b/test/test_lookup.py
@@ -1,24 +1,23 @@
 import os
 import tempfile
-import unittest
 
 from mako import exceptions
 from mako import lookup
 from mako import runtime
 from mako.template import Template
+from mako.testing.assertions import assert_raises_message
+from mako.testing.assertions import assert_raises_with_given_cause
+from mako.testing.config import config
+from mako.testing.helpers import file_with_template_code
+from mako.testing.helpers import replace_file_with_dir
+from mako.testing.helpers import result_lines
+from mako.testing.helpers import rewind_compile_time
 from mako.util import FastEncodingBuffer
-from .util.assertions import assert_raises_message
-from .util.assertions import assert_raises_with_given_cause
-from .util.fixtures import template_base
-from .util.helpers import file_with_template_code
-from .util.helpers import replace_file_with_dir
-from .util.helpers import result_lines
-from .util.helpers import rewind_compile_time
 
-tl = lookup.TemplateLookup(directories=[template_base])
+tl = lookup.TemplateLookup(directories=[config.template_base])
 
 
-class LookupTest(unittest.TestCase):
+class LookupTest:
     def test_basic(self):
         t = tl.get_template("index.html")
         assert result_lines(t.render()) == ["this is index"]
@@ -98,7 +97,7 @@
         """test the mechanics of an include where
         the include goes outside of the path"""
         tl = lookup.TemplateLookup(
-            directories=[os.path.join(template_base, "subdir")]
+            directories=[os.path.join(config.template_base, "subdir")]
         )
         index = tl.get_template("index.html")
 
diff --git a/test/test_loop.py b/test/test_loop.py
index be7d440..19d40b5 100644
--- a/test/test_loop.py
+++ b/test/test_loop.py
@@ -1,5 +1,4 @@
 import re
-import unittest
 
 from mako import exceptions
 from mako.codegen import _FOR_LOOP
@@ -7,12 +6,12 @@
 from mako.runtime import LoopContext
 from mako.runtime import LoopStack
 from mako.template import Template
-from .util.assertions import assert_raises_message
-from .util.fixtures import TemplateTest
-from .util.helpers import flatten_result
+from mako.testing.assertions import assert_raises_message
+from mako.testing.fixtures import TemplateTest
+from mako.testing.helpers import flatten_result
 
 
-class TestLoop(unittest.TestCase):
+class TestLoop:
     def test__FOR_LOOP(self):
         for statement, target_list, expression_list in (
             ("for x in y:", "x", "y"),
@@ -137,7 +136,7 @@
         )
 
 
-class TestLoopStack(unittest.TestCase):
+class TestLoopStack:
     def setUp(self):
         self.stack = LoopStack()
         self.bottom = "spam"
@@ -180,7 +179,7 @@
         assert before == (after + 1), "Exiting a context pops the stack"
 
 
-class TestLoopContext(unittest.TestCase):
+class TestLoopContext:
     def setUp(self):
         self.iterable = [1, 2, 3]
         self.ctx = LoopContext(self.iterable)
diff --git a/test/test_lru.py b/test/test_lru.py
index 7281537..f54bd15 100644
--- a/test/test_lru.py
+++ b/test/test_lru.py
@@ -1,5 +1,3 @@
-import unittest
-
 from mako.util import LRUCache
 
 
@@ -11,7 +9,7 @@
         return "item id %d" % self.id
 
 
-class LRUTest(unittest.TestCase):
+class LRUTest:
     def testlru(self):
         l = LRUCache(10, threshold=0.2)
 
diff --git a/test/test_namespace.py b/test/test_namespace.py
index bdd1641..b6b0544 100644
--- a/test/test_namespace.py
+++ b/test/test_namespace.py
@@ -1,11 +1,12 @@
 from mako import exceptions
 from mako import lookup
 from mako.template import Template
-from .util.assertions import assert_raises_message_with_given_cause
-from .util.assertions import eq_
-from .util.fixtures import TemplateTest
-from .util.helpers import flatten_result
-from .util.helpers import result_lines
+from mako.testing.assertions import assert_raises
+from mako.testing.assertions import assert_raises_message_with_given_cause
+from mako.testing.assertions import eq_
+from mako.testing.fixtures import TemplateTest
+from mako.testing.helpers import flatten_result
+from mako.testing.helpers import result_lines
 
 
 class NamespaceTest(TemplateTest):
@@ -586,7 +587,7 @@
         """,
         )
 
-        self.assertRaises(AttributeError, l.get_template("bar.html").render)
+        assert_raises(AttributeError, l.get_template("bar.html").render)
 
     def test_custom_tag_1(self):
         template = Template(
diff --git a/test/test_pygen.py b/test/test_pygen.py
index 5200e3e..8adc142 100644
--- a/test/test_pygen.py
+++ b/test/test_pygen.py
@@ -1,12 +1,11 @@
 from io import StringIO
-import unittest
 
 from mako.pygen import adjust_whitespace
 from mako.pygen import PythonPrinter
-from .util.assertions import eq_
+from mako.testing.assertions import eq_
 
 
-class GeneratePythonTest(unittest.TestCase):
+class GeneratePythonTest:
     def test_generate_normal(self):
         stream = StringIO()
         printer = PythonPrinter(stream)
@@ -165,7 +164,7 @@
         )
 
 
-class WhitespaceTest(unittest.TestCase):
+class WhitespaceTest:
     def test_basic(self):
         text = """
         for x in range(0,15):
diff --git a/test/test_runtime.py b/test/test_runtime.py
index 07802f9..0d6fce3 100644
--- a/test/test_runtime.py
+++ b/test/test_runtime.py
@@ -1,12 +1,10 @@
 """Assorted runtime unit tests
 """
-import unittest
-
 from mako import runtime
-from .util.assertions import eq_
+from mako.testing.assertions import eq_
 
 
-class ContextTest(unittest.TestCase):
+class ContextTest:
     def test_locals_kwargs(self):
         c = runtime.Context(None, foo="bar")
         eq_(c.kwargs, {"foo": "bar"})
diff --git a/test/test_template.py b/test/test_template.py
index 238b8c6..fc1aeca 100644
--- a/test/test_template.py
+++ b/test/test_template.py
@@ -1,5 +1,4 @@
 import os
-import unittest
 
 from mako import exceptions
 from mako import runtime
@@ -9,14 +8,13 @@
 from mako.template import ModuleInfo
 from mako.template import ModuleTemplate
 from mako.template import Template
-from .util.assertions import assert_raises
-from .util.assertions import assert_raises_message
-from .util.assertions import eq_
-from .util.fixtures import module_base
-from .util.fixtures import template_base
-from .util.fixtures import TemplateTest
-from .util.helpers import flatten_result
-from .util.helpers import result_lines
+from mako.testing.assertions import assert_raises
+from mako.testing.assertions import assert_raises_message
+from mako.testing.assertions import eq_
+from mako.testing.config import config
+from mako.testing.fixtures import TemplateTest
+from mako.testing.helpers import flatten_result
+from mako.testing.helpers import result_lines
 
 
 class ctx:
@@ -136,7 +134,7 @@
 
     def test_unicode_file_lookup(self):
         lookup = TemplateLookup(
-            directories=[template_base],
+            directories=[config.template_base],
             output_encoding="utf-8",
             default_filters=["decode.utf8"],
         )
@@ -165,11 +163,11 @@
             ),
         )
 
-        self.assertRaises(
+        assert_raises(
             exceptions.CompileException,
             Template,
             filename=self._file_path("badbom.html"),
-            module_directory=module_base,
+            module_directory=config.module_base,
         )
 
     def test_unicode_memory(self):
@@ -399,7 +397,7 @@
 
     def test_read_unicode(self):
         lookup = TemplateLookup(
-            directories=[template_base],
+            directories=[config.template_base],
             filesystem_checks=True,
             output_encoding="utf-8",
         )
@@ -1228,40 +1226,47 @@
 
 
 class ModuleDirTest(TemplateTest):
-    def tearDown(self):
+    def teardown_method(self):
         import shutil
 
-        shutil.rmtree(module_base, True)
+        shutil.rmtree(config.module_base, True)
 
     def test_basic(self):
         t = self._file_template("modtest.html")
         t2 = self._file_template("subdir/modtest.html")
 
-        eq_(t.module.__file__, os.path.join(module_base, "modtest.html.py"))
+        eq_(
+            t.module.__file__,
+            os.path.join(config.module_base, "modtest.html.py"),
+        )
         eq_(
             t2.module.__file__,
-            os.path.join(module_base, "subdir", "modtest.html.py"),
+            os.path.join(config.module_base, "subdir", "modtest.html.py"),
         )
 
     def test_callable(self):
         def get_modname(filename, uri):
             return os.path.join(
-                module_base,
+                config.module_base,
                 os.path.dirname(uri)[1:],
                 "foo",
                 os.path.basename(filename) + ".py",
             )
 
-        lookup = TemplateLookup(template_base, modulename_callable=get_modname)
+        lookup = TemplateLookup(
+            config.template_base, modulename_callable=get_modname
+        )
         t = lookup.get_template("/modtest.html")
         t2 = lookup.get_template("/subdir/modtest.html")
         eq_(
             t.module.__file__,
-            os.path.join(module_base, "foo", "modtest.html.py"),
+            os.path.join(config.module_base, "foo", "modtest.html.py"),
         )
         eq_(
             t2.module.__file__,
-            os.path.join(module_base, "subdir", "foo", "modtest.html.py"),
+            os.path.join(
+                config.module_base, "subdir", "foo", "modtest.html.py"
+            ),
         )
 
     def test_custom_writer(self):
@@ -1274,17 +1279,17 @@
             f.close()
 
         lookup = TemplateLookup(
-            template_base,
+            config.template_base,
             module_writer=write_module,
-            module_directory=module_base,
+            module_directory=config.module_base,
         )
         lookup.get_template("/modtest.html")
         lookup.get_template("/subdir/modtest.html")
         eq_(
             canary,
             [
-                os.path.join(module_base, "modtest.html.py"),
-                os.path.join(module_base, "subdir", "modtest.html.py"),
+                os.path.join(config.module_base, "modtest.html.py"),
+                os.path.join(config.module_base, "subdir", "modtest.html.py"),
             ],
         )
 
@@ -1438,7 +1443,7 @@
         ]
 
 
-class TestTemplateAPI(unittest.TestCase):
+class TestTemplateAPI:
     def test_metadata(self):
         t = Template(
             """
diff --git a/test/test_tgplugin.py b/test/test_tgplugin.py
index 9d5799b..38998c2 100644
--- a/test/test_tgplugin.py
+++ b/test/test_tgplugin.py
@@ -1,9 +1,11 @@
 from mako.ext.turbogears import TGPlugin
-from .util.fixtures import template_base
-from .util.fixtures import TemplateTest
-from .util.helpers import result_lines
+from mako.testing.config import config
+from mako.testing.fixtures import TemplateTest
+from mako.testing.helpers import result_lines
 
-tl = TGPlugin(options=dict(directories=[template_base]), extension="html")
+tl = TGPlugin(
+    options=dict(directories=[config.template_base]), extension="html"
+)
 
 
 class TestTGPlugin(TemplateTest):
diff --git a/test/test_util.py b/test/test_util.py
index 6aa4c47..95c1cb4 100644
--- a/test/test_util.py
+++ b/test/test_util.py
@@ -1,16 +1,19 @@
 import os
 import sys
-import unittest
+
+import pytest
 
 from mako import compat
 from mako import exceptions
 from mako import util
-from .util.assertions import assert_raises_message
-from .util.assertions import eq_
-from .util.exclusions import skip_if
+from mako.testing.assertions import assert_raises_message
+from mako.testing.assertions import eq_
+from mako.testing.assertions import in_
+from mako.testing.assertions import ne_
+from mako.testing.assertions import not_in
 
 
-class UtilTest(unittest.TestCase):
+class UtilTest:
     def test_fast_buffer_write(self):
         buf = util.FastEncodingBuffer()
         buf.write("string a ")
@@ -38,16 +41,16 @@
         data = util.read_file(fn, "rb")
         assert b"test_util" in data
 
-    @skip_if(lambda: compat.pypy, "Pypy does this differently")
+    @pytest.mark.skipif(compat.pypy, reason="Pypy does this differently")
     def test_load_module(self):
         path = os.path.join(os.path.dirname(__file__), "module_to_import.py")
         some_module = compat.load_module("test.module_to_import", path)
 
-        self.assertNotIn("test.module_to_import", sys.modules)
-        self.assertIn("some_function", dir(some_module))
+        not_in("test.module_to_import", sys.modules)
+        in_("some_function", dir(some_module))
         import test.module_to_import
 
-        self.assertNotEqual(some_module, test.module_to_import)
+        ne_(some_module, test.module_to_import)
 
     def test_load_plugin_failure(self):
         loader = util.PluginLoader("fakegroup")
diff --git a/test/testing/dummy.cfg b/test/testing/dummy.cfg
new file mode 100644
index 0000000..39644a3
--- /dev/null
+++ b/test/testing/dummy.cfg
@@ -0,0 +1,25 @@
+[boolean_values]
+yes = yes
+one = 1
+true = true
+on = on
+no = no
+zero = 0
+false = false
+off = off
+
+[additional_types]
+decimal_value = 100001.01
+datetime_value = 2021-12-04 00:05:23.283
+
+[type_mismatch]
+int_value = foo
+
+[missing_item]
+present_item = HERE
+
+[basic_values]
+int_value = 15421
+bool_value = true
+float_value = 14.01
+str_value = Ceci n'est pas une chaîne
diff --git a/test/testing/test_config.py b/test/testing/test_config.py
new file mode 100644
index 0000000..680d7a4
--- /dev/null
+++ b/test/testing/test_config.py
@@ -0,0 +1,176 @@
+import configparser
+from dataclasses import dataclass
+from datetime import datetime
+from decimal import Decimal
+from pathlib import Path
+
+import pytest
+
+from mako.testing._config import ConfigValueTypeError
+from mako.testing._config import MissingConfig
+from mako.testing._config import MissingConfigItem
+from mako.testing._config import MissingConfigSection
+from mako.testing._config import ReadsCfg
+from mako.testing.assertions import assert_raises_message_with_given_cause
+from mako.testing.assertions import assert_raises_with_given_cause
+
+PATH_TO_TEST_CONFIG = Path(__file__).parent / "dummy.cfg"
+
+
+@dataclass
+class BasicConfig(ReadsCfg):
+    int_value: int
+    bool_value: bool
+    float_value: float
+    str_value: str
+
+    section_header = "basic_values"
+
+
+@dataclass
+class BooleanConfig(ReadsCfg):
+    yes: bool
+    one: bool
+    true: bool
+    on: bool
+    no: bool
+    zero: bool
+    false: bool
+    off: bool
+
+    section_header = "boolean_values"
+
+
+@dataclass
+class UnsupportedTypesConfig(ReadsCfg):
+    decimal_value: Decimal
+    datetime_value: datetime
+
+    section_header = "additional_types"
+
+
+@dataclass
+class SupportedTypesConfig(ReadsCfg):
+    decimal_value: Decimal
+    datetime_value: datetime
+
+    section_header = "additional_types"
+    converters = {
+        Decimal: lambda v: Decimal(str(v)),
+        datetime: lambda v: datetime.fromisoformat(v),
+    }
+
+
+@dataclass
+class NonexistentSectionConfig(ReadsCfg):
+    some_value: str
+    another_value: str
+
+    section_header = "i_dont_exist"
+
+
+@dataclass
+class TypeMismatchConfig(ReadsCfg):
+    int_value: int
+
+    section_header = "type_mismatch"
+
+
+@dataclass
+class MissingItemConfig(ReadsCfg):
+    present_item: str
+    missing_item: str
+
+    section_header = "missing_item"
+
+
+class BasicConfigTest:
+    @pytest.fixture(scope="class")
+    def config(self):
+        return BasicConfig.from_cfg_file(PATH_TO_TEST_CONFIG)
+
+    def test_coercions(self, config):
+        assert isinstance(config.int_value, int)
+        assert isinstance(config.bool_value, bool)
+        assert isinstance(config.float_value, float)
+        assert isinstance(config.str_value, str)
+
+    def test_values(self, config):
+        assert config.int_value == 15421
+        assert config.bool_value == True
+        assert config.float_value == 14.01
+        assert config.str_value == "Ceci n'est pas une chaîne"
+
+    def test_error_on_loading_from_nonexistent_file(self):
+        assert_raises_with_given_cause(
+            MissingConfig,
+            FileNotFoundError,
+            BasicConfig.from_cfg_file,
+            "./n/o/f/i/l/e/h.ere",
+        )
+
+    def test_error_on_loading_from_nonexistent_section(self):
+        assert_raises_with_given_cause(
+            MissingConfigSection,
+            configparser.NoSectionError,
+            NonexistentSectionConfig.from_cfg_file,
+            PATH_TO_TEST_CONFIG,
+        )
+
+
+class BooleanConfigTest:
+    @pytest.fixture(scope="class")
+    def config(self):
+        return BooleanConfig.from_cfg_file(PATH_TO_TEST_CONFIG)
+
+    def test_values(self, config):
+        assert config.yes is True
+        assert config.one is True
+        assert config.true is True
+        assert config.on is True
+        assert config.no is False
+        assert config.zero is False
+        assert config.false is False
+        assert config.off is False
+
+
+class UnsupportedTypesConfigTest:
+    @pytest.fixture(scope="class")
+    def config(self):
+        return UnsupportedTypesConfig.from_cfg_file(PATH_TO_TEST_CONFIG)
+
+    def test_values(self, config):
+        assert config.decimal_value == "100001.01"
+        assert config.datetime_value == "2021-12-04 00:05:23.283"
+
+
+class SupportedTypesConfigTest:
+    @pytest.fixture(scope="class")
+    def config(self):
+        return SupportedTypesConfig.from_cfg_file(PATH_TO_TEST_CONFIG)
+
+    def test_values(self, config):
+        assert config.decimal_value == Decimal("100001.01")
+        assert config.datetime_value == datetime(2021, 12, 4, 0, 5, 23, 283000)
+
+
+class TypeMismatchConfigTest:
+    def test_error_on_load(self):
+        assert_raises_message_with_given_cause(
+            ConfigValueTypeError,
+            "Wrong value type for int_value",
+            ValueError,
+            TypeMismatchConfig.from_cfg_file,
+            PATH_TO_TEST_CONFIG,
+        )
+
+
+class MissingItemConfigTest:
+    def test_error_on_load(self):
+        assert_raises_message_with_given_cause(
+            MissingConfigItem,
+            "No config item for missing_item",
+            configparser.NoOptionError,
+            MissingItemConfig.from_cfg_file,
+            PATH_TO_TEST_CONFIG,
+        )
diff --git a/test/util/__init__.py b/test/util/__init__.py
deleted file mode 100644
index f8cb359..0000000
--- a/test/util/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from unittest import mock  # noqa
diff --git a/test/util/exclusions.py b/test/util/exclusions.py
deleted file mode 100644
index 8eb596e..0000000
--- a/test/util/exclusions.py
+++ /dev/null
@@ -1,47 +0,0 @@
-import unittest
-
-from mako.util import update_wrapper
-
-
-def skip_if(predicate, reason=None):
-    """Skip a test if predicate is true."""
-    reason = reason or predicate.__name__
-
-    def decorate(fn):
-        fn_name = fn.__name__
-
-        def maybe(*args, **kw):
-            if predicate():
-                msg = "'%s' skipped: %s" % (fn_name, reason)
-                raise unittest.SkipTest(msg)
-            else:
-                return fn(*args, **kw)
-
-        return update_wrapper(maybe, fn)
-
-    return decorate
-
-
-def requires_pygments_14(fn):
-    try:
-        import pygments
-
-        version = pygments.__version__
-    except:
-        version = "0"
-    return skip_if(
-        lambda: version < "1.4", "Requires pygments 1.4 or greater"
-    )(fn)
-
-
-def requires_no_pygments_exceptions(fn):
-    def go(*arg, **kw):
-        from mako import exceptions
-
-        exceptions._install_fallback()
-        try:
-            return fn(*arg, **kw)
-        finally:
-            exceptions._install_highlighting()
-
-    return update_wrapper(go, fn)
diff --git a/tox.ini b/tox.ini
index 8b2b0d2..d8edd3c 100644
--- a/tox.ini
+++ b/tox.ini
@@ -15,8 +15,6 @@
 
 setenv=
     cov: COVERAGE={[testenv]cov_args}
-    TEST_TEMPLATE_BASE={toxinidir}{/}test{/}templates
-    TEST_MODULE_BASE={env:TEST_TEMPLATE_BASE}{/}modules
 
 commands=pytest {env:COVERAGE:} {posargs}