From f4d64f9f6620d4de8c7659c83e19351eb42db11b Mon Sep 17 00:00:00 2001
From: Vsevolod Fedorov <vsevolod.fedorov@gmail.com>
Date: Wed, 15 Jun 2022 11:12:23 +0300
Subject: [PATCH] Move tests to pytest

Pytest makes each scenario into individual selectable test.
To be able to run each scenario separately is very useful for development.

Change-Id: I4b1c990a1fd839ce327cd7faa27159a9b9632fed
---
 README.rst                                   |   4 +
 test-requirements.txt                        |   8 +-
 tests/__init__.py                            |   0
 tests/base.py                                | 409 -----------
 tests/builders/__init__.py                   |   0
 tests/builders/test_builders.py              |  26 +-
 tests/cachestorage/test_cachestorage.py      |  55 +-
 tests/cmd/__init__.py                        |   0
 tests/cmd/conftest.py                        |  24 +
 tests/cmd/fixtures/enable-query-plugins.conf |   3 +
 tests/cmd/subcommands/__init__.py            |   0
 tests/cmd/subcommands/test_delete.py         |  92 +--
 tests/cmd/subcommands/test_delete_all.py     |  45 +-
 tests/cmd/subcommands/test_list.py           | 142 ++--
 tests/cmd/subcommands/test_test.py           | 712 +++++++++----------
 tests/cmd/subcommands/test_update.py         | 179 ++---
 tests/cmd/test_cmd.py                        |  40 +-
 tests/cmd/test_config.py                     | 265 +++----
 tests/cmd/test_recurse_path.py               | 183 +++--
 tests/conftest.py                            | 169 +++++
 tests/duplicates/__init__.py                 |   0
 tests/duplicates/test_duplicates.py          |  34 +-
 tests/enum_scenarios.py                      |  42 ++
 tests/errors/test_exceptions.py              | 115 +--
 tests/general/test_general.py                |  24 +-
 tests/githuborg/test_githuborg.py            |  26 +-
 tests/hipchat/test_hipchat.py                |  24 +-
 tests/jenkins_manager/test_manager.py        | 100 +--
 tests/jsonparser/test_jsonparser.py          |  23 +-
 tests/localyaml/test_localyaml.py            | 209 +++---
 tests/macros/test_macros.py                  |  23 +-
 tests/moduleregistry/test_moduleregistry.py  | 238 ++++---
 tests/modules/test_helpers.py                | 152 ++--
 tests/multibranch/test_multibranch.py        |  28 +-
 tests/notifications/test_notifications.py    |  24 +-
 tests/parallel/test_parallel.py              |  75 +-
 tests/parameters/test_parameters.py          |  24 +-
 tests/properties/test_properties.py          |  24 +-
 tests/publishers/test_publishers.py          |  24 +-
 tests/reporters/test_reporters.py            |  24 +-
 tests/scm/test_scm.py                        |  24 +-
 tests/triggers/test_triggers.py              |  24 +-
 tests/views/test_views.py                    |  71 +-
 tests/wrappers/test_wrappers.py              |  24 +-
 tests/xml_config/test_xml_config.py          |  94 +--
 tests/yamlparser/test_fixtures.py            |  41 ++
 tests/yamlparser/test_parser_exceptions.py   |  53 ++
 tests/yamlparser/test_yamlparser.py          |  67 --
 tox.ini                                      |   8 +-
 49 files changed, 2042 insertions(+), 1953 deletions(-)
 delete mode 100644 tests/__init__.py
 delete mode 100644 tests/base.py
 delete mode 100644 tests/builders/__init__.py
 delete mode 100644 tests/cmd/__init__.py
 create mode 100644 tests/cmd/conftest.py
 create mode 100644 tests/cmd/fixtures/enable-query-plugins.conf
 delete mode 100644 tests/cmd/subcommands/__init__.py
 create mode 100644 tests/conftest.py
 delete mode 100644 tests/duplicates/__init__.py
 create mode 100644 tests/enum_scenarios.py
 create mode 100644 tests/yamlparser/test_fixtures.py
 create mode 100644 tests/yamlparser/test_parser_exceptions.py
 delete mode 100644 tests/yamlparser/test_yamlparser.py

diff --git a/README.rst b/README.rst
index bf71a95a3..d69be11fe 100644
--- a/README.rst
+++ b/README.rst
@@ -90,6 +90,10 @@ execute the command::
 
     tox -e py38
 
+Unit tests could be run in parallel, using pytest-parallel pytest plugin::
+
+    tox -e py38 -- --workers=auto
+
 * Note: View ``tox.ini`` to run tests on other versions of Python,
   generating the documentation and additionally for any special notes
   on running the test to validate documentation external URLs from behind
diff --git a/test-requirements.txt b/test-requirements.txt
index 2d38d0385..da8578a9a 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -3,13 +3,11 @@
 # process, which may cause wedges in the gate later.
 
 coverage>=4.0 # Apache-2.0
-fixtures>=3.0.0 # Apache-2.0/BSD
 python-subunit>=0.0.18 # Apache-2.0/BSD
 sphinx>=4.4.0
-testscenarios>=0.4 # Apache-2.0/BSD
 testtools>=1.4.0 # MIT
-stestr>=2.0.0,!=3.0.0 # Apache-2.0/BSD
 tox>=2.9.1 # MIT
-mock>=2.0; python_version < '3.0'  # BSD
 sphinxcontrib-programoutput
-pluggy<1.0.0 # the last version that supports Python 2
+pytest==7.1.2
+pytest-mock==3.7.0
+pytest-parallel==0.1.1
diff --git a/tests/__init__.py b/tests/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/base.py b/tests/base.py
deleted file mode 100644
index 7e2f64d0d..000000000
--- a/tests/base.py
+++ /dev/null
@@ -1,409 +0,0 @@
-#!/usr/bin/env python
-#
-# Joint copyright:
-#  - Copyright 2012,2013 Wikimedia Foundation
-#  - Copyright 2012,2013 Antoine "hashar" Musso
-#  - Copyright 2013 Arnaud Fabre
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-# License for the specific language governing permissions and limitations
-# under the License.
-
-import doctest
-import configparser
-import io
-import json
-import logging
-import os
-import pkg_resources
-import re
-import xml.etree.ElementTree as XML
-
-import fixtures
-import six
-from six.moves import StringIO
-import testtools
-from testtools.content import text_content
-import testscenarios
-from yaml import safe_dump
-
-from jenkins_jobs.config import JJBConfig
-from jenkins_jobs.errors import InvalidAttributeError
-import jenkins_jobs.local_yaml as yaml
-from jenkins_jobs.alphanum import AlphanumSort
-from jenkins_jobs.modules import project_externaljob
-from jenkins_jobs.modules import project_flow
-from jenkins_jobs.modules import project_githuborg
-from jenkins_jobs.modules import project_matrix
-from jenkins_jobs.modules import project_maven
-from jenkins_jobs.modules import project_multibranch
-from jenkins_jobs.modules import project_multijob
-from jenkins_jobs.modules import view_all
-from jenkins_jobs.modules import view_delivery_pipeline
-from jenkins_jobs.modules import view_list
-from jenkins_jobs.modules import view_nested
-from jenkins_jobs.modules import view_pipeline
-from jenkins_jobs.modules import view_sectioned
-from jenkins_jobs.parser import YamlParser
-from jenkins_jobs.registry import ModuleRegistry
-from jenkins_jobs.xml_config import XmlJob
-from jenkins_jobs.xml_config import XmlJobGenerator
-
-# This dance deals with the fact that we want unittest.mock if
-# we're on Python 3.4 and later, and non-stdlib mock otherwise.
-try:
-    from unittest import mock  # noqa
-except ImportError:
-    import mock  # noqa
-
-
-def get_scenarios(
-    fixtures_path,
-    in_ext="yaml",
-    out_ext="xml",
-    plugins_info_ext="plugins_info.yaml",
-    filter_func=None,
-):
-    """Returns a list of scenarios, each scenario being described
-    by two parameters (yaml and xml filenames by default).
-        - content of the fixture output file (aka expected)
-    """
-    scenarios = []
-    files = {}
-    for dirpath, _, fs in os.walk(fixtures_path):
-        for fn in fs:
-            if fn in files:
-                files[fn].append(os.path.join(dirpath, fn))
-            else:
-                files[fn] = [os.path.join(dirpath, fn)]
-
-    input_files = [
-        files[f][0] for f in files if re.match(r".*\.{0}$".format(in_ext), f)
-    ]
-
-    for input_filename in input_files:
-        if input_filename.endswith(plugins_info_ext):
-            continue
-
-        if callable(filter_func) and filter_func(input_filename):
-            continue
-
-        output_candidate = re.sub(
-            r"\.{0}$".format(in_ext), ".{0}".format(out_ext), input_filename
-        )
-        # assume empty file if no output candidate found
-        if os.path.basename(output_candidate) in files:
-            out_filenames = files[os.path.basename(output_candidate)]
-        else:
-            out_filenames = None
-
-        plugins_info_candidate = re.sub(
-            r"\.{0}$".format(in_ext), ".{0}".format(plugins_info_ext), input_filename
-        )
-        if os.path.basename(plugins_info_candidate) not in files:
-            plugins_info_candidate = None
-
-        conf_candidate = re.sub(r"\.yaml$|\.json$", ".conf", input_filename)
-        conf_filename = files.get(os.path.basename(conf_candidate), None)
-
-        if conf_filename:
-            conf_filename = conf_filename[0]
-        else:
-            # for testing purposes we want to avoid using user config files
-            conf_filename = os.devnull
-
-        scenarios.append(
-            (
-                input_filename,
-                {
-                    "in_filename": input_filename,
-                    "out_filenames": out_filenames,
-                    "conf_filename": conf_filename,
-                    "plugins_info_filename": plugins_info_candidate,
-                },
-            )
-        )
-
-    return scenarios
-
-
-class BaseTestCase(testtools.TestCase):
-
-    # TestCase settings:
-    maxDiff = None  # always dump text difference
-    longMessage = True  # keep normal error message when providing our
-
-    def setUp(self):
-
-        super(BaseTestCase, self).setUp()
-        self.logger = self.useFixture(fixtures.FakeLogger(level=logging.DEBUG))
-
-    def _read_utf8_content(self):
-        # if None assume empty file
-        if not self.out_filenames:
-            return ""
-
-        # Read XML content, assuming it is unicode encoded
-        xml_content = ""
-        for f in sorted(self.out_filenames):
-            with io.open(f, "r", encoding="utf-8") as xml_file:
-                xml_content += "%s" % xml_file.read()
-        return xml_content
-
-    def _read_yaml_content(self, filename):
-        with io.open(filename, "r", encoding="utf-8") as yaml_file:
-            yaml_content = yaml.load(yaml_file)
-        return yaml_content
-
-    def _get_config(self):
-        jjb_config = JJBConfig(self.conf_filename)
-        jjb_config.validate()
-
-        return jjb_config
-
-
-class BaseScenariosTestCase(testscenarios.TestWithScenarios, BaseTestCase):
-
-    scenarios = []
-    fixtures_path = None
-
-    @mock.patch("pkg_resources.iter_entry_points")
-    def test_yaml_snippet(self, mock):
-        if not self.in_filename:
-            return
-
-        jjb_config = self._get_config()
-
-        expected_xml = self._read_utf8_content()
-        yaml_content = self._read_yaml_content(self.in_filename)
-
-        plugins_info = None
-        if self.plugins_info_filename:
-            plugins_info = self._read_yaml_content(self.plugins_info_filename)
-            self.addDetail(
-                "plugins-info-filename", text_content(self.plugins_info_filename)
-            )
-            self.addDetail("plugins-info", text_content(str(plugins_info)))
-
-        parser = YamlParser(jjb_config)
-        e = pkg_resources.EntryPoint.parse
-        d = pkg_resources.Distribution()
-        config = configparser.ConfigParser()
-        config.read(os.path.dirname(__file__) + "/../setup.cfg")
-        groups = {}
-        for key in config["entry_points"]:
-            groups[key] = list()
-            for line in config["entry_points"][key].split("\n"):
-                if "" == line.strip():
-                    continue
-                groups[key].append(e(line, dist=d))
-
-        def mock_iter_entry_points(group, name=None):
-            return (
-                entry for entry in groups[group] if name is None or name == entry.name
-            )
-
-        mock.side_effect = mock_iter_entry_points
-        registry = ModuleRegistry(jjb_config, plugins_info)
-        registry.set_parser_data(parser.data)
-
-        pub = self.klass(registry)
-
-        project = None
-        if "project-type" in yaml_content:
-            if yaml_content["project-type"] == "maven":
-                project = project_maven.Maven(registry)
-            elif yaml_content["project-type"] == "matrix":
-                project = project_matrix.Matrix(registry)
-            elif yaml_content["project-type"] == "flow":
-                project = project_flow.Flow(registry)
-            elif yaml_content["project-type"] == "githuborg":
-                project = project_githuborg.GithubOrganization(registry)
-            elif yaml_content["project-type"] == "multijob":
-                project = project_multijob.MultiJob(registry)
-            elif yaml_content["project-type"] == "multibranch":
-                project = project_multibranch.WorkflowMultiBranch(registry)
-            elif yaml_content["project-type"] == "multibranch-defaults":
-                project = project_multibranch.WorkflowMultiBranchDefaults(
-                    registry
-                )  # noqa
-            elif yaml_content["project-type"] == "externaljob":
-                project = project_externaljob.ExternalJob(registry)
-
-        if "view-type" in yaml_content:
-            if yaml_content["view-type"] == "all":
-                project = view_all.All(registry)
-            elif yaml_content["view-type"] == "delivery_pipeline":
-                project = view_delivery_pipeline.DeliveryPipeline(registry)
-            elif yaml_content["view-type"] == "list":
-                project = view_list.List(registry)
-            elif yaml_content["view-type"] == "nested":
-                project = view_nested.Nested(registry)
-            elif yaml_content["view-type"] == "pipeline":
-                project = view_pipeline.Pipeline(registry)
-            elif yaml_content["view-type"] == "sectioned":
-                project = view_sectioned.Sectioned(registry)
-            else:
-                raise InvalidAttributeError("view-type", yaml_content["view-type"])
-
-        if project:
-            xml_project = project.root_xml(yaml_content)
-        else:
-            xml_project = XML.Element("project")
-
-        # Generate the XML tree directly with modules/general
-        pub.gen_xml(xml_project, yaml_content)
-
-        # check output file is under correct path
-        if "name" in yaml_content:
-            prefix = os.path.dirname(self.in_filename)
-            # split using '/' since fullname uses URL path separator
-            expected_folders = [
-                os.path.normpath(
-                    os.path.join(
-                        prefix,
-                        "/".join(parser._getfullname(yaml_content).split("/")[:-1]),
-                    )
-                )
-            ]
-            actual_folders = [os.path.dirname(f) for f in self.out_filenames]
-
-            self.assertEquals(
-                expected_folders,
-                actual_folders,
-                "Output file under wrong path, was '%s', should be '%s'"
-                % (
-                    self.out_filenames[0],
-                    os.path.join(
-                        expected_folders[0], os.path.basename(self.out_filenames[0])
-                    ),
-                ),
-            )
-
-        # Prettify generated XML
-        pretty_xml = XmlJob(xml_project, "fixturejob").output().decode("utf-8")
-
-        self.assertThat(
-            pretty_xml,
-            testtools.matchers.DocTestMatches(
-                expected_xml, doctest.ELLIPSIS | doctest.REPORT_NDIFF
-            ),
-        )
-
-
-class SingleJobTestCase(BaseScenariosTestCase):
-    def test_yaml_snippet(self):
-        config = self._get_config()
-
-        expected_xml = (
-            self._read_utf8_content()
-            .strip()
-            .replace("<BLANKLINE>", "")
-            .replace("\n\n", "\n")
-        )
-
-        parser = YamlParser(config)
-        parser.parse(self.in_filename)
-
-        plugins_info = None
-        if self.plugins_info_filename:
-            plugins_info = self._read_yaml_content(self.plugins_info_filename)
-            self.addDetail(
-                "plugins-info-filename", text_content(self.plugins_info_filename)
-            )
-            self.addDetail("plugins-info", text_content(str(plugins_info)))
-
-        registry = ModuleRegistry(config, plugins_info)
-        registry.set_parser_data(parser.data)
-        job_data_list, view_data_list = parser.expandYaml(registry)
-
-        # Generate the XML tree
-        xml_generator = XmlJobGenerator(registry)
-        xml_jobs = xml_generator.generateXML(job_data_list)
-
-        xml_jobs.sort(key=AlphanumSort)
-
-        # check reference files are under correct path for folders
-        prefix = os.path.dirname(self.in_filename)
-        # split using '/' since fullname uses URL path separator
-        expected_folders = list(
-            set(
-                [
-                    os.path.normpath(
-                        os.path.join(prefix, "/".join(job_data["name"].split("/")[:-1]))
-                    )
-                    for job_data in job_data_list
-                ]
-            )
-        )
-        actual_folders = [os.path.dirname(f) for f in self.out_filenames]
-
-        six.assertCountEqual(
-            self,
-            expected_folders,
-            actual_folders,
-            "Output file under wrong path, was '%s', should be '%s'"
-            % (
-                self.out_filenames[0],
-                os.path.join(
-                    expected_folders[0], os.path.basename(self.out_filenames[0])
-                ),
-            ),
-        )
-
-        # Prettify generated XML
-        pretty_xml = (
-            "\n".join(job.output().decode("utf-8") for job in xml_jobs)
-            .strip()
-            .replace("\n\n", "\n")
-        )
-
-        self.assertThat(
-            pretty_xml,
-            testtools.matchers.DocTestMatches(
-                expected_xml, doctest.ELLIPSIS | doctest.REPORT_NDIFF
-            ),
-        )
-
-
-class JsonTestCase(BaseScenariosTestCase):
-    def test_yaml_snippet(self):
-        expected_json = self._read_utf8_content()
-        yaml_content = self._read_yaml_content(self.in_filename)
-
-        pretty_json = json.dumps(yaml_content, indent=4, separators=(",", ": "))
-
-        self.assertThat(
-            pretty_json,
-            testtools.matchers.DocTestMatches(
-                expected_json, doctest.ELLIPSIS | doctest.REPORT_NDIFF
-            ),
-        )
-
-
-class YamlTestCase(BaseScenariosTestCase):
-    def test_yaml_snippet(self):
-        expected_yaml = self._read_utf8_content()
-        yaml_content = self._read_yaml_content(self.in_filename)
-
-        # using json forces expansion of yaml anchors and aliases in the
-        # outputted yaml, otherwise it would simply appear exactly as
-        # entered which doesn't show that the net effect of the yaml
-        data = StringIO(json.dumps(yaml_content))
-
-        pretty_yaml = safe_dump(json.load(data), default_flow_style=False)
-
-        self.assertThat(
-            pretty_yaml,
-            testtools.matchers.DocTestMatches(
-                expected_yaml, doctest.ELLIPSIS | doctest.REPORT_NDIFF
-            ),
-        )
diff --git a/tests/builders/__init__.py b/tests/builders/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/builders/test_builders.py b/tests/builders/test_builders.py
index 9d9f77d4a..2c1fdbaf4 100644
--- a/tests/builders/test_builders.py
+++ b/tests/builders/test_builders.py
@@ -1,3 +1,5 @@
+#!/usr/bin/env python
+#
 # Joint copyright:
 #  - Copyright 2012,2013 Wikimedia Foundation
 #  - Copyright 2012,2013 Antoine "hashar" Musso
@@ -15,13 +17,25 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import os
+from operator import attrgetter
+from pathlib import Path
 
+import pytest
+
+from tests.enum_scenarios import scenario_list
 from jenkins_jobs.modules import builders
-from tests import base
 
 
-class TestCaseModuleBuilders(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    klass = builders.Builders
+fixtures_dir = Path(__file__).parent / "fixtures"
+
+
+@pytest.fixture(
+    params=scenario_list(fixtures_dir),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+def test_yaml_snippet(check_generator):
+    check_generator(builders.Builders)
diff --git a/tests/cachestorage/test_cachestorage.py b/tests/cachestorage/test_cachestorage.py
index 13c7b87a7..4936a8e6b 100644
--- a/tests/cachestorage/test_cachestorage.py
+++ b/tests/cachestorage/test_cachestorage.py
@@ -13,33 +13,40 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import os
+import os.path
+
+import pytest
 
 import jenkins_jobs
-from tests import base
-from tests.base import mock
 
 
-class TestCaseJobCache(base.BaseTestCase):
-    @mock.patch("jenkins_jobs.builder.JobCache.get_cache_dir", lambda x: "/bad/file")
-    def test_save_on_exit(self):
-        """
-        Test that the cache is saved on normal object deletion
-        """
+# Override fixture - do not use this mock.
+@pytest.fixture(autouse=True)
+def job_cache_mocked(mocker):
+    pass
 
-        with mock.patch("jenkins_jobs.builder.JobCache.save") as save_mock:
-            with mock.patch("os.path.isfile", return_value=False):
-                with mock.patch("jenkins_jobs.builder.JobCache._lock"):
-                    jenkins_jobs.builder.JobCache("dummy")
-            save_mock.assert_called_with()
 
-    @mock.patch("jenkins_jobs.builder.JobCache.get_cache_dir", lambda x: "/bad/file")
-    def test_cache_file(self):
-        """
-        Test providing a cachefile.
-        """
-        test_file = os.path.abspath(__file__)
-        with mock.patch("os.path.join", return_value=test_file):
-            with mock.patch("yaml.safe_load"):
-                with mock.patch("jenkins_jobs.builder.JobCache._lock"):
-                    jenkins_jobs.builder.JobCache("dummy").data = None
+def test_save_on_exit(mocker):
+    """
+    Test that the cache is saved on normal object deletion
+    """
+    mocker.patch("jenkins_jobs.builder.JobCache.get_cache_dir", lambda x: "/bad/file")
+
+    save_mock = mocker.patch("jenkins_jobs.builder.JobCache.save")
+    mocker.patch("os.path.isfile", return_value=False)
+    mocker.patch("jenkins_jobs.builder.JobCache._lock")
+    jenkins_jobs.builder.JobCache("dummy")
+    save_mock.assert_called_with()
+
+
+def test_cache_file(mocker):
+    """
+    Test providing a cachefile.
+    """
+    mocker.patch("jenkins_jobs.builder.JobCache.get_cache_dir", lambda x: "/bad/file")
+
+    test_file = os.path.abspath(__file__)
+    mocker.patch("os.path.join", return_value=test_file)
+    mocker.patch("yaml.safe_load")
+    mocker.patch("jenkins_jobs.builder.JobCache._lock")
+    jenkins_jobs.builder.JobCache("dummy").data = None
diff --git a/tests/cmd/__init__.py b/tests/cmd/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/cmd/conftest.py b/tests/cmd/conftest.py
new file mode 100644
index 000000000..81f771d14
--- /dev/null
+++ b/tests/cmd/conftest.py
@@ -0,0 +1,24 @@
+from pathlib import Path
+
+import pytest
+
+from jenkins_jobs.cli import entry
+
+
+@pytest.fixture
+def fixtures_dir():
+    return Path(__file__).parent / "fixtures"
+
+
+@pytest.fixture
+def default_config_file(fixtures_dir):
+    return str(fixtures_dir / "empty_builder.ini")
+
+
+@pytest.fixture
+def execute_jenkins_jobs():
+    def execute(args):
+        jenkins_jobs = entry.JenkinsJobs(args)
+        jenkins_jobs.execute()
+
+    return execute
diff --git a/tests/cmd/fixtures/enable-query-plugins.conf b/tests/cmd/fixtures/enable-query-plugins.conf
new file mode 100644
index 000000000..802fb4f91
--- /dev/null
+++ b/tests/cmd/fixtures/enable-query-plugins.conf
@@ -0,0 +1,3 @@
+[jenkins]
+url=http://test-jenkins.with.non.default.url:8080/
+query_plugins_info=True
diff --git a/tests/cmd/subcommands/__init__.py b/tests/cmd/subcommands/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/cmd/subcommands/test_delete.py b/tests/cmd/subcommands/test_delete.py
index 252b82ec5..4f014f603 100644
--- a/tests/cmd/subcommands/test_delete.py
+++ b/tests/cmd/subcommands/test_delete.py
@@ -18,55 +18,57 @@
 # of actions by the JJB library, usually through interaction with the
 # python-jenkins library.
 
-import os
-
-from tests.base import mock
-from tests.cmd.test_cmd import CmdTestsBase
+from unittest import mock
 
 
-@mock.patch("jenkins_jobs.builder.JenkinsManager.get_plugins_info", mock.MagicMock)
-class DeleteTests(CmdTestsBase):
-    @mock.patch("jenkins_jobs.cli.subcommand.update." "JenkinsManager.delete_jobs")
-    @mock.patch("jenkins_jobs.cli.subcommand.update." "JenkinsManager.delete_views")
-    def test_delete_single_job(self, delete_job_mock, delete_view_mock):
-        """
-        Test handling the deletion of a single Jenkins job.
-        """
+def test_delete_single_job(mocker, default_config_file, execute_jenkins_jobs):
+    """
+    Test handling the deletion of a single Jenkins job.
+    """
 
-        args = ["--conf", self.default_config_file, "delete", "test_job"]
-        self.execute_jenkins_jobs_with_args(args)
+    mocker.patch("jenkins_jobs.cli.subcommand.update.JenkinsManager.delete_jobs")
+    mocker.patch("jenkins_jobs.cli.subcommand.update.JenkinsManager.delete_views")
 
-    @mock.patch("jenkins_jobs.cli.subcommand.update." "JenkinsManager.delete_jobs")
-    @mock.patch("jenkins_jobs.cli.subcommand.update." "JenkinsManager.delete_views")
-    def test_delete_multiple_jobs(self, delete_job_mock, delete_view_mock):
-        """
-        Test handling the deletion of multiple Jenkins jobs.
-        """
+    args = ["--conf", default_config_file, "delete", "test_job"]
+    execute_jenkins_jobs(args)
 
-        args = ["--conf", self.default_config_file, "delete", "test_job1", "test_job2"]
-        self.execute_jenkins_jobs_with_args(args)
 
-    @mock.patch("jenkins_jobs.builder.JenkinsManager.delete_job")
-    def test_delete_using_glob_params(self, delete_job_mock):
-        """
-        Test handling the deletion of multiple Jenkins jobs using the glob
-        parameters feature.
-        """
+def test_delete_multiple_jobs(mocker, default_config_file, execute_jenkins_jobs):
+    """
+    Test handling the deletion of multiple Jenkins jobs.
+    """
 
-        args = [
-            "--conf",
-            self.default_config_file,
-            "delete",
-            "--path",
-            os.path.join(self.fixtures_path, "cmd-002.yaml"),
-            "*bar*",
-        ]
-        self.execute_jenkins_jobs_with_args(args)
-        calls = [mock.call("bar001"), mock.call("bar002")]
-        delete_job_mock.assert_has_calls(calls, any_order=True)
-        self.assertEqual(
-            delete_job_mock.call_count,
-            len(calls),
-            "Jenkins.delete_job() was called '%s' times when "
-            "expected '%s'" % (delete_job_mock.call_count, len(calls)),
-        )
+    mocker.patch("jenkins_jobs.cli.subcommand.update.JenkinsManager.delete_jobs")
+    mocker.patch("jenkins_jobs.cli.subcommand.update.JenkinsManager.delete_views")
+
+    args = ["--conf", default_config_file, "delete", "test_job1", "test_job2"]
+    execute_jenkins_jobs(args)
+
+
+def test_delete_using_glob_params(
+    mocker, fixtures_dir, default_config_file, execute_jenkins_jobs
+):
+    """
+    Test handling the deletion of multiple Jenkins jobs using the glob
+    parameters feature.
+    """
+
+    delete_job_mock = mocker.patch("jenkins_jobs.builder.JenkinsManager.delete_job")
+
+    args = [
+        "--conf",
+        default_config_file,
+        "delete",
+        "--path",
+        str(fixtures_dir / "cmd-002.yaml"),
+        "*bar*",
+    ]
+    execute_jenkins_jobs(args)
+    calls = [mock.call("bar001"), mock.call("bar002")]
+    delete_job_mock.assert_has_calls(calls, any_order=True)
+    assert delete_job_mock.call_count == len(
+        calls
+    ), "Jenkins.delete_job() was called '%s' times when " "expected '%s'" % (
+        delete_job_mock.call_count,
+        len(calls),
+    )
diff --git a/tests/cmd/subcommands/test_delete_all.py b/tests/cmd/subcommands/test_delete_all.py
index f2ef46e80..b7aac2660 100644
--- a/tests/cmd/subcommands/test_delete_all.py
+++ b/tests/cmd/subcommands/test_delete_all.py
@@ -17,31 +17,30 @@
 # of actions by the JJB library, usually through interaction with the
 # python-jenkins library.
 
-from tests.base import mock
-from tests.cmd.test_cmd import CmdTestsBase
+import pytest
 
 
-@mock.patch("jenkins_jobs.builder.JenkinsManager.get_plugins_info", mock.MagicMock)
-class DeleteAllTests(CmdTestsBase):
-    @mock.patch("jenkins_jobs.cli.subcommand.update." "JenkinsManager.delete_all_jobs")
-    def test_delete_all_accept(self, delete_job_mock):
-        """
-        Test handling the deletion of a single Jenkins job.
-        """
+def test_delete_all_accept(mocker, default_config_file, execute_jenkins_jobs):
+    """
+    Test handling the deletion of a single Jenkins job.
+    """
 
-        args = ["--conf", self.default_config_file, "delete-all"]
-        with mock.patch(
-            "jenkins_jobs.builder.JenkinsManager.get_views", return_value=[None]
-        ):
-            with mock.patch("jenkins_jobs.utils.input", return_value="y"):
-                self.execute_jenkins_jobs_with_args(args)
+    mocker.patch("jenkins_jobs.cli.subcommand.update.JenkinsManager.delete_all_jobs")
+    mocker.patch("jenkins_jobs.builder.JenkinsManager.get_views", return_value=[None])
+    mocker.patch("jenkins_jobs.utils.input", return_value="y")
 
-    @mock.patch("jenkins_jobs.cli.subcommand.update." "JenkinsManager.delete_all_jobs")
-    def test_delete_all_abort(self, delete_job_mock):
-        """
-        Test handling the deletion of a single Jenkins job.
-        """
+    args = ["--conf", default_config_file, "delete-all"]
+    execute_jenkins_jobs(args)
 
-        args = ["--conf", self.default_config_file, "delete-all"]
-        with mock.patch("jenkins_jobs.utils.input", return_value="n"):
-            self.assertRaises(SystemExit, self.execute_jenkins_jobs_with_args, args)
+
+def test_delete_all_abort(mocker, default_config_file, execute_jenkins_jobs):
+    """
+    Test handling the deletion of a single Jenkins job.
+    """
+
+    mocker.patch("jenkins_jobs.cli.subcommand.update.JenkinsManager.delete_all_jobs")
+    mocker.patch("jenkins_jobs.utils.input", return_value="n")
+
+    args = ["--conf", default_config_file, "delete-all"]
+    with pytest.raises(SystemExit):
+        execute_jenkins_jobs(args)
diff --git a/tests/cmd/subcommands/test_list.py b/tests/cmd/subcommands/test_list.py
index 002030b7f..43a9cb823 100644
--- a/tests/cmd/subcommands/test_list.py
+++ b/tests/cmd/subcommands/test_list.py
@@ -12,87 +12,83 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations
 # under the License.
-import io
-import os
 
-from testscenarios.testcase import TestWithScenarios
+from collections import namedtuple
 
-from tests.base import mock
-from tests.cmd.test_cmd import CmdTestsBase
+import pytest
 
 
-@mock.patch("jenkins_jobs.builder.JenkinsManager.get_plugins_info", mock.MagicMock)
-class ListFromJenkinsTests(TestWithScenarios, CmdTestsBase):
+JobsScenario = namedtuple("JobsScnenario", "name jobs globs found")
 
-    scenarios = [
-        ("single", dict(jobs=["job1"], globs=[], found=["job1"])),
-        ("multiple", dict(jobs=["job1", "job2"], globs=[], found=["job1", "job2"])),
-        (
-            "multiple_with_folder",
-            dict(
-                jobs=["folder1", "folder1/job1", "folder1/job2"],
-                globs=[],
-                found=["folder1", "folder1/job1", "folder1/job2"],
-            ),
-        ),
-        (
-            "multiple_with_glob",
-            dict(
-                jobs=["job1", "job2", "job3"],
-                globs=["job[1-2]"],
-                found=["job1", "job2"],
-            ),
-        ),
-        (
-            "multiple_with_multi_glob",
-            dict(
-                jobs=["job1", "job2", "job3", "job4"],
-                globs=["job1", "job[24]"],
-                found=["job1", "job2", "job4"],
-            ),
-        ),
-    ]
-
-    @mock.patch("jenkins_jobs.builder.JenkinsManager.get_jobs")
-    def test_list(self, get_jobs_mock):
-        def _get_jobs():
-            return [{"fullname": fullname} for fullname in self.jobs]
-
-        get_jobs_mock.side_effect = _get_jobs
-        console_out = io.BytesIO()
-
-        args = ["--conf", self.default_config_file, "list"] + self.globs
-
-        with mock.patch("sys.stdout", console_out):
-            self.execute_jenkins_jobs_with_args(args)
-
-        self.assertEqual(
-            console_out.getvalue().decode("utf-8").rstrip(), ("\n".join(self.found))
-        )
+jobs_scenarios = [
+    JobsScenario("single", jobs=["job1"], globs=[], found=["job1"]),
+    JobsScenario("multiple", jobs=["job1", "job2"], globs=[], found=["job1", "job2"]),
+    JobsScenario(
+        "multiple_with_folder",
+        jobs=["folder1", "folder1/job1", "folder1/job2"],
+        globs=[],
+        found=["folder1", "folder1/job1", "folder1/job2"],
+    ),
+    JobsScenario(
+        "multiple_with_glob",
+        jobs=["job1", "job2", "job3"],
+        globs=["job[1-2]"],
+        found=["job1", "job2"],
+    ),
+    JobsScenario(
+        "multiple_with_multi_glob",
+        jobs=["job1", "job2", "job3", "job4"],
+        globs=["job1", "job[24]"],
+        found=["job1", "job2", "job4"],
+    ),
+]
 
 
-@mock.patch("jenkins_jobs.builder.JenkinsManager.get_plugins_info", mock.MagicMock)
-class ListFromYamlTests(TestWithScenarios, CmdTestsBase):
+@pytest.mark.parametrize(
+    "scenario",
+    [pytest.param(s, id=s.name) for s in jobs_scenarios],
+)
+def test_from_jenkins_tests(
+    capsys, mocker, default_config_file, execute_jenkins_jobs, scenario
+):
+    def get_jobs():
+        return [{"fullname": fullname} for fullname in scenario.jobs]
 
-    scenarios = [
-        ("all", dict(globs=[], found=["bam001", "bar001", "bar002", "baz001"])),
-        (
-            "some",
-            dict(
-                globs=["*am*", "*002", "bar001"], found=["bam001", "bar001", "bar002"]
-            ),
-        ),
-    ]
+    mocker.patch("jenkins_jobs.builder.JenkinsManager.get_jobs", side_effect=get_jobs)
 
-    def test_list(self):
-        path = os.path.join(self.fixtures_path, "cmd-002.yaml")
+    args = ["--conf", default_config_file, "list"] + scenario.globs
+    execute_jenkins_jobs(args)
 
-        console_out = io.BytesIO()
-        with mock.patch("sys.stdout", console_out):
-            self.execute_jenkins_jobs_with_args(
-                ["--conf", self.default_config_file, "list", "-p", path] + self.globs
-            )
+    expected_out = "\n".join(scenario.found)
+    captured = capsys.readouterr()
+    assert captured.out.rstrip() == expected_out
 
-        self.assertEqual(
-            console_out.getvalue().decode("utf-8").rstrip(), ("\n".join(self.found))
-        )
+
+YamlScenario = namedtuple("YamlScnenario", "name globs found")
+
+yaml_scenarios = [
+    YamlScenario("all", globs=[], found=["bam001", "bar001", "bar002", "baz001"]),
+    YamlScenario(
+        "some",
+        globs=["*am*", "*002", "bar001"],
+        found=["bam001", "bar001", "bar002"],
+    ),
+]
+
+
+@pytest.mark.parametrize(
+    "scenario",
+    [pytest.param(s, id=s.name) for s in yaml_scenarios],
+)
+def test_from_yaml_tests(
+    capsys, fixtures_dir, default_config_file, execute_jenkins_jobs, scenario
+):
+    path = fixtures_dir / "cmd-002.yaml"
+
+    execute_jenkins_jobs(
+        ["--conf", default_config_file, "list", "-p", str(path)] + scenario.globs
+    )
+
+    expected_out = "\n".join(scenario.found)
+    captured = capsys.readouterr()
+    assert captured.out.rstrip() == expected_out
diff --git a/tests/cmd/subcommands/test_test.py b/tests/cmd/subcommands/test_test.py
index 87e4e68ab..2f294f1cd 100644
--- a/tests/cmd/subcommands/test_test.py
+++ b/tests/cmd/subcommands/test_test.py
@@ -18,295 +18,294 @@
 # of actions by the JJB library, usually through interaction with the
 # python-jenkins library.
 
-import difflib
 import filecmp
 import io
+import difflib
 import os
-import shutil
-import tempfile
 import yaml
+from unittest import mock
 
 import jenkins
-from six.moves import StringIO
-import testtools
+import pytest
+from testtools.assertions import assert_that
 
 from jenkins_jobs.cli import entry
-from tests.base import mock
-from tests.cmd.test_cmd import CmdTestsBase
 
 
-@mock.patch("jenkins_jobs.builder.JenkinsManager.get_plugins_info", mock.MagicMock)
-class TestTests(CmdTestsBase):
-    def test_non_existing_job(self):
-        """
-        Run test mode and pass a non-existing job name
-        (probably better to fail here)
-        """
-        args = [
-            "--conf",
-            self.default_config_file,
-            "test",
-            os.path.join(self.fixtures_path, "cmd-001.yaml"),
-            "invalid",
-        ]
-        self.execute_jenkins_jobs_with_args(args)
-
-    def test_valid_job(self):
-        """
-        Run test mode and pass a valid job name
-        """
-        args = [
-            "--conf",
-            self.default_config_file,
-            "test",
-            os.path.join(self.fixtures_path, "cmd-001.yaml"),
-            "foo-job",
-        ]
-        console_out = io.BytesIO()
-        with mock.patch("sys.stdout", console_out):
-            self.execute_jenkins_jobs_with_args(args)
-
-    def test_console_output(self):
-        """
-        Run test mode and verify that resulting XML gets sent to the console.
-        """
-
-        console_out = io.BytesIO()
-        with mock.patch("sys.stdout", console_out):
-            args = [
-                "--conf",
-                self.default_config_file,
-                "test",
-                os.path.join(self.fixtures_path, "cmd-001.yaml"),
-            ]
-            self.execute_jenkins_jobs_with_args(args)
-        xml_content = io.open(
-            os.path.join(self.fixtures_path, "cmd-001.xml"), "r", encoding="utf-8"
-        ).read()
-        self.assertEqual(console_out.getvalue().decode("utf-8"), xml_content)
-
-    def test_output_dir(self):
-        """
-        Run test mode with output to directory and verify that output files are
-        generated.
-        """
-        tmpdir = tempfile.mkdtemp()
-        self.addCleanup(shutil.rmtree, tmpdir)
-        args = ["test", os.path.join(self.fixtures_path, "cmd-001.yaml"), "-o", tmpdir]
-        self.execute_jenkins_jobs_with_args(args)
-        self.expectThat(
-            os.path.join(tmpdir, "foo-job"), testtools.matchers.FileExists()
-        )
-
-    def test_output_dir_config_xml(self):
-        """
-        Run test mode with output to directory in "config.xml" mode and verify
-        that output files are generated.
-        """
-        tmpdir = tempfile.mkdtemp()
-        self.addCleanup(shutil.rmtree, tmpdir)
-        args = [
-            "test",
-            os.path.join(self.fixtures_path, "cmd-001.yaml"),
-            "-o",
-            tmpdir,
-            "--config-xml",
-        ]
-        self.execute_jenkins_jobs_with_args(args)
-        self.expectThat(
-            os.path.join(tmpdir, "foo-job", "config.xml"),
-            testtools.matchers.FileExists(),
-        )
-
-    def test_stream_input_output_no_encoding_exceed_recursion(self):
-        """
-        Test that we don't have issues processing large number of jobs and
-        outputting the result if the encoding is not set.
-        """
-        console_out = io.BytesIO()
-
-        input_file = os.path.join(self.fixtures_path, "large-number-of-jobs-001.yaml")
-        with io.open(input_file, "r") as f:
-            with mock.patch("sys.stdout", console_out):
-                console_out.encoding = None
-                with mock.patch("sys.stdin", f):
-                    args = ["test"]
-                    self.execute_jenkins_jobs_with_args(args)
-
-    def test_stream_input_output_utf8_encoding(self):
-        """
-        Run test mode simulating using pipes for input and output using
-        utf-8 encoding
-        """
-        console_out = io.BytesIO()
-
-        input_file = os.path.join(self.fixtures_path, "cmd-001.yaml")
-        with io.open(input_file, "r") as f:
-            with mock.patch("sys.stdout", console_out):
-                with mock.patch("sys.stdin", f):
-                    args = ["--conf", self.default_config_file, "test"]
-                    self.execute_jenkins_jobs_with_args(args)
-
-        xml_content = io.open(
-            os.path.join(self.fixtures_path, "cmd-001.xml"), "r", encoding="utf-8"
-        ).read()
-        value = console_out.getvalue().decode("utf-8")
-        self.assertEqual(value, xml_content)
-
-    def test_stream_input_output_ascii_encoding(self):
-        """
-        Run test mode simulating using pipes for input and output using
-        ascii encoding with unicode input
-        """
-        console_out = io.BytesIO()
-        console_out.encoding = "ascii"
-
-        input_file = os.path.join(self.fixtures_path, "cmd-001.yaml")
-        with io.open(input_file, "r") as f:
-            with mock.patch("sys.stdout", console_out):
-                with mock.patch("sys.stdin", f):
-                    args = ["--conf", self.default_config_file, "test"]
-                    self.execute_jenkins_jobs_with_args(args)
-
-        xml_content = io.open(
-            os.path.join(self.fixtures_path, "cmd-001.xml"), "r", encoding="utf-8"
-        ).read()
-        value = console_out.getvalue().decode("ascii")
-        self.assertEqual(value, xml_content)
-
-    def test_stream_output_ascii_encoding_invalid_char(self):
-        """
-        Run test mode simulating using pipes for input and output using
-        ascii encoding for output with include containing a character
-        that cannot be converted.
-        """
-        console_out = io.BytesIO()
-        console_out.encoding = "ascii"
-
-        input_file = os.path.join(self.fixtures_path, "unicode001.yaml")
-        with io.open(input_file, "r", encoding="utf-8") as f:
-            with mock.patch("sys.stdout", console_out):
-                with mock.patch("sys.stdin", f):
-                    args = ["--conf", self.default_config_file, "test"]
-                    jenkins_jobs = entry.JenkinsJobs(args)
-                    e = self.assertRaises(UnicodeError, jenkins_jobs.execute)
-        self.assertIn("'ascii' codec can't encode character", str(e))
-
-    @mock.patch("jenkins_jobs.cli.subcommand.update.XmlJobGenerator.generateXML")
-    @mock.patch("jenkins_jobs.cli.subcommand.update.ModuleRegistry")
-    def test_plugins_info_stub_option(self, registry_mock, generateXML_mock):
-        """
-        Test handling of plugins_info stub option.
-        """
-        plugins_info_stub_yaml_file = os.path.join(
-            self.fixtures_path, "plugins-info.yaml"
-        )
-        args = [
-            "--conf",
-            os.path.join(self.fixtures_path, "cmd-001.conf"),
-            "test",
-            "-p",
-            plugins_info_stub_yaml_file,
-            os.path.join(self.fixtures_path, "cmd-001.yaml"),
-        ]
-
-        self.execute_jenkins_jobs_with_args(args)
-
-        with io.open(plugins_info_stub_yaml_file, "r", encoding="utf-8") as yaml_file:
-            plugins_info_list = yaml.safe_load(yaml_file)
-
-        registry_mock.assert_called_with(mock.ANY, plugins_info_list)
-
-    @mock.patch("jenkins_jobs.cli.subcommand.update.XmlJobGenerator.generateXML")
-    @mock.patch("jenkins_jobs.cli.subcommand.update.ModuleRegistry")
-    def test_bogus_plugins_info_stub_option(self, registry_mock, generateXML_mock):
-        """
-        Verify that a JenkinsJobException is raised if the plugins_info stub
-        file does not yield a list as its top-level object.
-        """
-        plugins_info_stub_yaml_file = os.path.join(
-            self.fixtures_path, "bogus-plugins-info.yaml"
-        )
-        args = [
-            "--conf",
-            os.path.join(self.fixtures_path, "cmd-001.conf"),
-            "test",
-            "-p",
-            plugins_info_stub_yaml_file,
-            os.path.join(self.fixtures_path, "cmd-001.yaml"),
-        ]
-
-        stderr = StringIO()
-        with mock.patch("sys.stderr", stderr):
-            self.assertRaises(SystemExit, entry.JenkinsJobs, args)
-        self.assertIn("must contain a Yaml list", stderr.getvalue())
+def test_non_existing_job(fixtures_dir, default_config_file, execute_jenkins_jobs):
+    """
+    Run test mode and pass a non-existing job name
+    (probably better to fail here)
+    """
+    args = [
+        "--conf",
+        default_config_file,
+        "test",
+        str(fixtures_dir / "cmd-001.yaml"),
+        "invalid",
+    ]
+    execute_jenkins_jobs(args)
 
 
-class TestJenkinsGetPluginInfoError(CmdTestsBase):
-    """Test without mocking get_plugins_info.
+def test_valid_job(fixtures_dir, default_config_file, execute_jenkins_jobs):
+    """
+    Run test mode and pass a valid job name
+    """
+    args = [
+        "--conf",
+        default_config_file,
+        "test",
+        str(fixtures_dir / "cmd-001.yaml"),
+        "foo-job",
+    ]
+    execute_jenkins_jobs(args)
 
-    This test class is used for testing the 'test' subcommand when we want
-    to validate its behavior without mocking
-    jenkins_jobs.builder.JenkinsManager.get_plugins_info
+
+def test_console_output(
+    capsys, fixtures_dir, default_config_file, execute_jenkins_jobs
+):
+    """
+    Run test mode and verify that resulting XML gets sent to the console.
     """
 
-    @mock.patch("jenkins.Jenkins.get_plugins")
-    def test_console_output_jenkins_connection_failure_warning(self, get_plugins_mock):
-        """
-        Run test mode and verify that failed Jenkins connection attempt
-        exception does not bubble out of cmd.main. Ideally, we would also test
-        that an appropriate message is logged to stderr but it's somewhat
-        difficult to figure out how to actually enable stderr in this test
-        suite.
-        """
+    args = [
+        "--conf",
+        default_config_file,
+        "test",
+        str(fixtures_dir / "cmd-001.yaml"),
+    ]
+    execute_jenkins_jobs(args)
 
-        get_plugins_mock.side_effect = jenkins.JenkinsException("Connection refused")
-        with mock.patch("sys.stdout"):
-            try:
-                args = [
-                    "--conf",
-                    self.default_config_file,
-                    "test",
-                    os.path.join(self.fixtures_path, "cmd-001.yaml"),
-                ]
-                self.execute_jenkins_jobs_with_args(args)
-            except jenkins.JenkinsException:
-                self.fail("jenkins.JenkinsException propagated to main")
-            except Exception:
-                pass  # only care about jenkins.JenkinsException for now
+    expected_output = fixtures_dir.joinpath("cmd-001.xml").read_text()
+    captured = capsys.readouterr()
+    assert captured.out == expected_output
 
-    @mock.patch("jenkins.Jenkins.get_plugins")
-    def test_skip_plugin_retrieval_if_no_config_provided(self, get_plugins_mock):
-        """
-        Verify that retrieval of information from Jenkins instance about its
-        plugins will be skipped when run if no config file provided.
-        """
-        with mock.patch("sys.stdout", new_callable=io.BytesIO):
-            args = [
-                "--conf",
-                self.default_config_file,
-                "test",
-                os.path.join(self.fixtures_path, "cmd-001.yaml"),
-            ]
-            entry.JenkinsJobs(args)
-        self.assertFalse(get_plugins_mock.called)
 
-    @mock.patch("jenkins.Jenkins.get_plugins_info")
-    def test_skip_plugin_retrieval_if_disabled(self, get_plugins_mock):
-        """
-        Verify that retrieval of information from Jenkins instance about its
-        plugins will be skipped when run if a config file provided and disables
-        querying through a config option.
-        """
-        with mock.patch("sys.stdout", new_callable=io.BytesIO):
-            args = [
-                "--conf",
-                os.path.join(self.fixtures_path, "disable-query-plugins.conf"),
-                "test",
-                os.path.join(self.fixtures_path, "cmd-001.yaml"),
-            ]
-            entry.JenkinsJobs(args)
-        self.assertFalse(get_plugins_mock.called)
+def test_output_dir(tmp_path, fixtures_dir, default_config_file, execute_jenkins_jobs):
+    """
+    Run test mode with output to directory and verify that output files are
+    generated.
+    """
+    args = ["test", str(fixtures_dir / "cmd-001.yaml"), "-o", str(tmp_path)]
+    execute_jenkins_jobs(args)
+    assert tmp_path.joinpath("foo-job").exists()
+
+
+def test_output_dir_config_xml(tmp_path, fixtures_dir, execute_jenkins_jobs):
+    """
+    Run test mode with output to directory in "config.xml" mode and verify
+    that output files are generated.
+    """
+    args = [
+        "test",
+        str(fixtures_dir / "cmd-001.yaml"),
+        "-o",
+        str(tmp_path),
+        "--config-xml",
+    ]
+    execute_jenkins_jobs(args)
+    assert tmp_path.joinpath("foo-job", "config.xml").exists()
+
+
+def test_stream_input_output_no_encoding_exceed_recursion(
+    mocker, fixtures_dir, execute_jenkins_jobs
+):
+    """
+    Test that we don't have issues processing large number of jobs and
+    outputting the result if the encoding is not set.
+    """
+    console_out = io.BytesIO()
+    console_out.encoding = None
+    mocker.patch("sys.stdout", console_out)
+
+    input = fixtures_dir.joinpath("large-number-of-jobs-001.yaml").read_bytes()
+    mocker.patch("sys.stdin", io.BytesIO(input))
+
+    args = ["test"]
+    execute_jenkins_jobs(args)
+
+
+def test_stream_input_output_utf8_encoding(
+    capsys, mocker, fixtures_dir, default_config_file, execute_jenkins_jobs
+):
+    """
+    Run test mode simulating using pipes for input and output using
+    utf-8 encoding
+    """
+    input = fixtures_dir.joinpath("cmd-001.yaml").read_bytes()
+    mocker.patch("sys.stdin", io.BytesIO(input))
+
+    args = ["--conf", default_config_file, "test"]
+    execute_jenkins_jobs(args)
+
+    expected_output = fixtures_dir.joinpath("cmd-001.xml").read_text()
+    captured = capsys.readouterr()
+    assert captured.out == expected_output
+
+
+def test_stream_input_output_ascii_encoding(
+    mocker, fixtures_dir, default_config_file, execute_jenkins_jobs
+):
+    """
+    Run test mode simulating using pipes for input and output using
+    ascii encoding with unicode input
+    """
+    console_out = io.BytesIO()
+    console_out.encoding = "ascii"
+    mocker.patch("sys.stdout", console_out)
+
+    input = fixtures_dir.joinpath("cmd-001.yaml").read_bytes()
+    mocker.patch("sys.stdin", io.BytesIO(input))
+
+    args = ["--conf", default_config_file, "test"]
+    execute_jenkins_jobs(args)
+
+    expected_output = fixtures_dir.joinpath("cmd-001.xml").read_text()
+    output = console_out.getvalue().decode("ascii")
+    assert output == expected_output
+
+
+def test_stream_output_ascii_encoding_invalid_char(
+    mocker, fixtures_dir, default_config_file
+):
+    """
+    Run test mode simulating using pipes for input and output using
+    ascii encoding for output with include containing a character
+    that cannot be converted.
+    """
+    console_out = io.BytesIO()
+    console_out.encoding = "ascii"
+    mocker.patch("sys.stdout", console_out)
+
+    input = fixtures_dir.joinpath("unicode001.yaml").read_bytes()
+    mocker.patch("sys.stdin", io.BytesIO(input))
+
+    args = ["--conf", default_config_file, "test"]
+    jenkins_jobs = entry.JenkinsJobs(args)
+    with pytest.raises(UnicodeError) as excinfo:
+        jenkins_jobs.execute()
+    assert "'ascii' codec can't encode character" in str(excinfo.value)
+
+
+def test_plugins_info_stub_option(mocker, fixtures_dir, execute_jenkins_jobs):
+    """
+    Test handling of plugins_info stub option.
+    """
+    mocker.patch("jenkins_jobs.cli.subcommand.update.XmlJobGenerator.generateXML")
+    registry_mock = mocker.patch("jenkins_jobs.cli.subcommand.update.ModuleRegistry")
+
+    plugins_info_stub_yaml_file = fixtures_dir / "plugins-info.yaml"
+    args = [
+        "--conf",
+        str(fixtures_dir / "cmd-001.conf"),
+        "test",
+        "-p",
+        str(plugins_info_stub_yaml_file),
+        str(fixtures_dir / "cmd-001.yaml"),
+    ]
+
+    execute_jenkins_jobs(args)
+
+    plugins_info_list = yaml.safe_load(plugins_info_stub_yaml_file.read_text())
+
+    registry_mock.assert_called_with(mock.ANY, plugins_info_list)
+
+
+def test_bogus_plugins_info_stub_option(
+    capsys, mocker, fixtures_dir, default_config_file
+):
+    """
+    Verify that a JenkinsJobException is raised if the plugins_info stub
+    file does not yield a list as its top-level object.
+    """
+    mocker.patch("jenkins_jobs.cli.subcommand.update.XmlJobGenerator.generateXML")
+    mocker.patch("jenkins_jobs.cli.subcommand.update.ModuleRegistry")
+
+    plugins_info_stub_yaml_file = fixtures_dir / "bogus-plugins-info.yaml"
+    args = [
+        "--conf",
+        str(fixtures_dir / "cmd-001.conf"),
+        "test",
+        "-p",
+        str(plugins_info_stub_yaml_file),
+        str(fixtures_dir / "cmd-001.yaml"),
+    ]
+
+    with pytest.raises(SystemExit):
+        entry.JenkinsJobs(args)
+
+    captured = capsys.readouterr()
+    assert "must contain a Yaml list" in captured.err
+
+
+# Test without mocking get_plugins_info.
+#
+# This test class is used for testing the 'test' subcommand when we want
+# to validate its behavior without mocking
+# jenkins_jobs.builder.JenkinsManager.get_plugins_info
+
+
+def test_console_output_jenkins_connection_failure_warning(
+    caplog, mocker, fixtures_dir, execute_jenkins_jobs
+):
+    """
+    Run test mode and verify that failed Jenkins connection attempt
+    exception does not bubble out of cmd.main.
+    """
+    mocker.patch(
+        "jenkins.Jenkins.get_plugins",
+        side_effect=jenkins.JenkinsException("Connection refused"),
+    )
+
+    try:
+        args = [
+            "--conf",
+            str(fixtures_dir / "enable-query-plugins.conf"),
+            "test",
+            str(fixtures_dir / "cmd-001.yaml"),
+        ]
+        execute_jenkins_jobs(args)
+    except jenkins.JenkinsException:
+        pytest.fail("jenkins.JenkinsException propagated to main")
+    except Exception:
+        pass  # only care about jenkins.JenkinsException for now
+    assert "Unable to retrieve Jenkins Plugin Info" in caplog.text
+
+
+def test_skip_plugin_retrieval_if_no_config_provided(
+    mocker, fixtures_dir, default_config_file
+):
+    """
+    Verify that retrieval of information from Jenkins instance about its
+    plugins will be skipped when run if no config file provided.
+    """
+    get_plugins_mock = mocker.patch("jenkins.Jenkins.get_plugins")
+    args = [
+        "--conf",
+        default_config_file,
+        "test",
+        str(fixtures_dir / "cmd-001.yaml"),
+    ]
+    entry.JenkinsJobs(args)
+    assert not get_plugins_mock.called
+
+
+@mock.patch("jenkins.Jenkins.get_plugins_info")
+def test_skip_plugin_retrieval_if_disabled(mocker, fixtures_dir):
+    """
+    Verify that retrieval of information from Jenkins instance about its
+    plugins will be skipped when run if a config file provided and disables
+    querying through a config option.
+    """
+    get_plugins_mock = mocker.patch("jenkins.Jenkins.get_plugins")
+    args = [
+        "--conf",
+        str(fixtures_dir / "disable-query-plugins.conf"),
+        "test",
+        str(fixtures_dir / "cmd-001.yaml"),
+    ]
+    entry.JenkinsJobs(args)
+    assert not get_plugins_mock.called
 
 
 class MatchesDirMissingFilesMismatch(object):
@@ -377,98 +376,97 @@ class MatchesDir(object):
         return None
 
 
-@mock.patch("jenkins_jobs.builder.JenkinsManager.get_plugins_info", mock.MagicMock)
-class TestTestsMultiPath(CmdTestsBase):
-    def setUp(self):
-        super(TestTestsMultiPath, self).setUp()
+@pytest.fixture
+def multipath(fixtures_dir):
+    path_list = [
+        str(fixtures_dir / "multi-path/yamldirs/" / p) for p in ["dir1", "dir2"]
+    ]
+    return os.pathsep.join(path_list)
 
-        path_list = [
-            os.path.join(self.fixtures_path, "multi-path/yamldirs/", p)
-            for p in ["dir1", "dir2"]
-        ]
-        self.multipath = os.pathsep.join(path_list)
-        self.output_dir = tempfile.mkdtemp()
 
-    def check_dirs_match(self, expected_dir):
-        try:
-            self.assertThat(self.output_dir, MatchesDir(expected_dir))
-        except testtools.matchers.MismatchError:
-            raise
-        else:
-            shutil.rmtree(self.output_dir)
+@pytest.fixture
+def output_dir(tmp_path):
+    dir = tmp_path / "output"
+    dir.mkdir()
+    return str(dir)
 
-    def test_multi_path(self):
-        """
-        Run test mode and pass multiple paths.
-        """
-        args = [
-            "--conf",
-            self.default_config_file,
-            "test",
-            "-o",
-            self.output_dir,
-            self.multipath,
-        ]
 
-        self.execute_jenkins_jobs_with_args(args)
-        self.check_dirs_match(
-            os.path.join(self.fixtures_path, "multi-path/output_simple")
-        )
+def test_multi_path(
+    fixtures_dir, default_config_file, execute_jenkins_jobs, output_dir, multipath
+):
+    """
+    Run test mode and pass multiple paths.
+    """
+    args = [
+        "--conf",
+        default_config_file,
+        "test",
+        "-o",
+        output_dir,
+        multipath,
+    ]
 
-    def test_recursive_multi_path_command_line(self):
-        """
-        Run test mode and pass multiple paths with recursive path option.
-        """
-        args = [
-            "--conf",
-            self.default_config_file,
-            "test",
-            "-o",
-            self.output_dir,
-            "-r",
-            self.multipath,
-        ]
+    execute_jenkins_jobs(args)
+    assert_that(output_dir, MatchesDir(fixtures_dir / "multi-path/output_simple"))
 
-        self.execute_jenkins_jobs_with_args(args)
-        self.check_dirs_match(
-            os.path.join(self.fixtures_path, "multi-path/output_recursive")
-        )
 
-    def test_recursive_multi_path_config_file(self):
-        # test recursive set in configuration file
-        args = [
-            "--conf",
-            os.path.join(self.fixtures_path, "multi-path/builder-recursive.ini"),
-            "test",
-            "-o",
-            self.output_dir,
-            self.multipath,
-        ]
-        self.execute_jenkins_jobs_with_args(args)
-        self.check_dirs_match(
-            os.path.join(self.fixtures_path, "multi-path/output_recursive")
-        )
+def test_recursive_multi_path_command_line(
+    fixtures_dir, default_config_file, execute_jenkins_jobs, output_dir, multipath
+):
+    """
+    Run test mode and pass multiple paths with recursive path option.
+    """
+    args = [
+        "--conf",
+        default_config_file,
+        "test",
+        "-o",
+        output_dir,
+        "-r",
+        multipath,
+    ]
 
-    def test_recursive_multi_path_with_excludes(self):
-        """
-        Run test mode and pass multiple paths with recursive path option.
-        """
-        exclude_path = os.path.join(self.fixtures_path, "multi-path/yamldirs/dir2/dir1")
-        args = [
-            "--conf",
-            self.default_config_file,
-            "test",
-            "-x",
-            exclude_path,
-            "-o",
-            self.output_dir,
-            "-r",
-            self.multipath,
-        ]
+    execute_jenkins_jobs(args)
+    assert_that(output_dir, MatchesDir(fixtures_dir / "multi-path/output_recursive"))
 
-        self.execute_jenkins_jobs_with_args(args)
-        self.check_dirs_match(
-            os.path.join(
-                self.fixtures_path, "multi-path/output_recursive_with_excludes"
-            )
-        )
+
+def test_recursive_multi_path_config_file(
+    fixtures_dir, execute_jenkins_jobs, output_dir, multipath
+):
+    # test recursive set in configuration file
+    args = [
+        "--conf",
+        str(fixtures_dir / "multi-path/builder-recursive.ini"),
+        "test",
+        "-o",
+        output_dir,
+        multipath,
+    ]
+    execute_jenkins_jobs(args)
+    assert_that(output_dir, MatchesDir(fixtures_dir / "multi-path/output_recursive"))
+
+
+def test_recursive_multi_path_with_excludes(
+    fixtures_dir, default_config_file, execute_jenkins_jobs, output_dir, multipath
+):
+    """
+    Run test mode and pass multiple paths with recursive path option.
+    """
+    exclude_path = fixtures_dir / "multi-path/yamldirs/dir2/dir1"
+    args = [
+        "--conf",
+        default_config_file,
+        "test",
+        "-x",
+        str(exclude_path),
+        "-o",
+        output_dir,
+        "-r",
+        multipath,
+    ]
+
+    execute_jenkins_jobs(args)
+    assert_that(
+        output_dir,
+        MatchesDir(fixtures_dir / "multi-path/output_recursive_with_excludes"),
+    )
diff --git a/tests/cmd/subcommands/test_update.py b/tests/cmd/subcommands/test_update.py
index 3206d71ff..3cd7de392 100644
--- a/tests/cmd/subcommands/test_update.py
+++ b/tests/cmd/subcommands/test_update.py
@@ -18,107 +18,110 @@
 # of actions by the JJB library, usually through interaction with the
 # python-jenkins library.
 
-import os
-import six
+from unittest import mock
 
-from tests.base import mock
-from tests.cmd.test_cmd import CmdTestsBase
+import pytest
 
 
-@mock.patch("jenkins_jobs.builder.JenkinsManager.get_plugins_info", mock.MagicMock)
-class UpdateTests(CmdTestsBase):
-    @mock.patch("jenkins_jobs.builder.jenkins.Jenkins.job_exists")
-    @mock.patch("jenkins_jobs.builder.jenkins.Jenkins.get_all_jobs")
-    @mock.patch("jenkins_jobs.builder.jenkins.Jenkins.reconfig_job")
-    def test_update_jobs(
-        self, jenkins_reconfig_job, jenkins_get_jobs, jenkins_job_exists
-    ):
-        """
-        Test update_job is called
-        """
-        path = os.path.join(self.fixtures_path, "cmd-002.yaml")
-        args = ["--conf", self.default_config_file, "update", path]
+def test_update_jobs(mocker, fixtures_dir, default_config_file, execute_jenkins_jobs):
+    """
+    Test update_job is called
+    """
+    mocker.patch("jenkins_jobs.builder.jenkins.Jenkins.job_exists")
+    mocker.patch("jenkins_jobs.builder.jenkins.Jenkins.get_all_jobs")
+    reconfig_job = mocker.patch("jenkins_jobs.builder.jenkins.Jenkins.reconfig_job")
 
-        self.execute_jenkins_jobs_with_args(args)
+    path = fixtures_dir / "cmd-002.yaml"
+    args = ["--conf", default_config_file, "update", str(path)]
 
-        jenkins_reconfig_job.assert_has_calls(
-            [
-                mock.call(job_name, mock.ANY)
-                for job_name in ["bar001", "bar002", "baz001", "bam001"]
-            ],
-            any_order=True,
-        )
+    execute_jenkins_jobs(args)
 
-    @mock.patch("jenkins_jobs.builder.JenkinsManager.is_job", return_value=True)
-    @mock.patch("jenkins_jobs.builder.JenkinsManager.get_jobs")
-    @mock.patch("jenkins_jobs.builder.JenkinsManager.get_job_md5")
-    @mock.patch("jenkins_jobs.builder.JenkinsManager.update_job")
-    def test_update_jobs_decode_job_output(
-        self, update_job_mock, get_job_md5_mock, get_jobs_mock, is_job_mock
-    ):
-        """
-        Test that job xml output has been decoded before attempting to update
-        """
-        # don't care about the value returned here
-        update_job_mock.return_value = ([], 0)
+    reconfig_job.assert_has_calls(
+        [
+            mock.call(job_name, mock.ANY)
+            for job_name in ["bar001", "bar002", "baz001", "bam001"]
+        ],
+        any_order=True,
+    )
 
-        path = os.path.join(self.fixtures_path, "cmd-002.yaml")
-        args = ["--conf", self.default_config_file, "update", path]
 
-        self.execute_jenkins_jobs_with_args(args)
-        self.assertTrue(isinstance(update_job_mock.call_args[0][1], six.text_type))
+def test_update_jobs_decode_job_output(
+    mocker, fixtures_dir, default_config_file, execute_jenkins_jobs
+):
+    """
+    Test that job xml output has been decoded before attempting to update
+    """
+    mocker.patch("jenkins_jobs.builder.JenkinsManager.is_job", return_value=True)
+    mocker.patch("jenkins_jobs.builder.JenkinsManager.get_jobs")
+    mocker.patch("jenkins_jobs.builder.JenkinsManager.get_job_md5")
+    update_job_mock = mocker.patch("jenkins_jobs.builder.JenkinsManager.update_job")
 
-    @mock.patch("jenkins_jobs.builder.jenkins.Jenkins.job_exists")
-    @mock.patch("jenkins_jobs.builder.jenkins.Jenkins.get_all_jobs")
-    @mock.patch("jenkins_jobs.builder.jenkins.Jenkins.reconfig_job")
-    @mock.patch("jenkins_jobs.builder.jenkins.Jenkins.delete_job")
-    def test_update_jobs_and_delete_old(
-        self,
-        jenkins_delete_job,
-        jenkins_reconfig_job,
-        jenkins_get_all_jobs,
-        jenkins_job_exists,
-    ):
-        """Test update behaviour with --delete-old option.
+    # don't care about the value returned here
+    update_job_mock.return_value = ([], 0)
 
-        * mock out a call to jenkins.Jenkins.get_jobs() to return a known list
-          of job names.
-        * mock out a call to jenkins.Jenkins.reconfig_job() and
-          jenkins.Jenkins.delete_job() to detect calls being made to determine
-          that JJB does correctly delete the jobs it should delete when passed
-          a specific set of inputs.
-        * mock out a call to jenkins.Jenkins.job_exists() to always return
-          True.
-        """
-        yaml_jobs = ["bar001", "bar002", "baz001", "bam001"]
-        extra_jobs = ["old_job001", "old_job002", "unmanaged"]
+    path = fixtures_dir / "cmd-002.yaml"
+    args = ["--conf", default_config_file, "update", str(path)]
 
-        path = os.path.join(self.fixtures_path, "cmd-002.yaml")
-        args = ["--conf", self.default_config_file, "update", "--delete-old", path]
+    execute_jenkins_jobs(args)
+    assert isinstance(update_job_mock.call_args[0][1], str)
 
-        jenkins_get_all_jobs.return_value = [
-            {"fullname": name} for name in yaml_jobs + extra_jobs
-        ]
 
-        with mock.patch(
-            "jenkins_jobs.builder.JenkinsManager.is_managed",
-            side_effect=(lambda name: name != "unmanaged"),
-        ):
-            self.execute_jenkins_jobs_with_args(args)
+def test_update_jobs_and_delete_old(
+    mocker, fixtures_dir, default_config_file, execute_jenkins_jobs
+):
+    """Test update behaviour with --delete-old option.
 
-        jenkins_reconfig_job.assert_has_calls(
-            [mock.call(job_name, mock.ANY) for job_name in yaml_jobs], any_order=True
-        )
-        calls = [mock.call(name) for name in extra_jobs if name != "unmanaged"]
-        jenkins_delete_job.assert_has_calls(calls)
-        # to ensure only the calls we expected were made, have to check
-        # there were no others, as no API call for assert_has_only_calls
-        self.assertEqual(jenkins_delete_job.call_count, len(calls))
+    * mock out a call to jenkins.Jenkins.get_jobs() to return a known list
+      of job names.
+    * mock out a call to jenkins.Jenkins.reconfig_job() and
+      jenkins.Jenkins.delete_job() to detect calls being made to determine
+      that JJB does correctly delete the jobs it should delete when passed
+      a specific set of inputs.
+    * mock out a call to jenkins.Jenkins.job_exists() to always return
+      True.
+    """
+    mocker.patch("jenkins_jobs.builder.jenkins.Jenkins.job_exists")
+    jenkins_get_all_jobs = mocker.patch(
+        "jenkins_jobs.builder.jenkins.Jenkins.get_all_jobs"
+    )
+    jenkins_reconfig_job = mocker.patch(
+        "jenkins_jobs.builder.jenkins.Jenkins.reconfig_job"
+    )
+    jenkins_delete_job = mocker.patch("jenkins_jobs.builder.jenkins.Jenkins.delete_job")
 
-    def test_update_timeout_not_set(self):
-        """Validate update timeout behavior when timeout not explicitly configured."""
-        self.skipTest("TODO: Develop actual update timeout test approach.")
+    yaml_jobs = ["bar001", "bar002", "baz001", "bam001"]
+    extra_jobs = ["old_job001", "old_job002", "unmanaged"]
 
-    def test_update_timeout_set(self):
-        """Validate update timeout behavior when timeout is explicitly configured."""
-        self.skipTest("TODO: Develop actual update timeout test approach.")
+    path = fixtures_dir / "cmd-002.yaml"
+    args = ["--conf", default_config_file, "update", "--delete-old", str(path)]
+
+    jenkins_get_all_jobs.return_value = [
+        {"fullname": name} for name in yaml_jobs + extra_jobs
+    ]
+
+    mocker.patch(
+        "jenkins_jobs.builder.JenkinsManager.is_managed",
+        side_effect=(lambda name: name != "unmanaged"),
+    )
+    execute_jenkins_jobs(args)
+
+    jenkins_reconfig_job.assert_has_calls(
+        [mock.call(job_name, mock.ANY) for job_name in yaml_jobs], any_order=True
+    )
+    calls = [mock.call(name) for name in extra_jobs if name != "unmanaged"]
+    jenkins_delete_job.assert_has_calls(calls)
+    # to ensure only the calls we expected were made, have to check
+    # there were no others, as no API call for assert_has_only_calls
+    assert jenkins_delete_job.call_count == len(calls)
+
+
+@pytest.mark.skip(reason="TODO: Develop actual update timeout test approach.")
+def test_update_timeout_not_set():
+    """Validate update timeout behavior when timeout not explicitly configured."""
+    pass
+
+
+@pytest.mark.skip(reason="TODO: Develop actual update timeout test approach.")
+def test_update_timeout_set():
+    """Validate update timeout behavior when timeout is explicitly configured."""
+    pass
diff --git a/tests/cmd/test_cmd.py b/tests/cmd/test_cmd.py
index e847ec39e..0da28dddd 100644
--- a/tests/cmd/test_cmd.py
+++ b/tests/cmd/test_cmd.py
@@ -1,37 +1,11 @@
-import os
+import pytest
 
 from jenkins_jobs.cli import entry
-from tests import base
-from tests.base import mock
 
 
-class CmdTestsBase(base.BaseTestCase):
-
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-
-    def setUp(self):
-        super(CmdTestsBase, self).setUp()
-
-        # Testing the cmd module can sometimes result in the JobCache class
-        # attempting to create the cache directory multiple times as the tests
-        # are run in parallel.  Stub out the JobCache to ensure that each
-        # test can safely create the cache directory without risk of
-        # interference.
-        cache_patch = mock.patch("jenkins_jobs.builder.JobCache", autospec=True)
-        self.cache_mock = cache_patch.start()
-        self.addCleanup(cache_patch.stop)
-
-        self.default_config_file = os.path.join(self.fixtures_path, "empty_builder.ini")
-
-    def execute_jenkins_jobs_with_args(self, args):
-        jenkins_jobs = entry.JenkinsJobs(args)
-        jenkins_jobs.execute()
-
-
-class TestCmd(CmdTestsBase):
-    def test_with_empty_args(self):
-        """
-        User passes no args, should fail with SystemExit
-        """
-        with mock.patch("sys.stderr"):
-            self.assertRaises(SystemExit, entry.JenkinsJobs, [])
+def test_with_empty_args(mocker):
+    """
+    User passes no args, should fail with SystemExit
+    """
+    with pytest.raises(SystemExit):
+        entry.JenkinsJobs([])
diff --git a/tests/cmd/test_config.py b/tests/cmd/test_config.py
index 96c2c463a..852c8a22b 100644
--- a/tests/cmd/test_config.py
+++ b/tests/cmd/test_config.py
@@ -1,160 +1,177 @@
 import io
-import os
+from pathlib import Path
 
-from tests.base import mock
-from tests.cmd.test_cmd import CmdTestsBase
+import pytest
 
 from jenkins_jobs.cli import entry
 from jenkins_jobs import builder
 
-patch = mock.patch
+
+global_conf = "/etc/jenkins_jobs/jenkins_jobs.ini"
+user_conf = Path.home() / ".config" / "jenkins_jobs" / "jenkins_jobs.ini"
+local_conf = Path(__file__).parent / "jenkins_jobs.ini"
 
 
-@mock.patch("jenkins_jobs.builder.JenkinsManager.get_plugins_info", mock.MagicMock)
-class TestConfigs(CmdTestsBase):
+def test_use_global_config(mocker, default_config_file):
+    """
+    Verify that JJB uses the global config file by default
+    """
+    mocker.patch("jenkins_jobs.builder.JenkinsManager.get_plugins_info")
 
-    global_conf = "/etc/jenkins_jobs/jenkins_jobs.ini"
-    user_conf = os.path.join(
-        os.path.expanduser("~"), ".config", "jenkins_jobs", "jenkins_jobs.ini"
-    )
-    local_conf = os.path.join(os.path.dirname(__file__), "jenkins_jobs.ini")
+    args = ["test", "foo"]
 
-    def test_use_global_config(self):
-        """
-        Verify that JJB uses the global config file by default
-        """
+    default_io_open = io.open
 
-        args = ["test", "foo"]
-        conffp = io.open(self.default_config_file, "r", encoding="utf-8")
+    def io_open(file, *args, **kw):
+        if file == global_conf:
+            default_io_open(default_config_file, "r", encoding="utf-8")
+        else:
+            return default_io_open(file, *args, **kw)
 
-        with patch("os.path.isfile", return_value=True) as m_isfile:
+    def isfile(path):
+        if path == global_conf:
+            return True
+        return False
 
-            def side_effect(path):
-                if path == self.global_conf:
-                    return True
-                return False
+    mocker.patch("os.path.isfile", side_effect=isfile)
+    mocked_open = mocker.patch("io.open", side_effect=io_open)
 
-            m_isfile.side_effect = side_effect
+    entry.JenkinsJobs(args, config_file_required=True)
 
-            with patch("io.open", return_value=conffp) as m_open:
-                entry.JenkinsJobs(args, config_file_required=True)
-                m_open.assert_called_with(self.global_conf, "r", encoding="utf-8")
+    mocked_open.assert_called_with(global_conf, "r", encoding="utf-8")
 
-    def test_use_config_in_user_home(self):
-        """
-        Verify that JJB uses config file in user home folder
-        """
 
-        args = ["test", "foo"]
+def test_use_config_in_user_home(mocker, default_config_file):
+    """
+    Verify that JJB uses config file in user home folder
+    """
 
-        conffp = io.open(self.default_config_file, "r", encoding="utf-8")
-        with patch("os.path.isfile", return_value=True) as m_isfile:
+    args = ["test", "foo"]
 
-            def side_effect(path):
-                if path == self.user_conf:
-                    return True
-                return False
+    default_io_open = io.open
 
-            m_isfile.side_effect = side_effect
-            with patch("io.open", return_value=conffp) as m_open:
-                entry.JenkinsJobs(args, config_file_required=True)
-                m_open.assert_called_with(self.user_conf, "r", encoding="utf-8")
+    def io_open(file, *args, **kw):
+        if file == str(user_conf):
+            default_io_open(default_config_file, "r", encoding="utf-8")
+        else:
+            return default_io_open(file, *args, **kw)
 
-    def test_non_existing_config_dir(self):
-        """
-        Run test mode and pass a non-existing configuration directory
-        """
-        args = ["--conf", self.default_config_file, "test", "foo"]
-        jenkins_jobs = entry.JenkinsJobs(args)
-        self.assertRaises(IOError, jenkins_jobs.execute)
+    def isfile(path):
+        if path == str(user_conf):
+            return True
+        return False
 
-    def test_non_existing_config_file(self):
-        """
-        Run test mode and pass a non-existing configuration file
-        """
-        args = ["--conf", self.default_config_file, "test", "non-existing.yaml"]
-        jenkins_jobs = entry.JenkinsJobs(args)
-        self.assertRaises(IOError, jenkins_jobs.execute)
+    mocker.patch("os.path.isfile", side_effect=isfile)
+    mocked_open = mocker.patch("io.open", side_effect=io_open)
 
-    def test_config_options_not_replaced_by_cli_defaults(self):
-        """
-        Run test mode and check config settings from conf file retained
-        when none of the global CLI options are set.
-        """
-        config_file = os.path.join(self.fixtures_path, "settings_from_config.ini")
-        args = ["--conf", config_file, "test", "dummy.yaml"]
-        jenkins_jobs = entry.JenkinsJobs(args)
-        jjb_config = jenkins_jobs.jjb_config
-        self.assertEqual(jjb_config.jenkins["user"], "jenkins_user")
-        self.assertEqual(jjb_config.jenkins["password"], "jenkins_password")
-        self.assertEqual(jjb_config.builder["ignore_cache"], True)
-        self.assertEqual(jjb_config.builder["flush_cache"], True)
-        self.assertEqual(jjb_config.builder["update"], "all")
-        self.assertEqual(jjb_config.yamlparser["allow_empty_variables"], True)
+    entry.JenkinsJobs(args, config_file_required=True)
+    mocked_open.assert_called_with(str(user_conf), "r", encoding="utf-8")
 
-    def test_config_options_overriden_by_cli(self):
-        """
-        Run test mode and check config settings from conf file retained
-        when none of the global CLI options are set.
-        """
-        args = [
-            "--user",
-            "myuser",
-            "--password",
-            "mypassword",
-            "--ignore-cache",
-            "--flush-cache",
-            "--allow-empty-variables",
-            "test",
-            "dummy.yaml",
-        ]
-        jenkins_jobs = entry.JenkinsJobs(args)
-        jjb_config = jenkins_jobs.jjb_config
-        self.assertEqual(jjb_config.jenkins["user"], "myuser")
-        self.assertEqual(jjb_config.jenkins["password"], "mypassword")
-        self.assertEqual(jjb_config.builder["ignore_cache"], True)
-        self.assertEqual(jjb_config.builder["flush_cache"], True)
-        self.assertEqual(jjb_config.yamlparser["allow_empty_variables"], True)
 
-    @mock.patch("jenkins_jobs.cli.subcommand.update.JenkinsManager")
-    def test_update_timeout_not_set(self, jenkins_mock):
-        """Check that timeout is left unset
+def test_non_existing_config_dir(default_config_file):
+    """
+    Run test mode and pass a non-existing configuration directory
+    """
+    args = ["--conf", default_config_file, "test", "foo"]
+    jenkins_jobs = entry.JenkinsJobs(args)
+    with pytest.raises(IOError):
+        jenkins_jobs.execute()
 
-        Test that the Jenkins object has the timeout set on it only when
-        provided via the config option.
-        """
 
-        path = os.path.join(self.fixtures_path, "cmd-002.yaml")
-        args = ["--conf", self.default_config_file, "update", path]
+def test_non_existing_config_file(default_config_file):
+    """
+    Run test mode and pass a non-existing configuration file
+    """
+    args = ["--conf", default_config_file, "test", "non-existing.yaml"]
+    jenkins_jobs = entry.JenkinsJobs(args)
+    with pytest.raises(IOError):
+        jenkins_jobs.execute()
 
-        jenkins_mock.return_value.update_jobs.return_value = ([], 0)
-        jenkins_mock.return_value.update_views.return_value = ([], 0)
-        self.execute_jenkins_jobs_with_args(args)
 
-        # validate that the JJBConfig used to initialize builder.Jenkins
-        # contains the expected timeout value.
+def test_config_options_not_replaced_by_cli_defaults(fixtures_dir):
+    """
+    Run test mode and check config settings from conf file retained
+    when none of the global CLI options are set.
+    """
+    config_file = fixtures_dir / "settings_from_config.ini"
+    args = ["--conf", str(config_file), "test", "dummy.yaml"]
+    jenkins_jobs = entry.JenkinsJobs(args)
+    jjb_config = jenkins_jobs.jjb_config
+    assert jjb_config.jenkins["user"] == "jenkins_user"
+    assert jjb_config.jenkins["password"] == "jenkins_password"
+    assert jjb_config.builder["ignore_cache"]
+    assert jjb_config.builder["flush_cache"]
+    assert jjb_config.builder["update"] == "all"
+    assert jjb_config.yamlparser["allow_empty_variables"]
 
-        jjb_config = jenkins_mock.call_args[0][0]
-        self.assertEqual(jjb_config.jenkins["timeout"], builder._DEFAULT_TIMEOUT)
 
-    @mock.patch("jenkins_jobs.cli.subcommand.update.JenkinsManager")
-    def test_update_timeout_set(self, jenkins_mock):
-        """Check that timeout is set correctly
+def test_config_options_overriden_by_cli():
+    """
+    Run test mode and check config settings from conf file retained
+    when none of the global CLI options are set.
+    """
+    args = [
+        "--user",
+        "myuser",
+        "--password",
+        "mypassword",
+        "--ignore-cache",
+        "--flush-cache",
+        "--allow-empty-variables",
+        "test",
+        "dummy.yaml",
+    ]
+    jenkins_jobs = entry.JenkinsJobs(args)
+    jjb_config = jenkins_jobs.jjb_config
+    assert jjb_config.jenkins["user"] == "myuser"
+    assert jjb_config.jenkins["password"] == "mypassword"
+    assert jjb_config.builder["ignore_cache"]
+    assert jjb_config.builder["flush_cache"]
+    assert jjb_config.yamlparser["allow_empty_variables"]
 
-        Test that the Jenkins object has the timeout set on it only when
-        provided via the config option.
-        """
 
-        path = os.path.join(self.fixtures_path, "cmd-002.yaml")
-        config_file = os.path.join(self.fixtures_path, "non-default-timeout.ini")
-        args = ["--conf", config_file, "update", path]
+def test_update_timeout_not_set(mocker, fixtures_dir, default_config_file):
+    """Check that timeout is left unset
 
-        jenkins_mock.return_value.update_jobs.return_value = ([], 0)
-        jenkins_mock.return_value.update_views.return_value = ([], 0)
-        self.execute_jenkins_jobs_with_args(args)
+    Test that the Jenkins object has the timeout set on it only when
+    provided via the config option.
+    """
+    jenkins_mock = mocker.patch("jenkins_jobs.cli.subcommand.update.JenkinsManager")
 
-        # validate that the JJBConfig used to initialize builder.Jenkins
-        # contains the expected timeout value.
+    path = fixtures_dir / "cmd-002.yaml"
+    args = ["--conf", default_config_file, "update", str(path)]
 
-        jjb_config = jenkins_mock.call_args[0][0]
-        self.assertEqual(jjb_config.jenkins["timeout"], 0.2)
+    jenkins_mock.return_value.update_jobs.return_value = ([], 0)
+    jenkins_mock.return_value.update_views.return_value = ([], 0)
+    jenkins_jobs = entry.JenkinsJobs(args)
+    jenkins_jobs.execute()
+
+    # validate that the JJBConfig used to initialize builder.Jenkins
+    # contains the expected timeout value.
+
+    jjb_config = jenkins_mock.call_args[0][0]
+    assert jjb_config.jenkins["timeout"] == builder._DEFAULT_TIMEOUT
+
+
+def test_update_timeout_set(mocker, fixtures_dir):
+    """Check that timeout is set correctly
+
+    Test that the Jenkins object has the timeout set on it only when
+    provided via the config option.
+    """
+    jenkins_mock = mocker.patch("jenkins_jobs.cli.subcommand.update.JenkinsManager")
+
+    path = fixtures_dir / "cmd-002.yaml"
+    config_file = fixtures_dir / "non-default-timeout.ini"
+    args = ["--conf", str(config_file), "update", str(path)]
+
+    jenkins_mock.return_value.update_jobs.return_value = ([], 0)
+    jenkins_mock.return_value.update_views.return_value = ([], 0)
+    jenkins_jobs = entry.JenkinsJobs(args)
+    jenkins_jobs.execute()
+
+    # validate that the JJBConfig used to initialize builder.Jenkins
+    # contains the expected timeout value.
+
+    jjb_config = jenkins_mock.call_args[0][0]
+    assert jjb_config.jenkins["timeout"] == 0.2
diff --git a/tests/cmd/test_recurse_path.py b/tests/cmd/test_recurse_path.py
index acb609f3b..be1f72c22 100644
--- a/tests/cmd/test_recurse_path.py
+++ b/tests/cmd/test_recurse_path.py
@@ -1,7 +1,4 @@
-import os
-
-from tests.base import mock
-import testtools
+from pathlib import Path
 
 from jenkins_jobs import utils
 
@@ -26,114 +23,104 @@ def fake_os_walk(paths):
     return os_walk
 
 
-# Testing the utils module can sometimes result in the JobCache class
-# attempting to create the cache directory multiple times as the tests
-# are run in parallel.  Stub out the JobCache to ensure that each
-# test can safely create the object without effect.
-@mock.patch("jenkins_jobs.builder.JobCache", mock.MagicMock)
-class CmdRecursePath(testtools.TestCase):
-    @mock.patch("jenkins_jobs.utils.os.walk")
-    def test_recursive_path_option_exclude_pattern(self, oswalk_mock):
-        """
-        Test paths returned by the recursive processing when using pattern
-        excludes.
+def test_recursive_path_option_exclude_pattern(mocker):
+    """
+    Test paths returned by the recursive processing when using pattern
+    excludes.
 
-        testing paths
-            /jjb_configs/dir1/test1/
-            /jjb_configs/dir1/file
-            /jjb_configs/dir2/test2/
-            /jjb_configs/dir3/bar/
-            /jjb_configs/test3/bar/
-            /jjb_configs/test3/baz/
-        """
+    testing paths
+        /jjb_configs/dir1/test1/
+        /jjb_configs/dir1/file
+        /jjb_configs/dir2/test2/
+        /jjb_configs/dir3/bar/
+        /jjb_configs/test3/bar/
+        /jjb_configs/test3/baz/
+    """
 
-        os_walk_paths = [
-            ("/jjb_configs", (["dir1", "dir2", "dir3", "test3"], ())),
-            ("/jjb_configs/dir1", (["test1"], ("file"))),
-            ("/jjb_configs/dir2", (["test2"], ())),
-            ("/jjb_configs/dir3", (["bar"], ())),
-            ("/jjb_configs/dir3/bar", ([], ())),
-            ("/jjb_configs/test3/bar", None),
-            ("/jjb_configs/test3/baz", None),
-        ]
+    os_walk_paths = [
+        ("/jjb_configs", (["dir1", "dir2", "dir3", "test3"], ())),
+        ("/jjb_configs/dir1", (["test1"], ("file"))),
+        ("/jjb_configs/dir2", (["test2"], ())),
+        ("/jjb_configs/dir3", (["bar"], ())),
+        ("/jjb_configs/dir3/bar", ([], ())),
+        ("/jjb_configs/test3/bar", None),
+        ("/jjb_configs/test3/baz", None),
+    ]
 
-        paths = [k for k, v in os_walk_paths if v is not None]
+    paths = [k for k, v in os_walk_paths if v is not None]
 
-        oswalk_mock.side_effect = fake_os_walk(os_walk_paths)
-        self.assertEqual(paths, utils.recurse_path("/jjb_configs", ["test*"]))
+    mocker.patch("jenkins_jobs.utils.os.walk", side_effect=fake_os_walk(os_walk_paths))
+    assert paths == utils.recurse_path("/jjb_configs", ["test*"])
 
-    @mock.patch("jenkins_jobs.utils.os.walk")
-    def test_recursive_path_option_exclude_absolute(self, oswalk_mock):
-        """
-        Test paths returned by the recursive processing when using absolute
-        excludes.
 
-        testing paths
-            /jjb_configs/dir1/test1/
-            /jjb_configs/dir1/file
-            /jjb_configs/dir2/test2/
-            /jjb_configs/dir3/bar/
-            /jjb_configs/test3/bar/
-            /jjb_configs/test3/baz/
-        """
+def test_recursive_path_option_exclude_absolute(mocker):
+    """
+    Test paths returned by the recursive processing when using absolute
+    excludes.
 
-        os_walk_paths = [
-            ("/jjb_configs", (["dir1", "dir2", "dir3", "test3"], ())),
-            ("/jjb_configs/dir1", None),
-            ("/jjb_configs/dir2", (["test2"], ())),
-            ("/jjb_configs/dir3", (["bar"], ())),
-            ("/jjb_configs/test3", (["bar", "baz"], ())),
-            ("/jjb_configs/dir2/test2", ([], ())),
-            ("/jjb_configs/dir3/bar", ([], ())),
-            ("/jjb_configs/test3/bar", ([], ())),
-            ("/jjb_configs/test3/baz", ([], ())),
-        ]
+    testing paths
+        /jjb_configs/dir1/test1/
+        /jjb_configs/dir1/file
+        /jjb_configs/dir2/test2/
+        /jjb_configs/dir3/bar/
+        /jjb_configs/test3/bar/
+        /jjb_configs/test3/baz/
+    """
 
-        paths = [k for k, v in os_walk_paths if v is not None]
+    os_walk_paths = [
+        ("/jjb_configs", (["dir1", "dir2", "dir3", "test3"], ())),
+        ("/jjb_configs/dir1", None),
+        ("/jjb_configs/dir2", (["test2"], ())),
+        ("/jjb_configs/dir3", (["bar"], ())),
+        ("/jjb_configs/test3", (["bar", "baz"], ())),
+        ("/jjb_configs/dir2/test2", ([], ())),
+        ("/jjb_configs/dir3/bar", ([], ())),
+        ("/jjb_configs/test3/bar", ([], ())),
+        ("/jjb_configs/test3/baz", ([], ())),
+    ]
 
-        oswalk_mock.side_effect = fake_os_walk(os_walk_paths)
+    paths = [k for k, v in os_walk_paths if v is not None]
 
-        self.assertEqual(
-            paths, utils.recurse_path("/jjb_configs", ["/jjb_configs/dir1"])
-        )
+    mocker.patch("jenkins_jobs.utils.os.walk", side_effect=fake_os_walk(os_walk_paths))
 
-    @mock.patch("jenkins_jobs.utils.os.walk")
-    def test_recursive_path_option_exclude_relative(self, oswalk_mock):
-        """
-        Test paths returned by the recursive processing when using relative
-        excludes.
+    assert paths == utils.recurse_path("/jjb_configs", ["/jjb_configs/dir1"])
 
-        testing paths
-            ./jjb_configs/dir1/test/
-            ./jjb_configs/dir1/file
-            ./jjb_configs/dir2/test/
-            ./jjb_configs/dir3/bar/
-            ./jjb_configs/test3/bar/
-            ./jjb_configs/test3/baz/
-        """
 
-        os_walk_paths = [
-            ("jjb_configs", (["dir1", "dir2", "dir3", "test3"], ())),
-            ("jjb_configs/dir1", (["test"], ("file"))),
-            ("jjb_configs/dir2", (["test2"], ())),
-            ("jjb_configs/dir3", (["bar"], ())),
-            ("jjb_configs/test3", (["bar", "baz"], ())),
-            ("jjb_configs/dir1/test", ([], ())),
-            ("jjb_configs/dir2/test2", ([], ())),
-            ("jjb_configs/dir3/bar", ([], ())),
-            ("jjb_configs/test3/bar", None),
-            ("jjb_configs/test3/baz", ([], ())),
-        ]
+def test_recursive_path_option_exclude_relative(mocker):
+    """
+    Test paths returned by the recursive processing when using relative
+    excludes.
 
-        rel_os_walk_paths = [
-            (os.path.abspath(os.path.join(os.path.curdir, k)), v)
-            for k, v in os_walk_paths
-        ]
+    testing paths
+        ./jjb_configs/dir1/test/
+        ./jjb_configs/dir1/file
+        ./jjb_configs/dir2/test/
+        ./jjb_configs/dir3/bar/
+        ./jjb_configs/test3/bar/
+        ./jjb_configs/test3/baz/
+    """
 
-        paths = [k for k, v in rel_os_walk_paths if v is not None]
+    os_walk_paths = [
+        ("jjb_configs", (["dir1", "dir2", "dir3", "test3"], ())),
+        ("jjb_configs/dir1", (["test"], ("file"))),
+        ("jjb_configs/dir2", (["test2"], ())),
+        ("jjb_configs/dir3", (["bar"], ())),
+        ("jjb_configs/test3", (["bar", "baz"], ())),
+        ("jjb_configs/dir1/test", ([], ())),
+        ("jjb_configs/dir2/test2", ([], ())),
+        ("jjb_configs/dir3/bar", ([], ())),
+        ("jjb_configs/test3/bar", None),
+        ("jjb_configs/test3/baz", ([], ())),
+    ]
 
-        oswalk_mock.side_effect = fake_os_walk(rel_os_walk_paths)
+    rel_os_walk_paths = [
+        (str(Path.cwd().joinpath(k).absolute()), v) for k, v in os_walk_paths
+    ]
 
-        self.assertEqual(
-            paths, utils.recurse_path("jjb_configs", ["jjb_configs/test3/bar"])
-        )
+    paths = [k for k, v in rel_os_walk_paths if v is not None]
+
+    mocker.patch(
+        "jenkins_jobs.utils.os.walk", side_effect=fake_os_walk(rel_os_walk_paths)
+    )
+
+    assert paths == utils.recurse_path("jjb_configs", ["jjb_configs/test3/bar"])
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 000000000..aaabbc20d
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,169 @@
+import configparser
+import pkg_resources
+import xml.etree.ElementTree as XML
+from pathlib import Path
+
+import pytest
+
+from jenkins_jobs.alphanum import AlphanumSort
+from jenkins_jobs.config import JJBConfig
+from jenkins_jobs.modules import project_externaljob
+from jenkins_jobs.modules import project_flow
+from jenkins_jobs.modules import project_githuborg
+from jenkins_jobs.modules import project_matrix
+from jenkins_jobs.modules import project_maven
+from jenkins_jobs.modules import project_multibranch
+from jenkins_jobs.modules import project_multijob
+from jenkins_jobs.parser import YamlParser
+from jenkins_jobs.registry import ModuleRegistry
+from jenkins_jobs.xml_config import XmlJob, XmlJobGenerator
+import jenkins_jobs.local_yaml as yaml
+
+
+# Avoid writing to ~/.cache/jenkins_jobs.
+@pytest.fixture(autouse=True)
+def job_cache_mocked(mocker):
+    mocker.patch("jenkins_jobs.builder.JobCache", autospec=True)
+
+
+@pytest.fixture
+def config_path(scenario):
+    return scenario.config_path
+
+
+@pytest.fixture
+def jjb_config(config_path):
+    config = JJBConfig(config_path)
+    config.validate()
+    return config
+
+
+@pytest.fixture
+def mock_iter_entry_points():
+    config = configparser.ConfigParser()
+    config.read(Path(__file__).parent / "../setup.cfg")
+    groups = {}
+    for key in config["entry_points"]:
+        groups[key] = list()
+        for line in config["entry_points"][key].split("\n"):
+            if "" == line.strip():
+                continue
+            groups[key].append(
+                pkg_resources.EntryPoint.parse(line, dist=pkg_resources.Distribution())
+            )
+
+    def iter_entry_points(group, name=None):
+        return (entry for entry in groups[group] if name is None or name == entry.name)
+
+    return iter_entry_points
+
+
+@pytest.fixture
+def input(scenario):
+    return yaml.load(scenario.in_path.read_text())
+
+
+@pytest.fixture
+def plugins_info(scenario):
+    if not scenario.plugins_info_path.exists():
+        return None
+    return yaml.load(scenario.plugins_info_path.read_text())
+
+
+@pytest.fixture
+def registry(mocker, mock_iter_entry_points, jjb_config, plugins_info):
+    mocker.patch("pkg_resources.iter_entry_points", side_effect=mock_iter_entry_points)
+    return ModuleRegistry(jjb_config, plugins_info)
+
+
+@pytest.fixture
+def project(input, registry):
+    type_to_class = {
+        "maven": project_maven.Maven,
+        "matrix": project_matrix.Matrix,
+        "flow": project_flow.Flow,
+        "githuborg": project_githuborg.GithubOrganization,
+        "multijob": project_multijob.MultiJob,
+        "multibranch": project_multibranch.WorkflowMultiBranch,
+        "multibranch-defaults": project_multibranch.WorkflowMultiBranchDefaults,
+        "externaljob": project_externaljob.ExternalJob,
+    }
+    try:
+        class_name = input["project-type"]
+    except KeyError:
+        return None
+    if class_name == "freestyle":
+        return None
+    cls = type_to_class[class_name]
+    return cls(registry)
+
+
+@pytest.fixture
+def expected_output(scenario):
+    return "".join(path.read_text() for path in sorted(scenario.out_paths))
+
+
+def check_folder(scenario, jjb_config, input):
+    if "name" not in input:
+        return
+    parser = YamlParser(jjb_config)
+    *dirs, name = parser._getfullname(input).split("/")
+    input_dir = scenario.in_path.parent
+    expected_out_dirs = [input_dir.joinpath(*dirs)]
+    actual_out_dirs = [path.parent for path in scenario.out_paths]
+    assert expected_out_dirs == actual_out_dirs
+
+
+@pytest.fixture
+def check_generator(scenario, input, expected_output, jjb_config, registry, project):
+    registry.set_parser_data({})
+
+    if project:
+        xml = project.root_xml(input)
+    else:
+        xml = XML.Element("project")
+
+    def check(Generator):
+        generator = Generator(registry)
+        generator.gen_xml(xml, input)
+        check_folder(scenario, jjb_config, input)
+        pretty_xml = XmlJob(xml, "fixturejob").output().decode()
+        assert expected_output == pretty_xml
+
+    return check
+
+
+@pytest.fixture
+def check_parser(jjb_config, registry):
+    parser = YamlParser(jjb_config)
+
+    def check(in_path):
+        parser.parse(str(in_path))
+        _ = parser.expandYaml(registry)
+
+    return check
+
+
+@pytest.fixture
+def check_job(scenario, expected_output, jjb_config, registry):
+    parser = YamlParser(jjb_config)
+
+    def check():
+        parser.parse(str(scenario.in_path))
+        registry.set_parser_data(parser.data)
+        job_data_list, view_data_list = parser.expandYaml(registry)
+        generator = XmlJobGenerator(registry)
+        job_xml_list = generator.generateXML(job_data_list)
+        job_xml_list.sort(key=AlphanumSort)
+
+        pretty_xml = (
+            "\n".join(job.output().decode() for job in job_xml_list)
+            .strip()
+            .replace("\n\n", "\n")
+        )
+        stripped_expected_output = (
+            expected_output.strip().replace("<BLANKLINE>", "").replace("\n\n", "\n")
+        )
+        assert stripped_expected_output == pretty_xml
+
+    return check
diff --git a/tests/duplicates/__init__.py b/tests/duplicates/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/duplicates/test_duplicates.py b/tests/duplicates/test_duplicates.py
index d22aea2a3..79b883efe 100644
--- a/tests/duplicates/test_duplicates.py
+++ b/tests/duplicates/test_duplicates.py
@@ -13,24 +13,30 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import os
+from operator import attrgetter
+from pathlib import Path
 
-from testtools import ExpectedException
+import pytest
 
 from jenkins_jobs.errors import JenkinsJobsException
-from tests import base
-from tests.base import mock
+from tests.enum_scenarios import scenario_list
 
 
-class TestCaseModuleDuplicates(base.SingleJobTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
+fixtures_dir = Path(__file__).parent / "fixtures"
 
-    @mock.patch("jenkins_jobs.builder.logger", autospec=True)
-    def test_yaml_snippet(self, mock_logger):
 
-        if os.path.basename(self.in_filename).startswith("exception_"):
-            with ExpectedException(JenkinsJobsException, "^Duplicate .*"):
-                super(TestCaseModuleDuplicates, self).test_yaml_snippet()
-        else:
-            super(TestCaseModuleDuplicates, self).test_yaml_snippet()
+@pytest.fixture(
+    params=scenario_list(fixtures_dir),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+def test_yaml_snippet(scenario, check_job):
+    if scenario.in_path.name.startswith("exception_"):
+        with pytest.raises(JenkinsJobsException) as excinfo:
+            check_job()
+        assert str(excinfo.value).startswith("Duplicate ")
+    else:
+        check_job()
diff --git a/tests/enum_scenarios.py b/tests/enum_scenarios.py
new file mode 100644
index 000000000..58387db9f
--- /dev/null
+++ b/tests/enum_scenarios.py
@@ -0,0 +1,42 @@
+#!/usr/bin/env python
+#
+# Joint copyright:
+#  - Copyright 2012,2013 Wikimedia Foundation
+#  - Copyright 2012,2013 Antoine "hashar" Musso
+#  - Copyright 2013 Arnaud Fabre
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from collections import namedtuple
+
+
+Scenario = namedtuple(
+    "Scnenario", "name in_path out_paths config_path plugins_info_path"
+)
+
+
+def scenario_list(fixtures_dir, in_ext=".yaml", out_ext=".xml"):
+    for path in fixtures_dir.rglob(f"*{in_ext}"):
+        if path.name.endswith("plugins_info.yaml"):
+            continue
+        out_path = path.with_suffix(out_ext)
+        out_path_list = list(fixtures_dir.rglob(out_path.name))
+        yield Scenario(
+            name=path.stem,
+            in_path=path,
+            out_paths=out_path_list,
+            # When config file is missing it will still be passed and not None,
+            # so JJBConfig will prefer it over system and user configs.
+            config_path=path.with_suffix(".conf"),
+            plugins_info_path=path.with_suffix(".plugins_info.yaml"),
+        )
diff --git a/tests/errors/test_exceptions.py b/tests/errors/test_exceptions.py
index 079a5406f..45f6fcd52 100644
--- a/tests/errors/test_exceptions.py
+++ b/tests/errors/test_exceptions.py
@@ -1,7 +1,6 @@
-from testtools import ExpectedException
+import pytest
 
 from jenkins_jobs import errors
-from tests import base
 
 
 def dispatch(exc, *args):
@@ -21,65 +20,67 @@ def gen_xml(exc, *args):
     raise exc(*args)
 
 
-class TestInvalidAttributeError(base.BaseTestCase):
-    def test_no_valid_values(self):
-        # When given no valid values, InvalidAttributeError simply displays a
-        # message indicating the invalid value, the component type, the
-        # component name, and the attribute name.
-        message = "'{0}' is an invalid value for attribute {1}.{2}".format(
-            "fnord", "type.name", "fubar"
-        )
-        with ExpectedException(errors.InvalidAttributeError, message):
-            dispatch(errors.InvalidAttributeError, "fubar", "fnord")
-
-    def test_with_valid_values(self):
-        # When given valid values, InvalidAttributeError displays a message
-        # indicating the invalid value, the component type, the component name,
-        # and the attribute name; additionally, it lists the valid values for
-        # the current component type & name.
-        valid_values = ["herp", "derp"]
-        message = "'{0}' is an invalid value for attribute {1}.{2}".format(
-            "fnord", "type.name", "fubar"
-        )
-        message += "\nValid values include: {0}".format(
-            ", ".join("'{0}'".format(value) for value in valid_values)
-        )
-
-        with ExpectedException(errors.InvalidAttributeError, message):
-            dispatch(errors.InvalidAttributeError, "fubar", "fnord", valid_values)
+def test_no_valid_values():
+    # When given no valid values, InvalidAttributeError simply displays a
+    # message indicating the invalid value, the component type, the
+    # component name, and the attribute name.
+    message = "'{0}' is an invalid value for attribute {1}.{2}".format(
+        "fnord", "type.name", "fubar"
+    )
+    with pytest.raises(errors.InvalidAttributeError) as excinfo:
+        dispatch(errors.InvalidAttributeError, "fubar", "fnord")
+    assert str(excinfo.value) == message
 
 
-class TestMissingAttributeError(base.BaseTestCase):
-    def test_with_single_missing_attribute(self):
-        # When passed a single missing attribute, display a message indicating
-        #  * the missing attribute
-        #  * which component type and component name is missing it.
-        missing_attribute = "herp"
-        message = "Missing {0} from an instance of '{1}'".format(
-            missing_attribute, "type.name"
-        )
+def test_with_valid_values():
+    # When given valid values, InvalidAttributeError displays a message
+    # indicating the invalid value, the component type, the component name,
+    # and the attribute name; additionally, it lists the valid values for
+    # the current component type & name.
+    valid_values = ["herp", "derp"]
+    message = "'{0}' is an invalid value for attribute {1}.{2}".format(
+        "fnord", "type.name", "fubar"
+    )
+    message += "\nValid values include: {0}".format(
+        ", ".join("'{0}'".format(value) for value in valid_values)
+    )
 
-        with ExpectedException(errors.MissingAttributeError, message):
-            dispatch(errors.MissingAttributeError, missing_attribute)
+    with pytest.raises(errors.InvalidAttributeError) as excinfo:
+        dispatch(errors.InvalidAttributeError, "fubar", "fnord", valid_values)
+    assert str(excinfo.value) == message
 
-        with ExpectedException(
-            errors.MissingAttributeError, message.replace("type.name", "module")
-        ):
-            gen_xml(errors.MissingAttributeError, missing_attribute)
 
-    def test_with_multiple_missing_attributes(self):
-        # When passed multiple missing attributes, display a message indicating
-        #  * the missing attributes
-        #  * which component type and component name is missing it.
-        missing_attribute = ["herp", "derp"]
-        message = "One of {0} must be present in '{1}'".format(
-            ", ".join("'{0}'".format(value) for value in missing_attribute), "type.name"
-        )
+def test_with_single_missing_attribute():
+    # When passed a single missing attribute, display a message indicating
+    #  * the missing attribute
+    #  * which component type and component name is missing it.
+    missing_attribute = "herp"
+    message = "Missing {0} from an instance of '{1}'".format(
+        missing_attribute, "type.name"
+    )
 
-        with ExpectedException(errors.MissingAttributeError, message):
-            dispatch(errors.MissingAttributeError, missing_attribute)
+    with pytest.raises(errors.MissingAttributeError) as excinfo:
+        dispatch(errors.MissingAttributeError, missing_attribute)
+    assert str(excinfo.value) == message
 
-        with ExpectedException(
-            errors.MissingAttributeError, message.replace("type.name", "module")
-        ):
-            gen_xml(errors.MissingAttributeError, missing_attribute)
+    with pytest.raises(errors.MissingAttributeError) as excinfo:
+        gen_xml(errors.MissingAttributeError, missing_attribute)
+    assert str(excinfo.value) == message.replace("type.name", "module")
+
+
+def test_with_multiple_missing_attributes():
+    # When passed multiple missing attributes, display a message indicating
+    #  * the missing attributes
+    #  * which component type and component name is missing it.
+    missing_attribute = ["herp", "derp"]
+    message = "One of {0} must be present in '{1}'".format(
+        ", ".join("'{0}'".format(value) for value in missing_attribute), "type.name"
+    )
+
+    with pytest.raises(errors.MissingAttributeError) as excinfo:
+        dispatch(errors.MissingAttributeError, missing_attribute)
+    assert str(excinfo.value) == message
+
+    with pytest.raises(errors.MissingAttributeError) as excinfo:
+        gen_xml(errors.MissingAttributeError, missing_attribute)
+    assert str(excinfo.value) == message.replace("type.name", "module")
diff --git a/tests/general/test_general.py b/tests/general/test_general.py
index d7a7d77e7..e4668ac38 100644
--- a/tests/general/test_general.py
+++ b/tests/general/test_general.py
@@ -15,13 +15,25 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import os
+from operator import attrgetter
+from pathlib import Path
 
+import pytest
+
+from tests.enum_scenarios import scenario_list
 from jenkins_jobs.modules import general
-from tests import base
 
 
-class TestCaseModuleGeneral(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    klass = general.General
+fixtures_dir = Path(__file__).parent / "fixtures"
+
+
+@pytest.fixture(
+    params=scenario_list(fixtures_dir),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+def test_yaml_snippet(check_generator):
+    check_generator(general.General)
diff --git a/tests/githuborg/test_githuborg.py b/tests/githuborg/test_githuborg.py
index cd12edf3e..443206dfa 100644
--- a/tests/githuborg/test_githuborg.py
+++ b/tests/githuborg/test_githuborg.py
@@ -13,13 +13,25 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-from tests import base
-import os
+from operator import attrgetter
+from pathlib import Path
+
+import pytest
+
+from tests.enum_scenarios import scenario_list
 from jenkins_jobs.modules import project_githuborg
 
 
-class TestCaseGithubOrganization(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    default_config_file = "/dev/null"
-    klass = project_githuborg.GithubOrganization
+fixtures_dir = Path(__file__).parent / "fixtures"
+
+
+@pytest.fixture(
+    params=scenario_list(fixtures_dir),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+def test_yaml_snippet(check_generator):
+    check_generator(project_githuborg.GithubOrganization)
diff --git a/tests/hipchat/test_hipchat.py b/tests/hipchat/test_hipchat.py
index 9e276754a..f32c50b1d 100644
--- a/tests/hipchat/test_hipchat.py
+++ b/tests/hipchat/test_hipchat.py
@@ -12,13 +12,25 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import os
+from operator import attrgetter
+from pathlib import Path
 
+import pytest
+
+from tests.enum_scenarios import scenario_list
 from jenkins_jobs.modules import hipchat_notif
-from tests import base
 
 
-class TestCaseModulePublishers(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    klass = hipchat_notif.HipChat
+fixtures_dir = Path(__file__).parent / "fixtures"
+
+
+@pytest.fixture(
+    params=scenario_list(fixtures_dir),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+def test_yaml_snippet(check_generator):
+    check_generator(hipchat_notif.HipChat)
diff --git a/tests/jenkins_manager/test_manager.py b/tests/jenkins_manager/test_manager.py
index 530a8eaf0..53bcf990e 100644
--- a/tests/jenkins_manager/test_manager.py
+++ b/tests/jenkins_manager/test_manager.py
@@ -14,72 +14,72 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
+from unittest import mock
+
+import pytest
+
 from jenkins_jobs.config import JJBConfig
 import jenkins_jobs.builder
-from tests import base
-from tests.base import mock
 
 
 _plugins_info = {}
 _plugins_info["plugin1"] = {"longName": "", "shortName": "", "version": ""}
 
 
-@mock.patch("jenkins_jobs.builder.JobCache", mock.MagicMock)
-class TestCaseTestJenkinsManager(base.BaseTestCase):
-    def setUp(self):
-        super(TestCaseTestJenkinsManager, self).setUp()
-        self.jjb_config = JJBConfig()
-        self.jjb_config.validate()
+@pytest.fixture
+def jjb_config():
+    config = JJBConfig()
+    config.validate()
+    return config
 
-    def test_plugins_list(self):
-        self.jjb_config.builder["plugins_info"] = _plugins_info
 
-        self.builder = jenkins_jobs.builder.JenkinsManager(self.jjb_config)
-        self.assertEqual(self.builder.plugins_list, _plugins_info)
+def test_plugins_list(jjb_config):
+    jjb_config.builder["plugins_info"] = _plugins_info
 
-    @mock.patch.object(
+    builder = jenkins_jobs.builder.JenkinsManager(jjb_config)
+    assert builder.plugins_list == _plugins_info
+
+
+def test_plugins_list_from_jenkins(mocker, jjb_config):
+    mocker.patch.object(
         jenkins_jobs.builder.jenkins.Jenkins, "get_plugins", return_value=_plugins_info
     )
-    def test_plugins_list_from_jenkins(self, jenkins_mock):
-        # Trigger fetching the plugins from jenkins when accessing the property
-        self.jjb_config.builder["plugins_info"] = {}
-        self.builder = jenkins_jobs.builder.JenkinsManager(self.jjb_config)
-        # See https://github.com/formiaczek/multi_key_dict/issues/17
-        # self.assertEqual(self.builder.plugins_list, k)
-        for key_tuple in self.builder.plugins_list.keys():
-            for key in key_tuple:
-                self.assertEqual(self.builder.plugins_list[key], _plugins_info[key])
+    # Trigger fetching the plugins from jenkins when accessing the property
+    jjb_config.builder["plugins_info"] = {}
+    builder = jenkins_jobs.builder.JenkinsManager(jjb_config)
+    # See https://github.com/formiaczek/multi_key_dict/issues/17
+    # self.assertEqual(self.builder.plugins_list, k)
+    for key_tuple in builder.plugins_list.keys():
+        for key in key_tuple:
+            assert builder.plugins_list[key] == _plugins_info[key]
 
-    def test_delete_managed(self):
-        self.jjb_config.builder["plugins_info"] = {}
-        self.builder = jenkins_jobs.builder.JenkinsManager(self.jjb_config)
 
-        with mock.patch.multiple(
-            "jenkins_jobs.builder.JenkinsManager",
-            get_jobs=mock.DEFAULT,
-            is_job=mock.DEFAULT,
-            is_managed=mock.DEFAULT,
-            delete_job=mock.DEFAULT,
-        ) as patches:
-            patches["get_jobs"].return_value = [
-                {"fullname": "job1"},
-                {"fullname": "job2"},
-            ]
-            patches["is_managed"].side_effect = [True, True]
-            patches["is_job"].side_effect = [True, True]
+def test_delete_managed(mocker, jjb_config):
+    jjb_config.builder["plugins_info"] = {}
+    builder = jenkins_jobs.builder.JenkinsManager(jjb_config)
 
-            self.builder.delete_old_managed()
-            self.assertEqual(patches["delete_job"].call_count, 2)
+    patches = mocker.patch.multiple(
+        "jenkins_jobs.builder.JenkinsManager",
+        get_jobs=mock.DEFAULT,
+        is_job=mock.DEFAULT,
+        is_managed=mock.DEFAULT,
+        delete_job=mock.DEFAULT,
+    )
+    patches["get_jobs"].return_value = [
+        {"fullname": "job1"},
+        {"fullname": "job2"},
+    ]
+    patches["is_managed"].side_effect = [True, True]
+    patches["is_job"].side_effect = [True, True]
 
-    def _get_plugins_info_error_test(self, error_string):
-        builder = jenkins_jobs.builder.JenkinsManager(self.jjb_config)
-        exception = jenkins_jobs.builder.jenkins.JenkinsException(error_string)
-        with mock.patch.object(builder.jenkins, "get_plugins", side_effect=exception):
-            plugins_info = builder.get_plugins_info()
-        self.assertEqual([_plugins_info["plugin1"]], plugins_info)
+    builder.delete_old_managed()
+    assert patches["delete_job"].call_count == 2
 
-    def test_get_plugins_info_handles_connectionrefused_errors(self):
-        self._get_plugins_info_error_test("Connection refused")
 
-    def test_get_plugins_info_handles_forbidden_errors(self):
-        self._get_plugins_info_error_test("Forbidden")
+@pytest.mark.parametrize("error_string", ["Connection refused", "Forbidden"])
+def test_get_plugins_info_error(mocker, jjb_config, error_string):
+    builder = jenkins_jobs.builder.JenkinsManager(jjb_config)
+    exception = jenkins_jobs.builder.jenkins.JenkinsException(error_string)
+    mocker.patch.object(builder.jenkins, "get_plugins", side_effect=exception)
+    plugins_info = builder.get_plugins_info()
+    assert [_plugins_info["plugin1"]] == plugins_info
diff --git a/tests/jsonparser/test_jsonparser.py b/tests/jsonparser/test_jsonparser.py
index 6257b4d36..1ebd5bd62 100644
--- a/tests/jsonparser/test_jsonparser.py
+++ b/tests/jsonparser/test_jsonparser.py
@@ -15,11 +15,24 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import os
+from operator import attrgetter
+from pathlib import Path
 
-from tests import base
+import pytest
+
+from tests.enum_scenarios import scenario_list
 
 
-class TestCaseModuleJsonParser(base.SingleJobTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path, in_ext="json", out_ext="xml")
+fixtures_dir = Path(__file__).parent / "fixtures"
+
+
+@pytest.fixture(
+    params=scenario_list(fixtures_dir, in_ext=".json"),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+def test_yaml_snippet(check_job):
+    check_job()
diff --git a/tests/localyaml/test_localyaml.py b/tests/localyaml/test_localyaml.py
index 3814fb56c..030968324 100644
--- a/tests/localyaml/test_localyaml.py
+++ b/tests/localyaml/test_localyaml.py
@@ -14,128 +14,169 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import os
-import yaml
+from io import StringIO
+from pathlib import Path
+from yaml import safe_dump
 
-from testtools import ExpectedException
+import json
+import pytest
 from yaml.composer import ComposerError
 
+import jenkins_jobs.local_yaml as yaml
 from jenkins_jobs.config import JJBConfig
 from jenkins_jobs.parser import YamlParser
 from jenkins_jobs.registry import ModuleRegistry
-from tests import base
+from tests.enum_scenarios import scenario_list
 
 
-def _exclude_scenarios(input_filename):
-    return os.path.basename(input_filename).startswith("custom_")
+fixtures_dir = Path(__file__).parent / "fixtures"
 
 
-class TestCaseLocalYamlInclude(base.JsonTestCase):
+@pytest.fixture
+def read_input(scenario):
+    def read():
+        return yaml.load(
+            scenario.in_path.read_text(),
+            search_path=[str(fixtures_dir)],
+        )
+
+    return read
+
+
+@pytest.mark.parametrize(
+    "scenario",
+    [
+        pytest.param(s, id=s.name)
+        for s in scenario_list(fixtures_dir, out_ext=".json")
+        if not s.name.startswith(("custom_", "exception_"))
+    ],
+)
+def test_include(read_input, expected_output):
     """
     Verify application specific tags independently of any changes to
     modules XML parsing behaviour
     """
 
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(
-        fixtures_path, "yaml", "json", filter_func=_exclude_scenarios
-    )
-
-    def test_yaml_snippet(self):
-
-        if os.path.basename(self.in_filename).startswith("exception_"):
-            with ExpectedException(ComposerError, "^found duplicate anchor .*"):
-                super(TestCaseLocalYamlInclude, self).test_yaml_snippet()
-        else:
-            super(TestCaseLocalYamlInclude, self).test_yaml_snippet()
+    input = read_input()
+    pretty_json = json.dumps(input, indent=4, separators=(",", ": "))
+    assert expected_output.rstrip() == pretty_json
 
 
-class TestCaseLocalYamlAnchorAlias(base.YamlTestCase):
+@pytest.mark.parametrize(
+    "scenario",
+    [
+        pytest.param(s, id=s.name)
+        for s in scenario_list(fixtures_dir, out_ext=".json")
+        if s.name.startswith("exception_")
+    ],
+)
+def test_include_error(read_input, expected_output):
+    with pytest.raises(ComposerError) as excinfo:
+        _ = read_input()
+    assert str(excinfo.value).startswith("found duplicate anchor ")
+
+
+@pytest.mark.parametrize(
+    "scenario",
+    [
+        pytest.param(s, id=s.name)
+        for s in scenario_list(fixtures_dir, in_ext=".iyaml", out_ext=".oyaml")
+    ],
+)
+def test_anchor_alias(read_input, expected_output):
     """
     Verify yaml input is expanded to the expected yaml output when using yaml
     anchors and aliases.
     """
 
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path, "iyaml", "oyaml")
+    input = read_input()
+    data = StringIO(json.dumps(input))
+    pretty_yaml = safe_dump(json.load(data), default_flow_style=False)
+    assert expected_output == pretty_yaml
 
 
-class TestCaseLocalYamlIncludeAnchors(base.BaseTestCase):
+def test_include_anchors():
+    """
+    Verify that anchors/aliases only span use of '!include' tag
 
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
+    To ensure that any yaml loaded by the include tag is in the same
+    space as the top level file, but individual top level yaml definitions
+    are treated by the yaml loader as independent.
+    """
 
-    def test_multiple_same_anchor_in_multiple_toplevel_yaml(self):
-        """
-        Verify that anchors/aliases only span use of '!include' tag
+    config = JJBConfig()
+    config.jenkins["url"] = "http://example.com"
+    config.jenkins["user"] = "jenkins"
+    config.jenkins["password"] = "password"
+    config.builder["plugins_info"] = []
+    config.validate()
 
-        To ensure that any yaml loaded by the include tag is in the same
-        space as the top level file, but individual top level yaml definitions
-        are treated by the yaml loader as independent.
-        """
+    files = [
+        "custom_same_anchor-001-part1.yaml",
+        "custom_same_anchor-001-part2.yaml",
+    ]
 
-        files = [
-            "custom_same_anchor-001-part1.yaml",
-            "custom_same_anchor-001-part2.yaml",
-        ]
-
-        jjb_config = JJBConfig()
-        jjb_config.jenkins["url"] = "http://example.com"
-        jjb_config.jenkins["user"] = "jenkins"
-        jjb_config.jenkins["password"] = "password"
-        jjb_config.builder["plugins_info"] = []
-        jjb_config.validate()
-        j = YamlParser(jjb_config)
-        j.load_files([os.path.join(self.fixtures_path, f) for f in files])
+    parser = YamlParser(config)
+    # Should not raise ComposerError.
+    parser.load_files([str(fixtures_dir / name) for name in files])
 
 
-class TestCaseLocalYamlRetainAnchors(base.BaseTestCase):
+def test_retain_anchor_default():
+    """
+    Verify that anchors are NOT retained across files by default.
+    """
 
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
+    config = JJBConfig()
+    config.validate()
 
-    def test_retain_anchors_default(self):
-        """
-        Verify that anchors are NOT retained across files by default.
-        """
+    files = [
+        "custom_retain_anchors_include001.yaml",
+        "custom_retain_anchors.yaml",
+    ]
 
-        files = ["custom_retain_anchors_include001.yaml", "custom_retain_anchors.yaml"]
+    parser = YamlParser(config)
+    with pytest.raises(ComposerError) as excinfo:
+        parser.load_files([str(fixtures_dir / name) for name in files])
+    assert "found undefined alias" in str(excinfo.value)
 
-        jjb_config = JJBConfig()
-        # use the default value for retain_anchors
-        jjb_config.validate()
-        j = YamlParser(jjb_config)
-        with ExpectedException(yaml.composer.ComposerError, "found undefined alias.*"):
-            j.load_files([os.path.join(self.fixtures_path, f) for f in files])
 
-    def test_retain_anchors_enabled(self):
-        """
-        Verify that anchors are retained across files if retain_anchors is
-        enabled in the config.
-        """
+def test_retain_anchors_enabled():
+    """
+    Verify that anchors are retained across files if retain_anchors is
+    enabled in the config.
+    """
 
-        files = ["custom_retain_anchors_include001.yaml", "custom_retain_anchors.yaml"]
+    config = JJBConfig()
+    config.yamlparser["retain_anchors"] = True
+    config.validate()
 
-        jjb_config = JJBConfig()
-        jjb_config.yamlparser["retain_anchors"] = True
-        jjb_config.validate()
-        j = YamlParser(jjb_config)
-        j.load_files([os.path.join(self.fixtures_path, f) for f in files])
+    files = [
+        "custom_retain_anchors_include001.yaml",
+        "custom_retain_anchors.yaml",
+    ]
 
-    def test_retain_anchors_enabled_j2_yaml(self):
-        """
-        Verify that anchors are retained across files and are properly retained when using !j2-yaml.
-        """
+    parser = YamlParser(config)
+    # Should not raise ComposerError.
+    parser.load_files([str(fixtures_dir / name) for name in files])
 
-        files = [
-            "custom_retain_anchors_j2_yaml_include001.yaml",
-            "custom_retain_anchors_j2_yaml.yaml",
-        ]
 
-        jjb_config = JJBConfig()
-        jjb_config.yamlparser["retain_anchors"] = True
-        jjb_config.validate()
-        j = YamlParser(jjb_config)
-        j.load_files([os.path.join(self.fixtures_path, f) for f in files])
+def test_retain_anchors_enabled_j2_yaml():
+    """
+    Verify that anchors are retained across files and are properly retained when using !j2-yaml.
+    """
 
-        registry = ModuleRegistry(jjb_config, None)
-        jobs, _ = j.expandYaml(registry)
-        self.assertEqual(jobs[0]["builders"][0]["shell"], "docker run ubuntu:latest")
+    config = JJBConfig()
+    config.yamlparser["retain_anchors"] = True
+    config.validate()
+
+    files = [
+        "custom_retain_anchors_j2_yaml_include001.yaml",
+        "custom_retain_anchors_j2_yaml.yaml",
+    ]
+
+    parser = YamlParser(config)
+    parser.load_files([str(fixtures_dir / name) for name in files])
+
+    registry = ModuleRegistry(config, None)
+    jobs, _ = parser.expandYaml(registry)
+    assert "docker run ubuntu:latest" == jobs[0]["builders"][0]["shell"]
diff --git a/tests/macros/test_macros.py b/tests/macros/test_macros.py
index 1535e333f..6946dad06 100644
--- a/tests/macros/test_macros.py
+++ b/tests/macros/test_macros.py
@@ -15,11 +15,24 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import os
+from operator import attrgetter
+from pathlib import Path
 
-from tests import base
+import pytest
+
+from tests.enum_scenarios import scenario_list
 
 
-class TestCaseModuleSCMMacro(base.SingleJobTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
+fixtures_dir = Path(__file__).parent / "fixtures"
+
+
+@pytest.fixture(
+    params=scenario_list(fixtures_dir),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+def test_yaml_snippet(check_job):
+    check_job()
diff --git a/tests/moduleregistry/test_moduleregistry.py b/tests/moduleregistry/test_moduleregistry.py
index 4f1d55020..3be1967e0 100644
--- a/tests/moduleregistry/test_moduleregistry.py
+++ b/tests/moduleregistry/test_moduleregistry.py
@@ -1,140 +1,146 @@
 import pkg_resources
+from collections import namedtuple
+from operator import attrgetter
 
-from testtools.content import text_content
-import testscenarios
+import pytest
 
 from jenkins_jobs.config import JJBConfig
 from jenkins_jobs.registry import ModuleRegistry
-from tests import base
 
 
-class ModuleRegistryPluginInfoTestsWithScenarios(
-    testscenarios.TestWithScenarios, base.BaseTestCase
-):
-    scenarios = [
-        ("s1", dict(v1="1.0.0", op="__gt__", v2="0.8.0")),
-        ("s2", dict(v1="1.0.1alpha", op="__gt__", v2="1.0.0")),
-        ("s3", dict(v1="1.0", op="__eq__", v2="1.0.0")),
-        ("s4", dict(v1="1.0", op="__eq__", v2="1.0")),
-        ("s5", dict(v1="1.0", op="__lt__", v2="1.8.0")),
-        ("s6", dict(v1="1.0.1alpha", op="__lt__", v2="1.0.1")),
-        ("s7", dict(v1="1.0alpha", op="__lt__", v2="1.0.0")),
-        ("s8", dict(v1="1.0-alpha", op="__lt__", v2="1.0.0")),
-        ("s9", dict(v1="1.1-alpha", op="__gt__", v2="1.0")),
-        ("s10", dict(v1="1.0-SNAPSHOT", op="__lt__", v2="1.0")),
-        ("s11", dict(v1="1.0.preview", op="__lt__", v2="1.0")),
-        ("s12", dict(v1="1.1-SNAPSHOT", op="__gt__", v2="1.0")),
-        ("s13", dict(v1="1.0a-SNAPSHOT", op="__lt__", v2="1.0a")),
-        (
-            "s14",
-            dict(
-                v1="1.4.6-SNAPSHOT (private-0986edd9-example)", op="__lt__", v2="1.4.6"
-            ),
-        ),
-        (
-            "s15",
-            dict(
-                v1="1.4.6-SNAPSHOT (private-0986edd9-example)", op="__gt__", v2="1.4.5"
-            ),
-        ),
-        ("s16", dict(v1="1.0.1-1.v1", op="__gt__", v2="1.0.1")),
-        ("s17", dict(v1="1.0.1-1.v1", op="__lt__", v2="1.0.2")),
-        ("s18", dict(v1="1.0.2-1.v1", op="__gt__", v2="1.0.1")),
-        ("s19", dict(v1="1.0.2-1.v1", op="__gt__", v2="1.0.1-2")),
+Scenario = namedtuple("Scnenario", "name v1 op v2")
+
+
+scenarios = [
+    Scenario("s1", v1="1.0.0", op="__gt__", v2="0.8.0"),
+    Scenario("s2", v1="1.0.1alpha", op="__gt__", v2="1.0.0"),
+    Scenario("s3", v1="1.0", op="__eq__", v2="1.0.0"),
+    Scenario("s4", v1="1.0", op="__eq__", v2="1.0"),
+    Scenario("s5", v1="1.0", op="__lt__", v2="1.8.0"),
+    Scenario("s6", v1="1.0.1alpha", op="__lt__", v2="1.0.1"),
+    Scenario("s7", v1="1.0alpha", op="__lt__", v2="1.0.0"),
+    Scenario("s8", v1="1.0-alpha", op="__lt__", v2="1.0.0"),
+    Scenario("s9", v1="1.1-alpha", op="__gt__", v2="1.0"),
+    Scenario("s10", v1="1.0-SNAPSHOT", op="__lt__", v2="1.0"),
+    Scenario("s11", v1="1.0.preview", op="__lt__", v2="1.0"),
+    Scenario("s12", v1="1.1-SNAPSHOT", op="__gt__", v2="1.0"),
+    Scenario("s13", v1="1.0a-SNAPSHOT", op="__lt__", v2="1.0a"),
+    Scenario(
+        "s14", v1="1.4.6-SNAPSHOT (private-0986edd9-example)", op="__lt__", v2="1.4.6"
+    ),
+    Scenario(
+        "s15", v1="1.4.6-SNAPSHOT (private-0986edd9-example)", op="__gt__", v2="1.4.5"
+    ),
+    Scenario("s16", v1="1.0.1-1.v1", op="__gt__", v2="1.0.1"),
+    Scenario("s17", v1="1.0.1-1.v1", op="__lt__", v2="1.0.2"),
+    Scenario("s18", v1="1.0.2-1.v1", op="__gt__", v2="1.0.1"),
+    Scenario("s19", v1="1.0.2-1.v1", op="__gt__", v2="1.0.1-2"),
+]
+
+
+@pytest.fixture(
+    params=scenarios,
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+@pytest.fixture
+def config():
+    config = JJBConfig()
+    config.validate()
+    return config
+
+
+@pytest.fixture
+def registry(config, scenario):
+    plugin_info = [
+        {
+            "shortName": "HerpDerpPlugin",
+            "longName": "Blah Blah Blah Plugin",
+        },
+        {
+            "shortName": "JankyPlugin1",
+            "longName": "Not A Real Plugin",
+            "version": scenario.v1,
+        },
     ]
+    return ModuleRegistry(config, plugin_info)
 
-    def setUp(self):
-        super(ModuleRegistryPluginInfoTestsWithScenarios, self).setUp()
 
-        jjb_config = JJBConfig()
-        jjb_config.validate()
+def test_get_plugin_info_dict(registry):
+    """
+    The goal of this test is to validate that the plugin_info returned by
+    ModuleRegistry.get_plugin_info is a dictionary whose key 'shortName' is
+    the same value as the string argument passed to
+    ModuleRegistry.get_plugin_info.
+    """
+    plugin_name = "JankyPlugin1"
+    plugin_info = registry.get_plugin_info(plugin_name)
 
-        plugin_info = [
-            {"shortName": "HerpDerpPlugin", "longName": "Blah Blah Blah Plugin"}
-        ]
-        plugin_info.append(
-            {
-                "shortName": "JankyPlugin1",
-                "longName": "Not A Real Plugin",
-                "version": self.v1,
-            }
-        )
+    assert isinstance(plugin_info, dict)
+    assert plugin_info["shortName"] == plugin_name
 
-        self.addDetail("plugin_info", text_content(str(plugin_info)))
-        self.registry = ModuleRegistry(jjb_config, plugin_info)
 
-    def tearDown(self):
-        super(ModuleRegistryPluginInfoTestsWithScenarios, self).tearDown()
+def test_get_plugin_info_dict_using_longName(registry):
+    """
+    The goal of this test is to validate that the plugin_info returned by
+    ModuleRegistry.get_plugin_info is a dictionary whose key 'longName' is
+    the same value as the string argument passed to
+    ModuleRegistry.get_plugin_info.
+    """
+    plugin_name = "Blah Blah Blah Plugin"
+    plugin_info = registry.get_plugin_info(plugin_name)
 
-    def test_get_plugin_info_dict(self):
-        """
-        The goal of this test is to validate that the plugin_info returned by
-        ModuleRegistry.get_plugin_info is a dictionary whose key 'shortName' is
-        the same value as the string argument passed to
-        ModuleRegistry.get_plugin_info.
-        """
-        plugin_name = "JankyPlugin1"
-        plugin_info = self.registry.get_plugin_info(plugin_name)
+    assert isinstance(plugin_info, dict)
+    assert plugin_info["longName"] == plugin_name
 
-        self.assertIsInstance(plugin_info, dict)
-        self.assertEqual(plugin_info["shortName"], plugin_name)
 
-    def test_get_plugin_info_dict_using_longName(self):
-        """
-        The goal of this test is to validate that the plugin_info returned by
-        ModuleRegistry.get_plugin_info is a dictionary whose key 'longName' is
-        the same value as the string argument passed to
-        ModuleRegistry.get_plugin_info.
-        """
-        plugin_name = "Blah Blah Blah Plugin"
-        plugin_info = self.registry.get_plugin_info(plugin_name)
+def test_get_plugin_info_dict_no_plugin(registry):
+    """
+    The goal of this test case is to validate the behavior of
+    ModuleRegistry.get_plugin_info when the given plugin cannot be found in
+    ModuleRegistry's internal representation of the plugins_info.
+    """
+    plugin_name = "PluginDoesNotExist"
+    plugin_info = registry.get_plugin_info(plugin_name)
 
-        self.assertIsInstance(plugin_info, dict)
-        self.assertEqual(plugin_info["longName"], plugin_name)
+    assert isinstance(plugin_info, dict)
+    assert plugin_info == {}
 
-    def test_get_plugin_info_dict_no_plugin(self):
-        """
-        The goal of this test case is to validate the behavior of
-        ModuleRegistry.get_plugin_info when the given plugin cannot be found in
-        ModuleRegistry's internal representation of the plugins_info.
-        """
-        plugin_name = "PluginDoesNotExist"
-        plugin_info = self.registry.get_plugin_info(plugin_name)
 
-        self.assertIsInstance(plugin_info, dict)
-        self.assertEqual(plugin_info, {})
+def test_get_plugin_info_dict_no_version(registry):
+    """
+    The goal of this test case is to validate the behavior of
+    ModuleRegistry.get_plugin_info when the given plugin shortName returns
+    plugin_info dict that has no version string. In a sane world where
+    plugin frameworks like Jenkins' are sane this should never happen, but
+    I am including this test and the corresponding default behavior
+    because, well, it's Jenkins.
+    """
+    plugin_name = "HerpDerpPlugin"
+    plugin_info = registry.get_plugin_info(plugin_name)
 
-    def test_get_plugin_info_dict_no_version(self):
-        """
-        The goal of this test case is to validate the behavior of
-        ModuleRegistry.get_plugin_info when the given plugin shortName returns
-        plugin_info dict that has no version string. In a sane world where
-        plugin frameworks like Jenkins' are sane this should never happen, but
-        I am including this test and the corresponding default behavior
-        because, well, it's Jenkins.
-        """
-        plugin_name = "HerpDerpPlugin"
-        plugin_info = self.registry.get_plugin_info(plugin_name)
+    assert isinstance(plugin_info, dict)
+    assert plugin_info["shortName"] == plugin_name
+    assert plugin_info["version"] == "0"
 
-        self.assertIsInstance(plugin_info, dict)
-        self.assertEqual(plugin_info["shortName"], plugin_name)
-        self.assertEqual(plugin_info["version"], "0")
 
-    def test_plugin_version_comparison(self):
-        """
-        The goal of this test case is to validate that valid tuple versions are
-        ordinally correct. That is, for each given scenario, v1.op(v2)==True
-        where 'op' is the equality operator defined for the scenario.
-        """
-        plugin_name = "JankyPlugin1"
-        plugin_info = self.registry.get_plugin_info(plugin_name)
-        v1 = plugin_info.get("version")
+def test_plugin_version_comparison(registry, scenario):
+    """
+    The goal of this test case is to validate that valid tuple versions are
+    ordinally correct. That is, for each given scenario, v1.op(v2)==True
+    where 'op' is the equality operator defined for the scenario.
+    """
+    plugin_name = "JankyPlugin1"
+    plugin_info = registry.get_plugin_info(plugin_name)
+    v1 = plugin_info.get("version")
 
-        op = getattr(pkg_resources.parse_version(v1), self.op)
-        test = op(pkg_resources.parse_version(self.v2))
+    op = getattr(pkg_resources.parse_version(v1), scenario.op)
+    test = op(pkg_resources.parse_version(scenario.v2))
 
-        self.assertTrue(
-            test,
-            msg="Unexpectedly found {0} {2} {1} == False "
-            "when comparing versions!".format(v1, self.v2, self.op),
-        )
+    assert test, (
+        f"Unexpectedly found {v1} {scenario.v2} {scenario.op} == False"
+        " when comparing versions!"
+    )
diff --git a/tests/modules/test_helpers.py b/tests/modules/test_helpers.py
index 5cf6d445f..255be3b5f 100644
--- a/tests/modules/test_helpers.py
+++ b/tests/modules/test_helpers.py
@@ -13,10 +13,11 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-from testtools.matchers import Equals
 import xml.etree.ElementTree as XML
 import yaml
 
+import pytest
+
 from jenkins_jobs.errors import InvalidAttributeError
 from jenkins_jobs.errors import MissingAttributeError
 from jenkins_jobs.errors import JenkinsJobsException
@@ -24,111 +25,108 @@ from jenkins_jobs.modules.helpers import (
     convert_mapping_to_xml,
     check_mutual_exclusive_data_args,
 )
-from tests import base
 
 
-class TestCaseTestHelpers(base.BaseTestCase):
-    def test_convert_mapping_to_xml(self):
-        """
-        Tests the test_convert_mapping_to_xml_fail_required function
-        """
+def test_convert_mapping_to_xml():
+    """
+    Tests the test_convert_mapping_to_xml_fail_required function
+    """
 
-        # Test default values
-        default_root = XML.Element("testdefault")
-        default_data = yaml.safe_load("string: hello")
-        default_mappings = [("default-string", "defaultString", "default")]
+    # Test default values
+    default_root = XML.Element("testdefault")
+    default_data = yaml.safe_load("string: hello")
+    default_mappings = [("default-string", "defaultString", "default")]
 
+    convert_mapping_to_xml(
+        default_root, default_data, default_mappings, fail_required=True
+    )
+    result = default_root.find("defaultString").text
+    result == "default"
+
+    # Test user input
+    user_input_root = XML.Element("testUserInput")
+    user_input_data = yaml.safe_load("user-input-string: hello")
+    user_input_mappings = [("user-input-string", "userInputString", "user-input")]
+
+    convert_mapping_to_xml(
+        user_input_root, user_input_data, user_input_mappings, fail_required=True
+    )
+    result = user_input_root.find("userInputString").text
+    result == "hello"
+
+    # Test missing required input
+    required_root = XML.Element("testrequired")
+    required_data = yaml.safe_load("string: hello")
+    required_mappings = [("required-string", "requiredString", None)]
+
+    with pytest.raises(MissingAttributeError):
         convert_mapping_to_xml(
-            default_root, default_data, default_mappings, fail_required=True
-        )
-        result = default_root.find("defaultString").text
-        self.assertThat(result, Equals("default"))
-
-        # Test user input
-        user_input_root = XML.Element("testUserInput")
-        user_input_data = yaml.safe_load("user-input-string: hello")
-        user_input_mappings = [("user-input-string", "userInputString", "user-input")]
-
-        convert_mapping_to_xml(
-            user_input_root, user_input_data, user_input_mappings, fail_required=True
-        )
-        result = user_input_root.find("userInputString").text
-        self.assertThat(result, Equals("hello"))
-
-        # Test missing required input
-        required_root = XML.Element("testrequired")
-        required_data = yaml.safe_load("string: hello")
-        required_mappings = [("required-string", "requiredString", None)]
-
-        self.assertRaises(
-            MissingAttributeError,
-            convert_mapping_to_xml,
             required_root,
             required_data,
             required_mappings,
             fail_required=True,
         )
 
-        # Test invalid user input for list
-        user_input_root = XML.Element("testUserInput")
-        user_input_data = yaml.safe_load("user-input-string: bye")
-        valid_inputs = ["hello"]
-        user_input_mappings = [
-            ("user-input-string", "userInputString", "user-input", valid_inputs)
-        ]
+    # Test invalid user input for list
+    user_input_root = XML.Element("testUserInput")
+    user_input_data = yaml.safe_load("user-input-string: bye")
+    valid_inputs = ["hello"]
+    user_input_mappings = [
+        ("user-input-string", "userInputString", "user-input", valid_inputs)
+    ]
 
-        self.assertRaises(
-            InvalidAttributeError,
-            convert_mapping_to_xml,
+    with pytest.raises(InvalidAttributeError):
+        convert_mapping_to_xml(
             user_input_root,
             user_input_data,
             user_input_mappings,
         )
 
-        # Test invalid user input for dict
-        user_input_root = XML.Element("testUserInput")
-        user_input_data = yaml.safe_load("user-input-string: later")
-        valid_inputs = {"hello": "world"}
-        user_input_mappings = [
-            ("user-input-string", "userInputString", "user-input", valid_inputs)
-        ]
+    # Test invalid user input for dict
+    user_input_root = XML.Element("testUserInput")
+    user_input_data = yaml.safe_load("user-input-string: later")
+    valid_inputs = {"hello": "world"}
+    user_input_mappings = [
+        ("user-input-string", "userInputString", "user-input", valid_inputs)
+    ]
 
-        self.assertRaises(
-            InvalidAttributeError,
-            convert_mapping_to_xml,
+    with pytest.raises(InvalidAttributeError):
+        convert_mapping_to_xml(
             user_input_root,
             user_input_data,
             user_input_mappings,
         )
 
-        # Test invalid key for dict
-        user_input_root = XML.Element("testUserInput")
-        user_input_data = yaml.safe_load("user-input-string: world")
-        valid_inputs = {"hello": "world"}
-        user_input_mappings = [
-            ("user-input-string", "userInputString", "user-input", valid_inputs)
-        ]
+    # Test invalid key for dict
+    user_input_root = XML.Element("testUserInput")
+    user_input_data = yaml.safe_load("user-input-string: world")
+    valid_inputs = {"hello": "world"}
+    user_input_mappings = [
+        ("user-input-string", "userInputString", "user-input", valid_inputs)
+    ]
 
-        self.assertRaises(
-            InvalidAttributeError,
-            convert_mapping_to_xml,
+    with pytest.raises(InvalidAttributeError):
+        convert_mapping_to_xml(
             user_input_root,
             user_input_data,
             user_input_mappings,
         )
 
-    def test_check_mutual_exclusive_data_args_no_mutual_exclusive(self):
-        @check_mutual_exclusive_data_args(0, "foo", "bar")
-        @check_mutual_exclusive_data_args(0, "foo", "baz")
-        def func(data):
-            pass
 
-        func({"baz": "qaz", "bar": "qaz"})
+def test_check_mutual_exclusive_data_args_no_mutual_exclusive():
+    @check_mutual_exclusive_data_args(0, "foo", "bar")
+    @check_mutual_exclusive_data_args(0, "foo", "baz")
+    def func(data):
+        pass
 
-    def test_check_mutual_exclusive_data_args_mutual_exclusive(self):
-        @check_mutual_exclusive_data_args(0, "foo", "bar")
-        @check_mutual_exclusive_data_args(0, "foo", "baz")
-        def func(data):
-            pass
+    func({"baz": "qaz", "bar": "qaz"})
 
-        self.assertRaises(JenkinsJobsException, func, {"foo": "qaz", "bar": "qaz"})
+
+def test_check_mutual_exclusive_data_args_mutual_exclusive():
+    @check_mutual_exclusive_data_args(0, "foo", "bar")
+    @check_mutual_exclusive_data_args(0, "foo", "baz")
+    def func(data):
+        pass
+
+    with pytest.raises(JenkinsJobsException):
+        func({"foo": "qaz", "bar": "qaz"})
diff --git a/tests/multibranch/test_multibranch.py b/tests/multibranch/test_multibranch.py
index c918366cf..5bc33b688 100644
--- a/tests/multibranch/test_multibranch.py
+++ b/tests/multibranch/test_multibranch.py
@@ -13,15 +13,25 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-from tests import base
-from tests.base import mock
-import os
+from operator import attrgetter
+from pathlib import Path
+
+import pytest
+
+from tests.enum_scenarios import scenario_list
 from jenkins_jobs.modules import project_multibranch
 
 
-@mock.patch("uuid.uuid4", mock.Mock(return_value="1-1-1-1-1"))
-class TestCaseMultibranchPipeline(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    default_config_file = "/dev/null"
-    klass = project_multibranch.WorkflowMultiBranch
+fixtures_dir = Path(__file__).parent / "fixtures"
+
+
+@pytest.fixture(
+    params=scenario_list(fixtures_dir),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+def test_yaml_snippet(check_generator):
+    check_generator(project_multibranch.WorkflowMultiBranch)
diff --git a/tests/notifications/test_notifications.py b/tests/notifications/test_notifications.py
index e1935efaf..7d5a961fa 100644
--- a/tests/notifications/test_notifications.py
+++ b/tests/notifications/test_notifications.py
@@ -15,13 +15,25 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import os
+from operator import attrgetter
+from pathlib import Path
 
+import pytest
+
+from tests.enum_scenarios import scenario_list
 from jenkins_jobs.modules import notifications
-from tests import base
 
 
-class TestCaseModuleNotifications(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    klass = notifications.Notifications
+fixtures_dir = Path(__file__).parent / "fixtures"
+
+
+@pytest.fixture(
+    params=scenario_list(fixtures_dir),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+def test_yaml_snippet(check_generator):
+    check_generator(notifications.Notifications)
diff --git a/tests/parallel/test_parallel.py b/tests/parallel/test_parallel.py
index b7ccd20b8..c199ae8a1 100644
--- a/tests/parallel/test_parallel.py
+++ b/tests/parallel/test_parallel.py
@@ -15,53 +15,52 @@
 import time
 from multiprocessing import cpu_count
 
-from testtools import matchers
-from testtools import TestCase
-
 from jenkins_jobs.parallel import concurrent
-from tests.base import mock
 
 
-class TestCaseParallel(TestCase):
-    def test_parallel_correct_order(self):
-        expected = list(range(10, 20))
+def test_parallel_correct_order():
+    expected = list(range(10, 20))
 
-        @concurrent
-        def parallel_test(num_base, num_extra):
-            return num_base + num_extra
+    @concurrent
+    def parallel_test(num_base, num_extra):
+        return num_base + num_extra
 
-        parallel_args = [{"num_extra": num} for num in range(10)]
-        result = parallel_test(10, concurrent=parallel_args)
-        self.assertThat(result, matchers.Equals(expected))
+    parallel_args = [{"num_extra": num} for num in range(10)]
+    result = parallel_test(10, concurrent=parallel_args)
+    assert result == expected
 
-    def test_parallel_time_less_than_serial(self):
-        @concurrent
-        def wait(secs):
-            time.sleep(secs)
 
-        before = time.time()
-        # ten threads to make it as fast as possible
-        wait(concurrent=[{"secs": 1} for _ in range(10)], n_workers=10)
-        after = time.time()
-        self.assertThat(after - before, matchers.LessThan(5))
+def test_parallel_time_less_than_serial():
+    @concurrent
+    def wait(secs):
+        time.sleep(secs)
 
-    def test_parallel_single_thread(self):
-        expected = list(range(10, 20))
+    before = time.time()
+    # ten threads to make it as fast as possible
+    wait(concurrent=[{"secs": 1} for _ in range(10)], n_workers=10)
+    after = time.time()
+    assert after - before < 5
 
-        @concurrent
-        def parallel_test(num_base, num_extra):
-            return num_base + num_extra
 
-        parallel_args = [{"num_extra": num} for num in range(10)]
-        result = parallel_test(10, concurrent=parallel_args, n_workers=1)
-        self.assertThat(result, matchers.Equals(expected))
+def test_parallel_single_thread():
+    expected = list(range(10, 20))
 
-    @mock.patch("jenkins_jobs.parallel.cpu_count", wraps=cpu_count)
-    def test_use_auto_detect_cores(self, mockCpu_count):
-        @concurrent
-        def parallel_test():
-            return True
+    @concurrent
+    def parallel_test(num_base, num_extra):
+        return num_base + num_extra
 
-        result = parallel_test(concurrent=[{} for _ in range(10)], n_workers=0)
-        self.assertThat(result, matchers.Equals([True for _ in range(10)]))
-        mockCpu_count.assert_called_once_with()
+    parallel_args = [{"num_extra": num} for num in range(10)]
+    result = parallel_test(10, concurrent=parallel_args, n_workers=1)
+    result == expected
+
+
+def test_use_auto_detect_cores(mocker):
+    mock = mocker.patch("jenkins_jobs.parallel.cpu_count", wraps=cpu_count)
+
+    @concurrent
+    def parallel_test():
+        return True
+
+    result = parallel_test(concurrent=[{} for _ in range(10)], n_workers=0)
+    assert result == [True for _ in range(10)]
+    mock.assert_called_once_with()
diff --git a/tests/parameters/test_parameters.py b/tests/parameters/test_parameters.py
index fad81a00d..1408e41c1 100644
--- a/tests/parameters/test_parameters.py
+++ b/tests/parameters/test_parameters.py
@@ -15,13 +15,25 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import os
+from operator import attrgetter
+from pathlib import Path
 
+import pytest
+
+from tests.enum_scenarios import scenario_list
 from jenkins_jobs.modules import parameters
-from tests import base
 
 
-class TestCaseModuleParameters(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    klass = parameters.Parameters
+fixtures_dir = Path(__file__).parent
+
+
+@pytest.fixture(
+    params=scenario_list(fixtures_dir),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+def test_yaml_snippet(check_generator):
+    check_generator(parameters.Parameters)
diff --git a/tests/properties/test_properties.py b/tests/properties/test_properties.py
index 5c722a2b8..a5611ddee 100644
--- a/tests/properties/test_properties.py
+++ b/tests/properties/test_properties.py
@@ -15,13 +15,25 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import os
+from operator import attrgetter
+from pathlib import Path
 
+import pytest
+
+from tests.enum_scenarios import scenario_list
 from jenkins_jobs.modules import properties
-from tests import base
 
 
-class TestCaseModuleProperties(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    klass = properties.Properties
+fixtures_dir = Path(__file__).parent / "fixtures"
+
+
+@pytest.fixture(
+    params=scenario_list(fixtures_dir),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+def test_yaml_snippet(check_generator):
+    check_generator(properties.Properties)
diff --git a/tests/publishers/test_publishers.py b/tests/publishers/test_publishers.py
index 16d2295ea..756e63c50 100644
--- a/tests/publishers/test_publishers.py
+++ b/tests/publishers/test_publishers.py
@@ -15,13 +15,25 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import os
+from operator import attrgetter
+from pathlib import Path
 
+import pytest
+
+from tests.enum_scenarios import scenario_list
 from jenkins_jobs.modules import publishers
-from tests import base
 
 
-class TestCaseModulePublishers(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    klass = publishers.Publishers
+fixtures_dir = Path(__file__).parent / "fixtures"
+
+
+@pytest.fixture(
+    params=scenario_list(fixtures_dir),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+def test_yaml_snippet(check_generator):
+    check_generator(publishers.Publishers)
diff --git a/tests/reporters/test_reporters.py b/tests/reporters/test_reporters.py
index 5b419e809..5f31a1cea 100644
--- a/tests/reporters/test_reporters.py
+++ b/tests/reporters/test_reporters.py
@@ -14,13 +14,25 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import os
+from operator import attrgetter
+from pathlib import Path
 
+import pytest
+
+from tests.enum_scenarios import scenario_list
 from jenkins_jobs.modules import reporters
-from tests import base
 
 
-class TestCaseModuleReporters(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    klass = reporters.Reporters
+fixtures_dir = Path(__file__).parent / "fixtures"
+
+
+@pytest.fixture(
+    params=scenario_list(fixtures_dir),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+def test_yaml_snippet(check_generator):
+    check_generator(reporters.Reporters)
diff --git a/tests/scm/test_scm.py b/tests/scm/test_scm.py
index 62ee87627..467b44d8f 100644
--- a/tests/scm/test_scm.py
+++ b/tests/scm/test_scm.py
@@ -15,13 +15,25 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import os
+from operator import attrgetter
+from pathlib import Path
 
+import pytest
+
+from tests.enum_scenarios import scenario_list
 from jenkins_jobs.modules import scm
-from tests import base
 
 
-class TestCaseModuleSCM(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    klass = scm.SCM
+fixtures_dir = Path(__file__).parent / "fixtures"
+
+
+@pytest.fixture(
+    params=scenario_list(fixtures_dir),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+def test_yaml_snippet(check_generator):
+    check_generator(scm.SCM)
diff --git a/tests/triggers/test_triggers.py b/tests/triggers/test_triggers.py
index 9f0ae5f49..a09f8ecbb 100644
--- a/tests/triggers/test_triggers.py
+++ b/tests/triggers/test_triggers.py
@@ -15,13 +15,25 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import os
+from operator import attrgetter
+from pathlib import Path
 
+import pytest
+
+from tests.enum_scenarios import scenario_list
 from jenkins_jobs.modules import triggers
-from tests import base
 
 
-class TestCaseModuleTriggers(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    klass = triggers.Triggers
+fixtures_dir = Path(__file__).parent / "fixtures"
+
+
+@pytest.fixture(
+    params=scenario_list(fixtures_dir),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+def test_yaml_snippet(check_generator):
+    check_generator(triggers.Triggers)
diff --git a/tests/views/test_views.py b/tests/views/test_views.py
index 06d153c5b..af41eb5c1 100644
--- a/tests/views/test_views.py
+++ b/tests/views/test_views.py
@@ -12,47 +12,62 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.import os
 
-import os
+from operator import attrgetter
+from pathlib import Path
+
+import pytest
+
 from jenkins_jobs.modules import view_all
 from jenkins_jobs.modules import view_delivery_pipeline
 from jenkins_jobs.modules import view_list
 from jenkins_jobs.modules import view_nested
 from jenkins_jobs.modules import view_pipeline
 from jenkins_jobs.modules import view_sectioned
-from tests import base
+from tests.enum_scenarios import scenario_list
 
 
-class TestCaseModuleViewAll(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    klass = view_all.All
+fixtures_dir = Path(__file__).parent / "fixtures"
 
 
-class TestCaseModuleViewDeliveryPipeline(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    klass = view_delivery_pipeline.DeliveryPipeline
+@pytest.fixture(
+    params=scenario_list(fixtures_dir),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
 
 
-class TestCaseModuleViewList(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    klass = view_list.List
+# But actually this is a view.
+@pytest.fixture
+def project(input, registry):
+    type_to_class = {
+        "all": view_all.All,
+        "delivery_pipeline": view_delivery_pipeline.DeliveryPipeline,
+        "list": view_list.List,
+        "nested": view_nested.Nested,
+        "pipeline": view_pipeline.Pipeline,
+        "sectioned": view_sectioned.Sectioned,
+    }
+    try:
+        class_name = input["view-type"]
+    except KeyError:
+        raise RuntimeError("'view-type' element is expected in input yaml")
+    cls = type_to_class[class_name]
+    return cls(registry)
 
 
-class TestCaseModuleViewNested(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    klass = view_nested.Nested
+view_class_list = [
+    view_all.All,
+    view_delivery_pipeline.DeliveryPipeline,
+    view_list.List,
+    view_nested.Nested,
+    view_pipeline.Pipeline,
+    view_sectioned.Sectioned,
+]
 
 
-class TestCaseModuleViewPipeline(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    klass = view_pipeline.Pipeline
-
-
-class TestCaseModuleViewSectioned(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    klass = view_sectioned.Sectioned
+@pytest.mark.parametrize(
+    "view_class", [pytest.param(cls, id=cls.__name__) for cls in view_class_list]
+)
+def test_view(view_class, check_generator):
+    check_generator(view_class)
diff --git a/tests/wrappers/test_wrappers.py b/tests/wrappers/test_wrappers.py
index f84d040ff..bd32cafa2 100644
--- a/tests/wrappers/test_wrappers.py
+++ b/tests/wrappers/test_wrappers.py
@@ -15,13 +15,25 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import os
+from operator import attrgetter
+from pathlib import Path
 
+import pytest
+
+from tests.enum_scenarios import scenario_list
 from jenkins_jobs.modules import wrappers
-from tests import base
 
 
-class TestCaseModuleWrappers(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-    klass = wrappers.Wrappers
+fixtures_dir = Path(__file__).parent / "fixtures"
+
+
+@pytest.fixture(
+    params=scenario_list(fixtures_dir),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+def test_yaml_snippet(check_generator):
+    check_generator(wrappers.Wrappers)
diff --git a/tests/xml_config/test_xml_config.py b/tests/xml_config/test_xml_config.py
index 6f8fd0b76..422c8ab17 100644
--- a/tests/xml_config/test_xml_config.py
+++ b/tests/xml_config/test_xml_config.py
@@ -12,65 +12,67 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import os
+from pathlib import Path
 
-from jenkins_jobs import errors
-from jenkins_jobs import parser
-from jenkins_jobs import registry
-from jenkins_jobs import xml_config
+import pytest
 
-from tests import base
+from jenkins_jobs.config import JJBConfig
+from jenkins_jobs.errors import JenkinsJobsException
+from jenkins_jobs.parser import YamlParser
+from jenkins_jobs.registry import ModuleRegistry
+from jenkins_jobs.xml_config import XmlJobGenerator, XmlViewGenerator
 
 
-class TestXmlJobGeneratorExceptions(base.BaseTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "exceptions")
+fixtures_dir = Path(__file__).parent / "exceptions"
 
-    def test_invalid_project(self):
-        self.conf_filename = None
-        config = self._get_config()
 
-        yp = parser.YamlParser(config)
-        yp.parse(os.path.join(self.fixtures_path, "invalid_project.yaml"))
+@pytest.fixture
+def config():
+    config = JJBConfig()
+    config.validate()
+    return config
 
-        reg = registry.ModuleRegistry(config)
-        job_data, _ = yp.expandYaml(reg)
 
-        # Generate the XML tree
-        xml_generator = xml_config.XmlJobGenerator(reg)
-        e = self.assertRaises(
-            errors.JenkinsJobsException, xml_generator.generateXML, job_data
-        )
-        self.assertIn("Unrecognized project-type:", str(e))
+@pytest.fixture
+def parser(config):
+    return YamlParser(config)
 
-    def test_invalid_view(self):
-        self.conf_filename = None
-        config = self._get_config()
 
-        yp = parser.YamlParser(config)
-        yp.parse(os.path.join(self.fixtures_path, "invalid_view.yaml"))
+@pytest.fixture
+def registry(config):
+    return ModuleRegistry(config)
 
-        reg = registry.ModuleRegistry(config)
-        _, view_data = yp.expandYaml(reg)
 
-        # Generate the XML tree
-        xml_generator = xml_config.XmlViewGenerator(reg)
-        e = self.assertRaises(
-            errors.JenkinsJobsException, xml_generator.generateXML, view_data
-        )
-        self.assertIn("Unrecognized view-type:", str(e))
+def test_invalid_project(parser, registry):
+    parser.parse(str(fixtures_dir / "invalid_project.yaml"))
+    jobs, views = parser.expandYaml(registry)
 
-    def test_incorrect_template_params(self):
-        self.conf_filename = None
-        config = self._get_config()
+    generator = XmlJobGenerator(registry)
 
-        yp = parser.YamlParser(config)
-        yp.parse(os.path.join(self.fixtures_path, "failure_formatting_component.yaml"))
+    with pytest.raises(JenkinsJobsException) as excinfo:
+        generator.generateXML(jobs)
+    assert "Unrecognized project-type:" in str(excinfo.value)
 
-        reg = registry.ModuleRegistry(config)
-        reg.set_parser_data(yp.data)
-        job_data_list, view_data_list = yp.expandYaml(reg)
 
-        xml_generator = xml_config.XmlJobGenerator(reg)
-        self.assertRaises(Exception, xml_generator.generateXML, job_data_list)
-        self.assertIn("Failure formatting component", self.logger.output)
-        self.assertIn("Problem formatting with args", self.logger.output)
+def test_invalid_view(parser, registry):
+    parser.parse(str(fixtures_dir / "invalid_view.yaml"))
+    jobs, views = parser.expandYaml(registry)
+
+    generator = XmlViewGenerator(registry)
+
+    with pytest.raises(JenkinsJobsException) as excinfo:
+        generator.generateXML(views)
+    assert "Unrecognized view-type:" in str(excinfo.value)
+
+
+def test_template_params(caplog, parser, registry):
+    parser.parse(str(fixtures_dir / "failure_formatting_component.yaml"))
+    registry.set_parser_data(parser.data)
+    jobs, views = parser.expandYaml(registry)
+
+    generator = XmlJobGenerator(registry)
+
+    with pytest.raises(Exception):
+        generator.generateXML(jobs)
+    assert "Failure formatting component" in caplog.text
+    assert "Problem formatting with args" in caplog.text
diff --git a/tests/yamlparser/test_fixtures.py b/tests/yamlparser/test_fixtures.py
new file mode 100644
index 000000000..6c18fd22a
--- /dev/null
+++ b/tests/yamlparser/test_fixtures.py
@@ -0,0 +1,41 @@
+# Joint copyright:
+#  - Copyright 2012,2013 Wikimedia Foundation
+#  - Copyright 2012,2013 Antoine "hashar" Musso
+#  - Copyright 2013 Arnaud Fabre
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import os
+from operator import attrgetter
+from pathlib import Path
+
+import pytest
+
+from tests.enum_scenarios import scenario_list
+
+
+fixtures_dir = Path(__file__).parent / "fixtures"
+
+
+@pytest.fixture(
+    params=scenario_list(fixtures_dir),
+    ids=attrgetter("name"),
+)
+def scenario(request):
+    return request.param
+
+
+def test_yaml_snippet(check_job):
+    # Some tests using config with 'include_path' expect JJB root to be current directory.
+    os.chdir(Path(__file__).parent / "../..")
+    check_job()
diff --git a/tests/yamlparser/test_parser_exceptions.py b/tests/yamlparser/test_parser_exceptions.py
new file mode 100644
index 000000000..e85725154
--- /dev/null
+++ b/tests/yamlparser/test_parser_exceptions.py
@@ -0,0 +1,53 @@
+# Joint copyright:
+#  - Copyright 2012,2013 Wikimedia Foundation
+#  - Copyright 2012,2013 Antoine "hashar" Musso
+#  - Copyright 2013 Arnaud Fabre
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import os
+from pathlib import Path
+
+import pytest
+
+
+exceptions_dir = Path(__file__).parent / "exceptions"
+
+
+# Override to avoid scenarios usage.
+@pytest.fixture
+def config_path():
+    return os.devnull
+
+
+# Override to avoid scenarios usage.
+@pytest.fixture
+def plugins_info():
+    return None
+
+
+def test_incorrect_template_dimensions(caplog, check_parser):
+    in_path = exceptions_dir / "incorrect_template_dimensions.yaml"
+    with pytest.raises(Exception) as excinfo:
+        check_parser(in_path)
+    assert "'NoneType' object is not iterable" in str(excinfo.value)
+    assert "- branch: current\n  current: null" in caplog.text
+
+
+@pytest.mark.parametrize("name", ["template", "params"])
+def test_failure_formatting(caplog, check_parser, name):
+    in_path = exceptions_dir / f"failure_formatting_{name}.yaml"
+    with pytest.raises(Exception):
+        check_parser(in_path)
+    assert f"Failure formatting {name}" in caplog.text
+    assert "Problem formatting with args" in caplog.text
diff --git a/tests/yamlparser/test_yamlparser.py b/tests/yamlparser/test_yamlparser.py
deleted file mode 100644
index 320308355..000000000
--- a/tests/yamlparser/test_yamlparser.py
+++ /dev/null
@@ -1,67 +0,0 @@
-# Joint copyright:
-#  - Copyright 2012,2013 Wikimedia Foundation
-#  - Copyright 2012,2013 Antoine "hashar" Musso
-#  - Copyright 2013 Arnaud Fabre
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-# License for the specific language governing permissions and limitations
-# under the License.
-
-import os
-
-from jenkins_jobs import parser
-from jenkins_jobs import registry
-
-from tests import base
-
-
-class TestCaseModuleYamlInclude(base.SingleJobTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "fixtures")
-    scenarios = base.get_scenarios(fixtures_path)
-
-
-class TestYamlParserExceptions(base.BaseTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "exceptions")
-
-    def test_incorrect_template_dimensions(self):
-        self.conf_filename = None
-        config = self._get_config()
-
-        yp = parser.YamlParser(config)
-        yp.parse(os.path.join(self.fixtures_path, "incorrect_template_dimensions.yaml"))
-
-        reg = registry.ModuleRegistry(config)
-
-        e = self.assertRaises(Exception, yp.expandYaml, reg)
-        self.assertIn("'NoneType' object is not iterable", str(e))
-        self.assertIn("- branch: current\n  current: null", self.logger.output)
-
-
-class TestYamlParserFailureFormattingExceptions(base.BaseScenariosTestCase):
-    fixtures_path = os.path.join(os.path.dirname(__file__), "exceptions")
-    scenarios = [("s1", {"name": "template"}), ("s2", {"name": "params"})]
-
-    def test_yaml_snippet(self):
-        self.conf_filename = None
-        config = self._get_config()
-
-        yp = parser.YamlParser(config)
-        yp.parse(
-            os.path.join(
-                self.fixtures_path, "failure_formatting_{}.yaml".format(self.name)
-            )
-        )
-
-        reg = registry.ModuleRegistry(config)
-
-        self.assertRaises(Exception, yp.expandYaml, reg)
-        self.assertIn("Failure formatting {}".format(self.name), self.logger.output)
-        self.assertIn("Problem formatting with args", self.logger.output)
diff --git a/tox.ini b/tox.ini
index 9688584c4..05822d942 100644
--- a/tox.ini
+++ b/tox.ini
@@ -22,7 +22,7 @@ commands =
     - find . -type d -name "__pycache__" -delete
     # test that we can call jjb using both variants with same results
     bash {toxinidir}/tools/test-commands.sh
-    stestr run --slowest {posargs}
+    pytest {posargs}
 whitelist_externals =
     bash
     find
@@ -34,16 +34,14 @@ commands =
     bash -c "if [ -d {toxinidir}/../python-jenkins ]; then \
     pip install -q -U -e 'git+file://{toxinidir}/../python-jenkins#egg=python-jenkins' ; else \
     pip install -q -U -e 'git+https://git.openstack.org/openstack/python-jenkins@master#egg=python-jenkins' ; fi "
-    stestr run --slowest {posargs}
+    pytest {posargs}
 
 [testenv:cover]
 setenv =
     {[testenv]setenv}
-    PYTHON=coverage run --source jenkins_jobs --parallel-mode
 commands =
     {[tox]install_test_deps}
-    stestr run {posargs}
-    coverage combine
+    coverage run --source jenkins_jobs -m pytest {posargs}
     coverage html -d cover
     coverage xml -o cover/coverage.xml