Repository: capitalone/edgetest (language: python)

Issue:
Test lower bounds
It's useful to know when you need to raise a lower bound of a dependency when you add a new feature to exclude versions you are no longer compatible with. We can start with a simple version and consider a more complex version to implement.

1. Just test the lower bounds of each listed dependency and tell the user that they need to upgrade to some version that they would do manually.
2. Find the minimum lower bound of each dependency such that the tests still pass.


Discussion / hints:

Fix patch:
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
index 3251aa6..9b8b048 100644
--- a/docs/source/usage.rst
+++ b/docs/source/usage.rst
@@ -457,6 +457,90 @@ For instance, ``snowflake-connector-python[pandas]>=2.2.8,<2.3.9`` might be repl
                 --deps scikit-learn \
                 --export
 
+Testing lower bounds of packages
+--------------------------------
+
+.. warning::
+
+    This feature is experimental and may change in future releases. Limited functionality is available.
+
+Suppose you want to test if a new feature is compatible with dependencies' lower bounds.
+You can use an edgetest environment to test the lower bounds of dependencies in your ``setup.cfg`` or ``pyproject.toml`` as follows:
+
+.. tabs::
+
+    .. tab:: .cfg
+
+        .. code-block:: ini
+
+            [edgetest.envs.pandas_lower]
+            lower =
+                pandas
+
+    .. tab:: .toml
+
+        .. code-block:: toml
+
+            [edgetest.envs.pandas_lower]
+            lower = [
+                "pandas"
+            ]
+
+Running the edgetest command using this environment specification will:
+
+1. Create your virtual environment (as in the standard case),
+2. Install the local package,
+3. Install the lower version of ``pandas`` as specified in your project configuration file, and
+4. Run the ``pytest`` in the environment.
+
+For example, if your project configuration looks like this:
+
+.. tabs::
+
+    .. tab:: .cfg
+
+        .. code-block:: ini
+
+            [options]
+            install_requires =
+                pandas>=0.24.3
+
+            [edgetest.envs.pandas_lower]
+            lower =
+                pandas
+
+    .. tab:: .toml
+
+        .. code-block:: toml
+
+            [project]
+            dependencies = [
+                "pandas>=0.24.3",
+            ]
+
+            [edgetest.envs.pandas_lower]
+            lower =
+                pandas
+
+then edgetest will:
+
+1. Create the ``pandas_lower`` virtual environment,
+2. Install the local package,
+3. Install ``pandas==0.24.3``, and
+4. Run the ``pytest`` in the environment.
+
+Testing lower bounds of dependencies currently has the following limitations:
+
+- Can only parse main dependencies. Edgetest cannot parse lower bounds specified in extras.
+- Can only install lower bounds of packages specified with ``>=``. Edgetest cannot test the lower bounds of packages specified with ``>``.
+- Cannot automatically update or export lower bounds of dependencies to a project configuration file.
+
+.. note::
+    It is hard to install lower bounds of dependencies if they are very outdated.
+    Older versions of dependencies like pandas may be incompatible with newer Python versions
+    or may not be able to be installed with a given numpy version. In the case where installation
+    is not possible, edgetest will report that the setup was unsuccessful and move on to the next environment.
+
 Using plugins
 -------------
 
diff --git a/setup.cfg b/setup.cfg
index abc6cee..534aa97 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -161,3 +161,16 @@ extras =
 	tests
 deps =
 	pip-tools
+
+[edgetest.envs.low]
+python_version = 3.9
+lower =
+	Cerberus
+	click
+	pluggy
+	tabulate
+	packaging
+extras =
+	tests
+deps =
+	pip-tools


Test patch:
diff --git a/edgetest/core.py b/edgetest/core.py
index cc3ca90..cbc3c30 100644
--- a/edgetest/core.py
+++ b/edgetest/core.py
@@ -25,6 +25,9 @@ class TestPackage:
         The name of the conda environment to create/use.
     upgrade : list
         The list of packages to upgrade
+    lower : list
+        The list of packages to install lower bounds alongside the lower bound value.
+        E.g. ``["pandas==1.5.2"]``.
     package_dir : str, optional (default None)
         The location of the local package to install and test.
 
@@ -41,7 +44,8 @@ def __init__(
         self,
         hook: _HookRelay,
         envname: str,
-        upgrade: List[str],
+        upgrade: Optional[List[str]] = None,
+        lower: Optional[List[str]] = None,
         package_dir: Optional[str] = None,
     ):
         """Init method."""
@@ -49,9 +53,16 @@ def __init__(
         self._basedir = Path(Path.cwd(), ".edgetest")
         self._basedir.mkdir(exist_ok=True)
         self.envname = envname
+
+        if (upgrade is None and lower is None) or (
+            upgrade is not None and lower is not None
+        ):
+            raise ValueError("Exactly one of upgrade and lower must be provided.")
         self.upgrade = upgrade
+        self.lower = lower
         self.package_dir = package_dir or "."
 
+        self.setup_status: bool = False
         self.status: bool = False
 
     @property
@@ -69,6 +80,7 @@ def setup(
         self,
         extras: Optional[List[str]] = None,
         deps: Optional[List[str]] = None,
+        skip: bool = False,
         **options,
     ) -> None:
         """Set up the testing environment.
@@ -79,6 +91,9 @@ def setup(
             The list of extra installations to include.
         deps : list, optional (default None)
             A list of additional dependencies to install via ``pip``
+        skip : bool, optional (default False)
+            Whether to skip setup as a pre-made environment has already been
+            created.
         **options
             Additional options for ``self.hook.create_environment``.
 
@@ -91,12 +106,22 @@ def setup(
         RuntimeError
             This error will be raised if any part of the set up process fails.
         """
+        if skip:
+            self.setup_status = True
+            return
         # Create the conda environment
-        LOG.info(f"Creating the following environment: {self.envname}...")
-        self.hook.create_environment(
-            basedir=self._basedir, envname=self.envname, conf=options
-        )
-        LOG.info(f"Successfully created {self.envname}")
+        try:
+            LOG.info(f"Creating the following environment: {self.envname}...")
+            self.hook.create_environment(
+                basedir=self._basedir, envname=self.envname, conf=options
+            )
+            LOG.info(f"Successfully created {self.envname}")
+        except RuntimeError:
+            LOG.exception(
+                "Could not create the following environment: %s", self.envname
+            )
+            self.setup_status = False
+            return
 
         # Install the local package
         with pushd(self.package_dir):
@@ -105,31 +130,85 @@ def setup(
                 pkg += f"[{', '.join(extras)}]"
             if deps:
                 LOG.info(
-                    f"Installing specified additional dependencies into {self.envname}..."
+                    "Installing specified additional dependencies into %s: %s",
+                    self.envname,
+                    ", ".join(deps),
                 )
                 split = [shlex.split(dep) for dep in deps]
-                _run_command(
-                    self.python_path,
-                    "-m",
-                    "pip",
-                    "install",
-                    *[itm for lst in split for itm in lst],
+                try:
+                    _run_command(
+                        self.python_path,
+                        "-m",
+                        "pip",
+                        "install",
+                        *[itm for lst in split for itm in lst],
+                    )
+                except RuntimeError:
+                    LOG.exception(
+                        "Unable to install specified additional dependencies in %s",
+                        self.envname,
+                    )
+                    self.setup_status = False
+                    return
+                LOG.info(
+                    f"Successfully installed specified additional dependencies into {self.envname}"
                 )
+
             LOG.info(f"Installing the local package into {self.envname}...")
-            _run_command(self.python_path, "-m", "pip", "install", pkg)
-            LOG.info(f"Successfully installed the local package into {self.envname}...")
-
-        # Upgrade package(s)
-        LOG.info(
-            f"Upgrading the following packages in {self.envname}: {', '.join(self.upgrade)}"
-        )
-        self.hook.run_update(
-            basedir=self._basedir,
-            envname=self.envname,
-            upgrade=self.upgrade,
-            conf=options,
-        )
-        LOG.info(f"Successfully upgraded packages in {self.envname}")
+            try:
+                _run_command(self.python_path, "-m", "pip", "install", pkg)
+                LOG.info(
+                    f"Successfully installed the local package into {self.envname}..."
+                )
+            except RuntimeError:
+                LOG.exception(
+                    "Unable to install the local package into %s", self.envname
+                )
+                self.setup_status = False
+                return
+
+        if self.upgrade:
+            # Upgrade package(s)
+            LOG.info(
+                f"Upgrading the following packages in {self.envname}: {', '.join(self.upgrade)}"
+            )
+            try:
+                self.hook.run_update(
+                    basedir=self._basedir,
+                    envname=self.envname,
+                    upgrade=self.upgrade,
+                    conf=options,
+                )
+                self.setup_status = True
+            except RuntimeError:
+                self.setup_status = False
+                LOG.exception("Unable to upgrade packages in %s", self.envname)
+                return
+            LOG.info("Successfully upgraded packages in %s", self.envname)
+        else:
+            # Install lower bounds of package(s)
+            LOG.info(
+                "Installing lower bounds of packages in %s: %s",
+                {self.envname},
+                ", ".join(self.lower),  # type:ignore
+            )
+            try:
+                self.hook.run_install_lower(
+                    basedir=self._basedir,
+                    envname=self.envname,
+                    lower=self.lower,
+                    conf=options,
+                )
+                self.setup_status = True
+            except RuntimeError:
+                self.setup_status = False
+                LOG.exception(
+                    "Unable to install lower bounds of packages in %s", self.envname
+                )
+                return
+            LOG.info(
+                f"Successfully installed lower bounds of packages in {self.envname}"
+            )
 
     def upgraded_packages(self) -> List[Dict[str, str]]:
         """Get the list of upgraded packages for the test environment.
@@ -144,6 +223,8 @@ def upgraded_packages(self) -> List[Dict[str, str]]:
             The output of ``pip list --format json``, filtered to the packages upgraded
             for this environment.
         """
+        if self.upgrade is None:
+            return []
         # Get the version for the upgraded package(s)
         out, _ = _run_command(self.python_path, "-m", "pip", "list", "--format", "json")
         outjson = json.loads(out)
@@ -155,6 +236,21 @@ def upgraded_packages(self) -> List[Dict[str, str]]:
             if _isin_case_dashhyphen_ins(pkg.get("name", ""), upgrade_wo_extras)
         ]
 
+    def lowered_packages(self) -> List[Dict[str, str]]:
+        """Get list of lowered packages for the test environment.
+
+        Returns
+        -------
+        List[Dict[str, str]]
+            List of lowered packages and their versions
+        """
+        if self.lower is None:
+            return []
+        packages_split = (pkg_str.split("==") for pkg_str in self.lower)
+        return [
+            {"name": pkg_info[0], "version": pkg_info[1]} for pkg_info in packages_split
+        ]
+
     def run_tests(self, command: str) -> int:
         """Run the tests in the package directory.
 
@@ -168,6 +264,8 @@ def run_tests(self, command: str) -> int:
         int
             The exit code
         """
+        if not self.setup_status:
+            raise RuntimeError("Environment setup failed. Cannot run tests.")
         with pushd(self.package_dir):
             popen = Popen(
                 (self.python_path, "-m", *shlex.split(command)), universal_newlines=True
diff --git a/edgetest/hookspecs.py b/edgetest/hookspecs.py
index 244b04f..d64a36c 100644
--- a/edgetest/hookspecs.py
+++ b/edgetest/hookspecs.py
@@ -95,6 +95,25 @@ def run_update(basedir: str, envname: str, upgrade: List, conf: Dict):
     """
 
 
+@hookspec(firstresult=True)
+def run_install_lower(basedir: str, envname: str, lower: Dict[str, str], conf: Dict):
+    """Install lower bounds of packages provided.
+
+    Parameters
+    ----------
+    basedir : str
+        The base directory location for the environment.
+    envname : str
+        Environment to install into.
+    lower_bounds : Dict[str, str]
+        Lower bounds of packages to install.
+    conf : Dict
+        The configuration dictionary for the environment. This is useful if you
+        want to add configuration arguments for additional dependencies that can
+        only be installed through the environment manager (e.g. Conda).
+    """
+
+
 @hookspec
 def post_run_hook(testers: List, conf: Dict):
     """Post testing hook.
diff --git a/edgetest/interface.py b/edgetest/interface.py
index 63106fb..3d7cbd5 100644
--- a/edgetest/interface.py
+++ b/edgetest/interface.py
@@ -150,17 +150,19 @@ def cli(
             TestPackage(
                 hook=pm.hook,
                 envname=env["name"],
-                upgrade=env["upgrade"],
+                upgrade=env.get("upgrade"),
+                lower=env.get("lower"),
                 package_dir=env["package_dir"],
             )
         )
         # Set up the test environment
         if nosetup:
             click.echo(f"Using existing environment for {env['name']}...")
+            testers[-1].setup(skip=True, **env)
         else:
             testers[-1].setup(**env)
         # Run the tests
-        if notest:
+        if notest or not testers[-1].setup_status:
             click.echo(f"Skipping tests for {env['name']}")
         else:
             testers[-1].run_tests(env["command"])
diff --git a/edgetest/lib.py b/edgetest/lib.py
index 187fb43..dc9e97b 100644
--- a/edgetest/lib.py
+++ b/edgetest/lib.py
@@ -80,3 +80,33 @@ def run_update(basedir: str, envname: str, upgrade: List, conf: Dict):
         )
     except Exception:
         raise RuntimeError(f"Unable to pip upgrade: {upgrade}")
+
+
+@hookimpl(trylast=True)
+def run_install_lower(basedir: str, envname: str, lower: List[str], conf: Dict):
+    """Install lower bounds of packages provided.
+
+    Parameters
+    ----------
+    basedir : str
+        The base directory location for the environment.
+    envname : str
+        Environment to install into.
+    lower : List[str]
+        Lower bounds of packages to install.
+    conf : Dict
+        The configuration dictionary for the environment. This is useful if you
+        want to add configuration arguments for additional dependencies that can
+        only be installed through the environment manager (e.g. Conda).
+    """
+    python_path = path_to_python(basedir, envname)
+    try:
+        _run_command(
+            python_path,
+            "-m",
+            "pip",
+            "install",
+            *lower,
+        )
+    except Exception:
+        raise RuntimeError(f"Unable to pip install: {lower}")
diff --git a/edgetest/report.py b/edgetest/report.py
index 559ab9d..c67b3f1 100644
--- a/edgetest/report.py
+++ b/edgetest/report.py
@@ -28,11 +28,39 @@ def gen_report(testers: List[TestPackage], output_type: str = "rst") -> Any:
     if output_type not in VALID_OUTPUTS:
         raise ValueError(f"Invalid output_type provided: {output_type}")
 
-    headers = ["Environment", "Passing tests", "Upgraded packages", "Package version"]
+    headers = [
+        "Environment",
+        "Setup successful",
+        "Passing tests",
+        "Upgraded packages",
+        "Lowered packages",
+        "Package version",
+    ]
     rows: List[List] = []
     for env in testers:
         upgraded = env.upgraded_packages()
+        lowered = env.lowered_packages()
         for pkg in upgraded:
-            rows.append([env.envname, env.status, pkg["name"], pkg["version"]])
+            rows.append(
+                [
+                    env.envname,
+                    env.setup_status,
+                    env.status,
+                    pkg["name"],
+                    "",
+                    pkg["version"],
+                ]
+            )
+        for pkg in lowered:
+            rows.append(
+                [
+                    env.envname,
+                    env.setup_status,
+                    env.status,
+                    "",
+                    pkg["name"],
+                    pkg["version"],
+                ]
+            )
 
     return tabulate(rows, headers=headers, tablefmt=output_type)
diff --git a/edgetest/schema.py b/edgetest/schema.py
index f8126c8..e1eda93 100644
--- a/edgetest/schema.py
+++ b/edgetest/schema.py
@@ -19,6 +19,14 @@
                     },
                     "coerce": "listify",
                     "required": True,
+                    "excludes": "lower",
+                },
+                "lower": {
+                    "type": "list",
+                    "schema": {"type": "string"},
+                    "coerce": "listify",
+                    "required": True,
+                    "excludes": "upgrade",
                 },
                 "extras": {
                     "type": "list",
diff --git a/edgetest/utils.py b/edgetest/utils.py
index 118edfd..ae7a50f 100644
--- a/edgetest/utils.py
+++ b/edgetest/utils.py
@@ -215,6 +215,15 @@ def parse_cfg(filename: str = "setup.cfg", requirements: Optional[str] = None) -
         if section_name[1] == "envs":
             output["envs"].append(dict(config[section]))
             output["envs"][-1]["name"] = section_name[2]
+            if (
+                "lower" in output["envs"][-1]
+                and "options" in config
+                and "install_requires" in config["options"]
+            ):
+                output["envs"][-1]["lower"] = get_lower_bounds(
+                    config.get("options", "install_requires"),
+                    output["envs"][-1]["lower"],
+                )
         else:
             output[section_name[1]] = dict(config[section])
     if len(output["envs"]) == 0:
@@ -334,13 +343,23 @@ def parse_toml(
                         )
                     output["envs"].append(dict(config["edgetest"]["envs"][env]))  # type: ignore
                     output["envs"][-1]["name"] = env
+                    if (
+                        "lower" in output["envs"][-1]
+                        and "project" in config
+                        and "dependencies" in config["project"]  # type: ignore
+                    ):
+                        output["envs"][-1]["lower"] = get_lower_bounds(
+                            dict(config["project"])["dependencies"],  # type: ignore
+                            output["envs"][-1]["lower"],
+                        )
             elif isinstance(config["edgetest"][section], Table):  # type: ignore
                 output[section] = dict(config["edgetest"][section])  # type: ignore
 
     if len(output["envs"]) == 0:
         if config.get("project").get("dependencies"):  # type: ignore
             output = convert_requirements(
-                requirements="\n".join(config["project"]["dependencies"]), conf=output  # type: ignore # noqa: E501
+                requirements="\n".join(config["project"]["dependencies"]),  # type: ignore
+                conf=output,  # type: ignore # noqa: E501
             )
         elif requirements:
             req_conf = gen_requirements_config(fname_or_buf=requirements)
@@ -500,3 +519,44 @@ def _isin_case_dashhyphen_ins(a: str, vals: List[str]) -> bool:
         if a.replace("_", "-").lower() == b.replace("_", "-").lower():
             return True
     return False
+
+
+def get_lower_bounds(requirements: str, lower: str) -> str:
+    r"""Get lower bounds of requested packages from installation requirements.
+
+    Parses through the project ``requirements`` and the newline-delimited
+    packages requested in ``lower`` to find the lower bounds.
+
+    Parameters
+    ----------
+    requirements : str
+        Project setup requirements,
+        e.g. ``"pandas>=1.5.1,<=1.4.2\nnumpy>=1.22.1,<=1.25.4"``
+    lower : str
+        Newline-delimited packages requested,
+         e.g. ``"pandas\nnumpy"``.
+
+    Returns
+    -------
+    str
+        The packages along with the lower bound, e.g. ``"pandas==1.5.1\nnumpy==1.22.1"``.
+    """
+    all_lower_bounds = {
+        pkg.project_name + (f"[{','.join(pkg.extras)}]" if pkg.extras else ""): dict(
+            pkg.specs
+        ).get(">=")
+        for pkg in parse_requirements(requirements)
+    }
+
+    lower_with_bounds = ""
+    for pkg_name, lower_bound in all_lower_bounds.items():
+        # TODO: Parse through extra requirements as well to get lower bounds
+        if lower_bound is None:
+            LOG.warning(
+                "Requested %s lower bound, but did not find in install requirements.",
+                pkg_name,
+            )
+        elif _isin_case_dashhyphen_ins(pkg_name, lower.split("\n")):
+            lower_with_bounds += f"{pkg_name}=={lower_bound}\n"
+
+    return lower_with_bounds
diff --git a/tests/conftest.py b/tests/conftest.py
index e7666c4..5c9480e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -34,6 +34,96 @@ def run_update(self, basedir: str, envname: str, upgrade: List):
         """Update packages from upgrade list."""
         pass
 
+    @hookimpl
+    def run_install_lower(basedir: str, envname: str, lower: Dict[str, str]):
+        """Install lower bounds of packages from lower list."""
+        pass
+
+
+class FakeHookEnvironmentError:
+    """Create a series of fake hooks that raise errors."""
+
+    @hookimpl
+    def path_to_python(self, basedir: str, envname: str) -> str:
+        """Return the path to the python executable."""
+        # return str(Path(basedir, envname, "bin", "python"))
+        if platform.system() == "Windows":
+            return str(Path(basedir) / envname / "Scripts" / "python")
+        else:
+            return str(Path(basedir) / envname / "bin" / "python")
+
+    @hookimpl
+    def create_environment(self, basedir: str, envname: str, conf: Dict):
+        """Create the virtual environment for testing."""
+        raise RuntimeError("Could not create virtual environment.")
+
+    @hookimpl
+    def run_update(self, basedir: str, envname: str, upgrade: List):
+        """Update packages from upgrade list."""
+        pass
+
+    @hookimpl
+    def run_install_lower(basedir: str, envname: str, lower: Dict[str, str]):
+        """Install lower bounds of packages from lower list."""
+        pass
+
+
+class FakeHookUpdateError:
+    """Create a series of fake hooks that raise errors."""
+
+    @hookimpl
+    def path_to_python(self, basedir: str, envname: str) -> str:
+        """Return the path to the python executable."""
+        # return str(Path(basedir, envname, "bin", "python"))
+        if platform.system() == "Windows":
+            return str(Path(basedir) / envname / "Scripts" / "python")
+        else:
+            return str(Path(basedir) / envname / "bin" / "python")
+
+    @hookimpl
+    def create_environment(self, basedir: str, envname: str, conf: Dict):
+        """Create the virtual environment for testing."""
+        pass
+
+    @hookimpl
+    def run_update(self, basedir: str, envname: str, upgrade: List):
+        """Update packages from upgrade list."""
+        raise RuntimeError("Could not update packages.")
+
+    @hookimpl
+    def run_install_lower(basedir: str, envname: str, lower: Dict[str, str]):
+        """Install lower bounds of packages from lower list."""
+        pass
+
+
+class FakeHookLowerError:
+    """Create a series of fake hooks that raise errors."""
+
+    @hookimpl
+    def path_to_python(self, basedir: str, envname: str) -> str:
+        """Return the path to the python executable."""
+        # return str(Path(basedir, envname, "bin", "python"))
+        if platform.system() == "Windows":
+            return str(Path(basedir) / envname / "Scripts" / "python")
+        else:
+            return str(Path(basedir) / envname / "bin" / "python")
+
+    @hookimpl
+    def create_environment(self, basedir: str, envname: str, conf: Dict):
+        """Create the virtual environment for testing."""
+        pass
+
+    @hookimpl
+    def run_update(self, basedir: str, envname: str, upgrade: List):
+        """Update packages from upgrade list."""
+        pass
+
+    @hookimpl
+    def run_install_lower(basedir: str, envname: str, lower: Dict[str, str]):
+        """Install lower bounds of packages from lower list."""
+        raise RuntimeError("Could not install lower bounds of packages.")
+
+
 
 @pytest.fixture
 def plugin_manager():
@@ -43,3 +133,30 @@ def plugin_manager():
     pm.register(FakeHook())
 
     return pm
+
+@pytest.fixture
+def plugin_manager_environment_error():
+    """The plugin manager for our fake hook that raises errors."""
+    pm = pluggy.PluginManager("edgetest")
+    pm.add_hookspecs(hookspecs)
+    pm.register(FakeHookEnvironmentError())
+
+    return pm
+
+@pytest.fixture
+def plugin_manager_upgrade_error():
+    """The plugin manager for our fake hook that raises errors."""
+    pm = pluggy.PluginManager("edgetest")
+    pm.add_hookspecs(hookspecs)
+    pm.register(FakeHookUpdateError())
+
+    return pm
+
+@pytest.fixture
+def plugin_manager_lower_error():
+    """The plugin manager for our fake hook that raises errors."""
+    pm = pluggy.PluginManager("edgetest")
+    pm.add_hookspecs(hookspecs)
+    pm.register(FakeHookLowerError())
+
+    return pm
\ No newline at end of file
diff --git a/tests/test_core.py b/tests/test_core.py
index 6c9805a..9819c67 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -9,6 +9,42 @@
 from edgetest.core import TestPackage
 
 
+@patch.object(Path, "cwd")
+def test_init(mock_path, tmpdir, plugin_manager):
+    """Test init."""
+    location = tmpdir.mkdir("mydir")
+    mock_path.return_value = Path(str(location))
+    tester_upgrade = TestPackage(
+        hook=plugin_manager.hook, envname="myenv", upgrade=["myupgrade"]
+    )
+    tester_lower = TestPackage(
+        hook=plugin_manager.hook, envname="myenv_lower", lower=["mylower"]
+    )
+
+    for tester in (tester_upgrade, tester_lower):
+        assert tester.hook == plugin_manager.hook
+        assert tester._basedir == Path(str(location)) / ".edgetest"
+        assert tester.package_dir == "."
+        assert not tester.setup_status
+        assert not tester.status
+
+    assert tester_upgrade.envname == "myenv"
+    assert tester_upgrade.upgrade == ["myupgrade"]
+    assert tester_upgrade.lower is None
+
+    assert tester_lower.envname == "myenv_lower"
+    assert tester_lower.upgrade is None
+    assert tester_lower.lower == ["mylower"]
+
+    with pytest.raises(ValueError):
+        _tester_error = TestPackage(
+            hook=plugin_manager.hook,
+            envname="myenv",
+            upgrade=["myupgrade"],
+            lower=["mylower"],
+        )
+
+
 @patch.object(Path, "cwd")
 @patch("edgetest.utils.Popen", autospec=True)
 def test_basic_setup(mock_popen, mock_path, tmpdir, plugin_manager):
@@ -39,25 +75,75 @@ def test_basic_setup(mock_popen, mock_path, tmpdir, plugin_manager):
             universal_newlines=True,
         ),
     ]
+    assert tester.setup_status
 
 
 @patch.object(Path, "cwd")
 @patch("edgetest.utils.Popen", autospec=True)
-def test_basic_setup_error(mock_popen, mock_path, tmpdir, plugin_manager):
+def test_basic_setup_create_environment_error(
+    mock_popen, mock_path, tmpdir, plugin_manager_environment_error
+):
     """Test failure in environment creation."""
     location = tmpdir.mkdir("mydir")
     mock_path.return_value = Path(str(location))
     mock_popen.return_value.communicate.return_value = ("output", "error")
-    type(mock_popen.return_value).returncode = PropertyMock(return_value=1)
+    type(mock_popen.return_value).returncode = PropertyMock(return_value=0)
 
     tester = TestPackage(
-        hook=plugin_manager.hook, envname="myenv", upgrade=["myupgrade"]
+        hook=plugin_manager_environment_error.hook,
+        envname="myenv",
+        upgrade=["myupgrade"],
     )
 
     assert tester._basedir == Path(str(location)) / ".edgetest"
 
-    with pytest.raises(RuntimeError):
-        tester.setup()
+    tester.setup()
+    assert not tester.setup_status
+
+
+@patch.object(Path, "cwd")
+@patch("edgetest.utils.Popen", autospec=True)
+def test_basic_setup_upgrade_error(
+    mock_popen, mock_path, tmpdir, plugin_manager_upgrade_error
+):
+    """Test failure in environment creation."""
+    location = tmpdir.mkdir("mydir")
+    mock_path.return_value = Path(str(location))
+    mock_popen.return_value.communicate.return_value = ("output", "error")
+    type(mock_popen.return_value).returncode = PropertyMock(return_value=0)
+
+    tester = TestPackage(
+        hook=plugin_manager_upgrade_error.hook,
+        envname="myenv",
+        upgrade=["myupgrade"],
+    )
+
+    assert tester._basedir == Path(str(location)) / ".edgetest"
+
+    tester.setup()
+    assert not tester.setup_status
+
+@patch.object(Path, "cwd")
+@patch("edgetest.utils.Popen", autospec=True)
+def test_basic_setup_lower_error(
+    mock_popen, mock_path, tmpdir, plugin_manager_lower_error
+):
+    """Test failure in environment creation."""
+    location = tmpdir.mkdir("mydir")
+    mock_path.return_value = Path(str(location))
+    mock_popen.return_value.communicate.return_value = ("output", "error")
+    type(mock_popen.return_value).returncode = PropertyMock(return_value=0)
+
+    tester = TestPackage(
+        hook=plugin_manager_lower_error.hook,
+        envname="myenv",
+        lower=["mylower"],
+    )
+
+    assert tester._basedir == Path(str(location)) / ".edgetest"
+
+    tester.setup()
+    assert not tester.setup_status
 
 
 @patch.object(Path, "cwd")
@@ -91,6 +177,8 @@ def test_setup_extras(mock_popen, mock_path, tmpdir, plugin_manager):
         ),
     ]
 
+    assert tester.setup_status
+
 
 @patch.object(Path, "cwd")
 @patch("edgetest.utils.Popen", autospec=True)
@@ -137,6 +225,51 @@ def test_setup_pip_deps(mock_popen, mock_path, tmpdir, plugin_manager):
         ),
     ]
 
+    assert tester.setup_status
+
+
+@patch.object(Path, "cwd")
+@patch("edgetest.utils.Popen", autospec=True)
+def test_setup_pip_deps_error(mock_popen, mock_path, tmpdir, plugin_manager):
+    """Test creating an environment with pip dependencies."""
+    location = tmpdir.mkdir("mydir")
+    mock_path.return_value = Path(str(location))
+    mock_popen.return_value.communicate.return_value = ("output", "error")
+    type(mock_popen.return_value).returncode = PropertyMock(return_value=1)
+
+    tester = TestPackage(
+        hook=plugin_manager.hook, envname="myenv", upgrade=["myupgrade"]
+    )
+
+    assert tester._basedir == Path(str(location)) / ".edgetest"
+
+    tester.setup(deps=["-r requirements.txt", "otherpkg"])
+
+    env_loc = str(Path(str(location)) / ".edgetest" / "myenv")
+
+    if platform.system() == "Windows":
+        py_loc = Path(env_loc) / "Scripts" / "python"
+    else:
+        py_loc = Path(env_loc) / "bin" / "python"
+
+    assert mock_popen.call_args_list == [
+        call(
+            (
+                f"{py_loc}",
+                "-m",
+                "pip",
+                "install",
+                "-r",
+                "requirements.txt",
+                "otherpkg",
+            ),
+            stdout=-1,
+            universal_newlines=True,
+        ),
+    ]
+
+    assert not tester.setup_status
+
 
 @patch.object(Path, "cwd")
 @patch("edgetest.core.Popen", autospec=True)
@@ -157,8 +290,13 @@ def test_run_tests(mock_popen, mock_path, tmpdir, plugin_manager):
     else:
         py_loc = Path(env_loc) / "bin" / "python"
 
-    out = tester.run_tests(command="pytest tests -m 'not integration'")
+    # If environment not setup successfully, then there should be an error
+    with pytest.raises(RuntimeError):
+        tester.run_tests(command="pytest tests -m 'not integration'")
 
+    # Manually specify that environment was setup successfully
+    tester.setup_status = True
+    out = tester.run_tests(command="pytest tests -m 'not integration'")
     assert out == 0
     assert mock_popen.call_args_list == [
         call(
diff --git a/tests/test_integration_cfg.py b/tests/test_integration_cfg.py
index 149ed0c..d9f1682 100644
--- a/tests/test_integration_cfg.py
+++ b/tests/test_integration_cfg.py
@@ -32,6 +32,34 @@
     tests
 """
 
+SETUP_CFG_LOWER = """
+[metadata]
+name = toy_package
+version = 0.1.0
+description = Fake description
+python_requires =
+    >=3.7.0
+
+[options]
+zip_safe = False
+include_package_data = True
+packages = find:
+install_requires =
+    pandas<=1.2.0,>=1.0.0
+
+[options.extras_require]
+tests =
+    pytest
+
+[edgetest]
+extras =
+    tests
+
+[edgetest.envs.lower_env]
+lower =
+    pandas
+"""
+
 
 SETUP_CFG_DASK = """
 [metadata]
@@ -47,7 +75,7 @@
 packages = find:
 install_requires =
     scikit-learn>=1.0,<=1.2.0
-    dask[dataframe]<=2022.1.0
+    dask[dataframe]<=2022.1.0,>=2021.6.1
 
 [options.extras_require]
 tests =
@@ -59,6 +87,13 @@
 upgrade =
     Scikit_Learn
     Dask[DataFrame]
+
+[edgetest.envs.lower_env]
+extras =
+    tests
+lower =
+    scikit_Learn
+    Dask[DataFrame]
 """
 
 
@@ -117,6 +152,40 @@ def test_toy_package():
             ).is_dir()
 
 
+@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows")
+@pytest.mark.integration
+def test_toy_package_lower():
+    """Test lower bounds with a toy package."""
+    runner = CliRunner()
+
+    with runner.isolated_filesystem() as loc:
+        with open("setup.cfg", "w") as outfile:
+            outfile.write(SETUP_CFG_LOWER)
+        with open("setup.py", "w") as outfile:
+            outfile.write(SETUP_PY)
+        # Make a directory for the module
+        Path(loc, "toy_package").mkdir()
+        with open(Path(loc, "toy_package", "__init__.py"), "w") as outfile:
+            outfile.write(MODULE_CODE)
+        # Make a directory for the tests
+        Path(loc, "tests").mkdir()
+        with open(Path(loc, "tests", "test_main.py"), "w") as outfile:
+            outfile.write(TEST_CODE)
+
+        # Run the CLI
+        result = runner.invoke(cli, ["--config=setup.cfg"])
+
+        assert result.exit_code == 0
+        assert Path(loc, ".edgetest").is_dir()
+        assert Path(loc, ".edgetest", "lower_env").is_dir()
+        assert "pandas" in result.stdout
+
+        if not sys.platform == "win32":
+            assert Path(
+                loc, ".edgetest", "lower_env", "lib", PY_VER, "site-packages", "pandas"
+            ).is_dir()
+
+
 @pytest.mark.integration
 def test_toy_package_dask():
     """Test using edgetest with a toy package."""
@@ -141,18 +210,20 @@ def test_toy_package_dask():
 
         assert result.exit_code == 0
         assert Path(loc, ".edgetest").is_dir()
-        assert Path(loc, ".edgetest", "core").is_dir()
 
-        if not sys.platform == "win32":
-            assert Path(
-                loc, ".edgetest", "core", "lib", PY_VER, "site-packages", "dask"
-            ).is_dir()
-            assert Path(
-                loc, ".edgetest", "core", "lib", PY_VER, "site-packages", "pandas"
-            ).is_dir()
-            assert Path(
-                loc, ".edgetest", "core", "lib", PY_VER, "site-packages", "sklearn"
-            ).is_dir()
+        for envname in ("core", "lower_env"):
+            assert Path(loc, ".edgetest", envname).is_dir()
+
+            if not sys.platform == "win32":
+                assert Path(
+                    loc, ".edgetest", envname, "lib", PY_VER, "site-packages", "dask"
+                ).is_dir()
+                assert Path(
+                    loc, ".edgetest", envname, "lib", PY_VER, "site-packages", "pandas"
+                ).is_dir()
+                assert Path(
+                    loc, ".edgetest", envname, "lib", PY_VER, "site-packages", "sklearn"
+                ).is_dir()
 
         config = configparser.ConfigParser()
         config.read("setup.cfg")
@@ -160,6 +231,13 @@ def test_toy_package_dask():
         assert "scikit-learn" in config["options"]["install_requires"]
         assert "dask[dataframe]" in config["options"]["install_requires"]
         assert config["edgetest.envs.core"]["extras"] == "\ntests"
-        assert config["edgetest.envs.core"]["upgrade"] == "\nScikit_Learn\nDask[DataFrame]"
+        assert (
+            config["edgetest.envs.core"]["upgrade"] == "\nScikit_Learn\nDask[DataFrame]"
+        )
+        assert config["edgetest.envs.lower_env"]["extras"] == "\ntests"
+        assert (
+            config["edgetest.envs.lower_env"]["lower"]
+            == "\nscikit_Learn\nDask[DataFrame]"
+        )
         assert "dask" in result.stdout
         assert "scikit-learn" in result.stdout
diff --git a/tests/test_integration_toml.py b/tests/test_integration_toml.py
index e31ad9d..bbc0796 100644
--- a/tests/test_integration_toml.py
+++ b/tests/test_integration_toml.py
@@ -24,6 +24,23 @@
 extras = ["tests"]
 """
 
+SETUP_TOML_LOWER = """
+[project]
+name = "toy_package"
+version = "0.1.0"
+description = "Fake description"
+requires-python = ">=3.7.0"
+dependencies = ["pandas<=1.2.0,>=1.0.0"]
+
+[project.optional-dependencies]
+tests = ["pytest"]
+
+[edgetest]
+extras = ["tests"]
+
+[edgetest.envs.lower_env]
+lower = ["pandas"]
+"""
 
 SETUP_TOML_DASK = """
 [project]
@@ -31,7 +48,7 @@
 version = "0.1.0"
 description = "Fake description"
 requires-python = ">=3.7.0"
-dependencies = ["Scikit_Learn>=1.0,<=1.2.0", "Dask[dataframe]<=2022.1.0"]
+dependencies = ["Scikit_Learn>=1.0,<=1.2.0", "Dask[dataframe]<=2022.1.0,>=2021.6.1"]
 
 [project.optional-dependencies]
 tests = ["pytest"]
@@ -39,6 +56,10 @@
 [edgetest.envs.core]
 extras = ["tests"]
 upgrade = ["scikit-learn", "dask[dataframe]"]
+
+[edgetest.envs.lower_env]
+extras = ["tests"]
+lower = ["scikit-learn", "dask[dataframe]"]
 """
 
 
@@ -97,14 +118,15 @@ def test_toy_package():
             ).is_dir()
 
 
+@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows")
 @pytest.mark.integration
-def test_toy_package_dask():
+def test_toy_package_lower():
     """Test using edgetest with a toy package."""
     runner = CliRunner()
 
     with runner.isolated_filesystem() as loc:
         with open("pyproject.toml", "w") as outfile:
-            outfile.write(SETUP_TOML_DASK)
+            outfile.write(SETUP_TOML_LOWER)
         with open("setup.py", "w") as outfile:
             outfile.write(SETUP_PY)
         # Make a directory for the module
@@ -117,23 +139,58 @@ def test_toy_package_dask():
             outfile.write(TEST_CODE)
 
         # Run the CLI
-        result = runner.invoke(cli, ["--config=pyproject.toml", "--export"])
+        result = runner.invoke(cli, ["--config=pyproject.toml"])
 
         assert result.exit_code == 0
         assert Path(loc, ".edgetest").is_dir()
-        assert Path(loc, ".edgetest", "core").is_dir()
+        assert Path(loc, ".edgetest", "lower_env").is_dir()
+        assert "pandas" in result.stdout
 
         if not sys.platform == "win32":
             assert Path(
-                loc, ".edgetest", "core", "lib", PY_VER, "site-packages", "dask"
-            ).is_dir()
-            assert Path(
-                loc, ".edgetest", "core", "lib", PY_VER, "site-packages", "pandas"
-            ).is_dir()
-            assert Path(
-                loc, ".edgetest", "core", "lib", PY_VER, "site-packages", "sklearn"
+                loc, ".edgetest", "lower_env", "lib", PY_VER, "site-packages", "pandas"
             ).is_dir()
 
+
+@pytest.mark.integration
+def test_toy_package_dask():
+    """Test using edgetest with a toy package."""
+    runner = CliRunner()
+
+    with runner.isolated_filesystem() as loc:
+        with open("pyproject.toml", "w") as outfile:
+            outfile.write(SETUP_TOML_DASK)
+        with open("setup.py", "w") as outfile:
+            outfile.write(SETUP_PY)
+        # Make a directory for the module
+        Path(loc, "toy_package").mkdir()
+        with open(Path(loc, "toy_package", "__init__.py"), "w") as outfile:
+            outfile.write(MODULE_CODE)
+        # Make a directory for the tests
+        Path(loc, "tests").mkdir()
+        with open(Path(loc, "tests", "test_main.py"), "w") as outfile:
+            outfile.write(TEST_CODE)
+
+        # Run the CLI
+        result = runner.invoke(cli, ["--config=pyproject.toml", "--export"])
+
+        assert result.exit_code == 0
+        assert Path(loc, ".edgetest").is_dir()
+
+        for envname in ("core", "lower_env"):
+            assert Path(loc, ".edgetest", envname).is_dir()
+
+            if not sys.platform == "win32":
+                assert Path(
+                    loc, ".edgetest", envname, "lib", PY_VER, "site-packages", "dask"
+                ).is_dir()
+                assert Path(
+                    loc, ".edgetest", envname, "lib", PY_VER, "site-packages", "pandas"
+                ).is_dir()
+                assert Path(
+                    loc, ".edgetest", envname, "lib", PY_VER, "site-packages", "sklearn"
+                ).is_dir()
+
         config = load(open("pyproject.toml"))
 
         assert "Scikit_Learn" in config["project"]["dependencies"][0]
@@ -143,5 +200,10 @@ def test_toy_package_dask():
             "scikit-learn",
             "dask[dataframe]",
         ]
+        assert config["edgetest"]["envs"]["lower_env"]["extras"] == ["tests"]
+        assert config["edgetest"]["envs"]["lower_env"]["lower"] == [
+            "scikit-learn",
+            "dask[dataframe]",
+        ]
         assert "dask" in result.stdout
         assert "scikit-learn" in result.stdout
diff --git a/tests/test_interface_cfg.py b/tests/test_interface_cfg.py
index 57c47c0..1998ee1 100644
--- a/tests/test_interface_cfg.py
+++ b/tests/test_interface_cfg.py
@@ -22,6 +22,19 @@
     pytest tests -m 'not integration'
 """
 
+SETUP_CFG_LOWER = """
+[options]
+install_requires =
+    myupgrade
+    mylower<=0.1.0,>=0.0.1
+
+[edgetest.envs.myenv_lower]
+lower =
+    mylower
+command =
+    pytest tests -m 'not integration'
+"""
+
 SETUP_CFG_REQS = """
 [options]
 install_requires =
@@ -60,30 +73,49 @@
 
 TABLE_OUTPUT = """
 
-=============  ===============  ===================  =================
-Environment    Passing tests    Upgraded packages    Package version
-=============  ===============  ===================  =================
-myenv          True             myupgrade            0.2.0
-=============  ===============  ===================  =================
+=============  ==================  ===============  ===================  ==================  =================
+Environment    Setup successful    Passing tests    Upgraded packages    Lowered packages    Package version
+=============  ==================  ===============  ===================  ==================  =================
+myenv          True                True             myupgrade                                0.2.0
+=============  ==================  ===============  ===================  ==================  =================
+"""
+
+TABLE_OUTPUT_LOWER = """
+
+=============  ==================  ===============  ===================  ==================  =================
+Environment    Setup successful    Passing tests    Upgraded packages    Lowered packages    Package version
+=============  ==================  ===============  ===================  ==================  =================
+myenv_lower    True                True                                  mylower             0.0.1
+=============  ==================  ===============  ===================  ==================  =================
 """
 
 TABLE_OUTPUT_NOTEST = """
 
-=============  ===============  ===================  =================
-Environment    Passing tests    Upgraded packages    Package version
-=============  ===============  ===================  =================
-myenv          False            myupgrade            0.2.0
-=============  ===============  ===================  =================
+=============  ==================  ===============  ===================  ==================  =================
+Environment    Setup successful    Passing tests    Upgraded packages    Lowered packages    Package version
+=============  ==================  ===============  ===================  ==================  =================
+myenv          True                False            myupgrade                                0.2.0
+=============  ==================  ===============  ===================  ==================  =================
 """
 
+TABLE_OUTPUT_NOTEST_LOWER = """
+
+=============  ==================  ===============  ===================  ==================  =================
+Environment    Setup successful    Passing tests    Upgraded packages    Lowered packages    Package version
+=============  ==================  ===============  ===================  ==================  =================
+myenv_lower    True                False                                 mylower             0.0.1
+=============  ==================  ===============  ===================  ==================  =================
+"""
+
+
 TABLE_OUTPUT_REQS = """
 
-================  ===============  ===================  =================
-Environment       Passing tests    Upgraded packages    Package version
-================  ===============  ===================  =================
-myupgrade         True             myupgrade            0.2.0
-all-requirements  True             myupgrade            0.2.0
-================  ===============  ===================  =================
+================  ==================  ===============  ===================  ==================  =================
+Environment       Setup successful    Passing tests    Upgraded packages    Lowered packages    Package version
+================  ==================  ===============  ===================  ==================  =================
+myupgrade         True                True             myupgrade                                0.2.0
+all-requirements  True                True             myupgrade                                0.2.0
+================  ==================  ===============  ===================  ==================  =================
 """
 
 
@@ -155,6 +187,68 @@ def test_cli_basic(mock_popen, mock_cpopen, mock_builder):
     assert result.output == TABLE_OUTPUT
 
 
+@patch("edgetest.lib.EnvBuilder", autospec=True)
+@patch("edgetest.core.Popen", autospec=True)
+@patch("edgetest.utils.Popen", autospec=True)
+def test_cli_basic_lower(mock_popen, mock_cpopen, mock_builder):
+    """Test creating a basic environment."""
+    mock_popen.return_value.communicate.return_value = (PIP_LIST, "error")
+    type(mock_popen.return_value).returncode = PropertyMock(return_value=0)
+    mock_cpopen.return_value.communicate.return_value = ("output", "error")
+    type(mock_cpopen.return_value).returncode = PropertyMock(return_value=0)
+
+    runner = CliRunner()
+
+    with runner.isolated_filesystem() as loc:
+        with open("setup.cfg", "w") as outfile:
+            outfile.write(SETUP_CFG_LOWER)
+
+        result = runner.invoke(cli, ["--config=setup.cfg"])
+
+    assert result.exit_code == 0
+
+    env_loc = Path(loc) / ".edgetest" / "myenv_lower"
+    if platform.system() == "Windows":
+        py_loc = env_loc / "Scripts" / "python"
+    else:
+        py_loc = env_loc / "bin" / "python"
+
+    mock_builder.return_value.create.assert_called_with(env_loc)
+    assert mock_popen.call_args_list == [
+        call(
+            (f"{str(py_loc)}", "-m", "pip", "install", "."),
+            stdout=-1,
+            universal_newlines=True,
+        ),
+        call(
+            (
+                f"{str(py_loc)}",
+                "-m",
+                "pip",
+                "install",
+                "mylower==0.0.1",
+            ),
+            stdout=-1,
+            universal_newlines=True,
+        ),
+    ]
+    assert mock_cpopen.call_args_list == [
+        call(
+            (
+                f"{str(py_loc)}",
+                "-m",
+                "pytest",
+                "tests",
+                "-m",
+                "not integration",
+            ),
+            universal_newlines=True,
+        )
+    ]
+
+    assert result.output == TABLE_OUTPUT_LOWER
+
+
 @patch("edgetest.lib.EnvBuilder", autospec=True)
 @patch("edgetest.core.Popen", autospec=True)
 @patch("edgetest.utils.Popen", autospec=True)
@@ -367,6 +461,44 @@ def test_cli_nosetup(mock_popen, mock_cpopen):
     )
 
 
+@patch("edgetest.core.Popen", autospec=True)
+@patch("edgetest.utils.Popen", autospec=True)
+def test_cli_nosetup_lower(mock_popen, mock_cpopen):
+    """Test creating a basic environment."""
+    mock_popen.return_value.communicate.return_value = (PIP_LIST, "error")
+    type(mock_popen.return_value).returncode = PropertyMock(return_value=0)
+    mock_cpopen.return_value.communicate.return_value = ("output", "error")
+    type(mock_cpopen.return_value).returncode = PropertyMock(return_value=0)
+
+    runner = CliRunner()
+
+    with runner.isolated_filesystem() as loc:
+        with open("setup.cfg", "w") as outfile:
+            outfile.write(SETUP_CFG_LOWER)
+
+        result = runner.invoke(cli, ["--config=setup.cfg", "--nosetup"])
+
+    assert result.exit_code == 0
+
+    env_loc = str(Path(loc) / ".edgetest" / "myenv_lower")
+    if platform.system() == "Windows":
+        py_loc = Path(env_loc) / "Scripts" / "python"
+    else:
+        py_loc = Path(env_loc) / "bin" / "python"
+
+    assert mock_cpopen.call_args_list == [
+        call(
+            (f"{py_loc}", "-m", "pytest", "tests", "-m", "not integration"),
+            universal_newlines=True,
+        )
+    ]
+
+    assert (
+        result.output
+        == f"""Using existing environment for myenv_lower...\n{TABLE_OUTPUT_LOWER}"""
+    )
+
+
 @patch("edgetest.lib.EnvBuilder", autospec=True)
 @patch("edgetest.utils.Popen", autospec=True)
 def test_cli_notest(mock_popen, mock_builder):
@@ -417,3 +549,52 @@ def test_cli_notest(mock_popen, mock_builder):
     ]
 
     assert result.output == f"""Skipping tests for myenv\n{TABLE_OUTPUT_NOTEST}"""
+
+
+@patch("edgetest.lib.EnvBuilder", autospec=True)
+@patch("edgetest.utils.Popen", autospec=True)
+def test_cli_notest_lower(mock_popen, mock_builder):
+    """Test creating a basic environment."""
+    mock_popen.return_value.communicate.return_value = (PIP_LIST, "error")
+    type(mock_popen.return_value).returncode = PropertyMock(return_value=0)
+
+    runner = CliRunner()
+
+    with runner.isolated_filesystem() as loc:
+        with open("setup.cfg", "w") as outfile:
+            outfile.write(SETUP_CFG_LOWER)
+
+        result = runner.invoke(cli, ["--config=setup.cfg", "--notest"])
+
+    assert result.exit_code == 0
+
+    env_loc = Path(loc) / ".edgetest" / "myenv_lower"
+    if platform.system() == "Windows":
+        py_loc = env_loc / "Scripts" / "python"
+    else:
+        py_loc = env_loc / "bin" / "python"
+
+    mock_builder.return_value.create.assert_called_with(env_loc)
+    assert mock_popen.call_args_list == [
+        call(
+            (f"{str(py_loc)}", "-m", "pip", "install", "."),
+            stdout=-1,
+            universal_newlines=True,
+        ),
+        call(
+            (
+                f"{str(py_loc)}",
+                "-m",
+                "pip",
+                "install",
+                "mylower==0.0.1",
+            ),
+            stdout=-1,
+            universal_newlines=True,
+        ),
+    ]
+
+    assert (
+        result.output
+        == f"""Skipping tests for myenv_lower\n{TABLE_OUTPUT_NOTEST_LOWER}"""
+    )
diff --git a/tests/test_interface_toml.py b/tests/test_interface_toml.py
index d201914..1216d7c 100644
--- a/tests/test_interface_toml.py
+++ b/tests/test_interface_toml.py
@@ -20,6 +20,15 @@
 command = "pytest tests -m 'not integration'"
 """
 
+SETUP_TOML_LOWER = """
+[project]
+dependencies = ["myupgrade<=0.1.5", "mylower<=0.1.0,>=0.0.1"]
+
+[edgetest.envs.myenv_lower]
+lower = ["mylower"]
+command = "pytest tests -m 'not integration'"
+"""
+
 SETUP_TOML_REQS = """
 [project]
 dependencies = ["myupgrade<=0.1.5"]
@@ -57,30 +66,48 @@
 
 TABLE_OUTPUT = """
 
-=============  ===============  ===================  =================
-Environment    Passing tests    Upgraded packages    Package version
-=============  ===============  ===================  =================
-myenv          True             myupgrade            0.2.0
-=============  ===============  ===================  =================
+=============  ==================  ===============  ===================  ==================  =================
+Environment    Setup successful    Passing tests    Upgraded packages    Lowered packages    Package version
+=============  ==================  ===============  ===================  ==================  =================
+myenv          True                True             myupgrade                                0.2.0
+=============  ==================  ===============  ===================  ==================  =================
+"""
+
+TABLE_OUTPUT_LOWER = """
+
+=============  ==================  ===============  ===================  ==================  =================
+Environment    Setup successful    Passing tests    Upgraded packages    Lowered packages    Package version
+=============  ==================  ===============  ===================  ==================  =================
+myenv_lower    True                True                                  mylower             0.0.1
+=============  ==================  ===============  ===================  ==================  =================
 """
 
 TABLE_OUTPUT_NOTEST = """
 
-=============  ===============  ===================  =================
-Environment    Passing tests    Upgraded packages    Package version
-=============  ===============  ===================  =================
-myenv          False            myupgrade            0.2.0
-=============  ===============  ===================  =================
+=============  ==================  ===============  ===================  ==================  =================
+Environment    Setup successful    Passing tests    Upgraded packages    Lowered packages    Package version
+=============  ==================  ===============  ===================  ==================  =================
+myenv          True                False            myupgrade                                0.2.0
+=============  ==================  ===============  ===================  ==================  =================
+"""
+
+TABLE_OUTPUT_NOTEST_LOWER = """
+
+=============  ==================  ===============  ===================  ==================  =================
+Environment    Setup successful    Passing tests    Upgraded packages    Lowered packages    Package version
+=============  ==================  ===============  ===================  ==================  =================
+myenv_lower    True                False                                 mylower             0.0.1
+=============  ==================  ===============  ===================  ==================  =================
 """
 
 TABLE_OUTPUT_REQS = """
 
-================  ===============  ===================  =================
-Environment       Passing tests    Upgraded packages    Package version
-================  ===============  ===================  =================
-myupgrade         True             myupgrade            0.2.0
-all-requirements  True             myupgrade            0.2.0
-================  ===============  ===================  =================
+================  ==================  ===============  ===================  ==================  =================
+Environment       Setup successful    Passing tests    Upgraded packages    Lowered packages    Package version
+================  ==================  ===============  ===================  ==================  =================
+myupgrade         True                True             myupgrade                                0.2.0
+all-requirements  True                True             myupgrade                                0.2.0
+================  ==================  ===============  ===================  ==================  =================
 """
 
 
@@ -152,6 +179,68 @@ def test_cli_basic(mock_popen, mock_cpopen, mock_builder):
     assert result.output == TABLE_OUTPUT
 
 
+@patch("edgetest.lib.EnvBuilder", autospec=True)
+@patch("edgetest.core.Popen", autospec=True)
+@patch("edgetest.utils.Popen", autospec=True)
+def test_cli_basic_lower(mock_popen, mock_cpopen, mock_builder):
+    """Test creating a basic environment."""
+    mock_popen.return_value.communicate.return_value = (PIP_LIST, "error")
+    type(mock_popen.return_value).returncode = PropertyMock(return_value=0)
+    mock_cpopen.return_value.communicate.return_value = ("output", "error")
+    type(mock_cpopen.return_value).returncode = PropertyMock(return_value=0)
+
+    runner = CliRunner()
+
+    with runner.isolated_filesystem() as loc:
+        with open("pyproject.toml", "w") as outfile:
+            outfile.write(SETUP_TOML_LOWER)
+
+        result = runner.invoke(cli, ["--config=pyproject.toml"])
+
+    assert result.exit_code == 0
+
+    env_loc = Path(loc) / ".edgetest" / "myenv_lower"
+    if platform.system() == "Windows":
+        py_loc = env_loc / "Scripts" / "python"
+    else:
+        py_loc = env_loc / "bin" / "python"
+
+    mock_builder.return_value.create.assert_called_with(env_loc)
+    assert mock_popen.call_args_list == [
+        call(
+            (f"{str(py_loc)}", "-m", "pip", "install", "."),
+            stdout=-1,
+            universal_newlines=True,
+        ),
+        call(
+            (
+                f"{str(py_loc)}",
+                "-m",
+                "pip",
+                "install",
+                "mylower==0.0.1",
+            ),
+            stdout=-1,
+            universal_newlines=True,
+        ),
+    ]
+    assert mock_cpopen.call_args_list == [
+        call(
+            (
+                f"{str(py_loc)}",
+                "-m",
+                "pytest",
+                "tests",
+                "-m",
+                "not integration",
+            ),
+            universal_newlines=True,
+        )
+    ]
+
+    assert result.output == TABLE_OUTPUT_LOWER
+
+
 @patch("edgetest.lib.EnvBuilder", autospec=True)
 @patch("edgetest.core.Popen", autospec=True)
 @patch("edgetest.utils.Popen", autospec=True)
@@ -364,6 +453,44 @@ def test_cli_nosetup(mock_popen, mock_cpopen):
     )
 
 
+@patch("edgetest.core.Popen", autospec=True)
+@patch("edgetest.utils.Popen", autospec=True)
+def test_cli_nosetup_lower(mock_popen, mock_cpopen):
+    """Test creating a basic environment."""
+    mock_popen.return_value.communicate.return_value = (PIP_LIST, "error")
+    type(mock_popen.return_value).returncode = PropertyMock(return_value=0)
+    mock_cpopen.return_value.communicate.return_value = ("output", "error")
+    type(mock_cpopen.return_value).returncode = PropertyMock(return_value=0)
+
+    runner = CliRunner()
+
+    with runner.isolated_filesystem() as loc:
+        with open("pyproject.toml", "w") as outfile:
+            outfile.write(SETUP_TOML_LOWER)
+
+        result = runner.invoke(cli, ["--config=pyproject.toml", "--nosetup"])
+
+    assert result.exit_code == 0
+
+    env_loc = str(Path(loc) / ".edgetest" / "myenv_lower")
+    if platform.system() == "Windows":
+        py_loc = Path(env_loc) / "Scripts" / "python"
+    else:
+        py_loc = Path(env_loc) / "bin" / "python"
+
+    assert mock_cpopen.call_args_list == [
+        call(
+            (f"{py_loc}", "-m", "pytest", "tests", "-m", "not integration"),
+            universal_newlines=True,
+        )
+    ]
+
+    assert (
+        result.output
+        == f"""Using existing environment for myenv_lower...\n{TABLE_OUTPUT_LOWER}"""
+    )
+
+
 @patch("edgetest.lib.EnvBuilder", autospec=True)
 @patch("edgetest.utils.Popen", autospec=True)
 def test_cli_notest(mock_popen, mock_builder):
@@ -414,3 +541,52 @@ def test_cli_notest(mock_popen, mock_builder):
     ]
 
     assert result.output == f"""Skipping tests for myenv\n{TABLE_OUTPUT_NOTEST}"""
+
+
+@patch("edgetest.lib.EnvBuilder", autospec=True)
+@patch("edgetest.utils.Popen", autospec=True)
+def test_cli_notest_lower(mock_popen, mock_builder):
+    """Test creating a basic environment."""
+    mock_popen.return_value.communicate.return_value = (PIP_LIST, "error")
+    type(mock_popen.return_value).returncode = PropertyMock(return_value=0)
+
+    runner = CliRunner()
+
+    with runner.isolated_filesystem() as loc:
+        with open("pyproject.toml", "w") as outfile:
+            outfile.write(SETUP_TOML_LOWER)
+
+        result = runner.invoke(cli, ["--config=pyproject.toml", "--notest"])
+
+    assert result.exit_code == 0
+
+    env_loc = Path(loc) / ".edgetest" / "myenv_lower"
+    if platform.system() == "Windows":
+        py_loc = env_loc / "Scripts" / "python"
+    else:
+        py_loc = env_loc / "bin" / "python"
+
+    mock_builder.return_value.create.assert_called_with(env_loc)
+    assert mock_popen.call_args_list == [
+        call(
+            (f"{str(py_loc)}", "-m", "pip", "install", "."),
+            stdout=-1,
+            universal_newlines=True,
+        ),
+        call(
+            (
+                f"{str(py_loc)}",
+                "-m",
+                "pip",
+                "install",
+                "mylower==0.0.1",
+            ),
+            stdout=-1,
+            universal_newlines=True,
+        ),
+    ]
+
+    assert (
+        result.output
+        == f"""Skipping tests for myenv_lower\n{TABLE_OUTPUT_NOTEST_LOWER}"""
+    )
diff --git a/tests/test_lib.py b/tests/test_lib.py
index 9c0f148..5e1da0a 100644
--- a/tests/test_lib.py
+++ b/tests/test_lib.py
@@ -3,7 +3,12 @@
 
 import pytest
 
-from edgetest.lib import create_environment, path_to_python, run_update
+from edgetest.lib import (
+    create_environment,
+    path_to_python,
+    run_install_lower,
+    run_update,
+)
 
 
 @patch("edgetest.lib.platform", autospec=True)
@@ -45,3 +50,23 @@ def test_run_update(mock_run):
     mock_run.side_effect = RuntimeError()
     with pytest.raises(RuntimeError):
         run_update("test", "test", ["1", "2"], {"test": "test"})
+
+
+@patch("edgetest.lib._run_command", autospec=True)
+def test_run_install_lower(mock_run):
+    python_path = path_to_python("test", "test")
+    run_install_lower("test", "test", ["package1==1", "package2==2"], {"test": "test"})
+    mock_run.assert_called_with(
+        python_path,
+        "-m",
+        "pip",
+        "install",
+        "package1==1",
+        "package2==2",
+    )
+
+    mock_run.side_effect = RuntimeError()
+    with pytest.raises(RuntimeError):
+        run_install_lower(
+            "test", "test", ["package1==1", "package2==2"], {"test": "test"}
+        )
diff --git a/tests/test_report.py b/tests/test_report.py
index 188be89..e8325d5 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -8,36 +8,31 @@
 
 @patch("edgetest.report.tabulate", autospec=True)
 @patch("edgetest.core.TestPackage.upgraded_packages", autospec=True)
-def test_report(mock_test_package, mock_tabulate, plugin_manager):
+@patch("edgetest.core.TestPackage.lowered_packages", autospec=True)
+def test_report(
+    mock_test_package_lowered, mock_test_package, mock_tabulate, plugin_manager
+):
     """Test gen_report function"""
 
     tester_list = [
         TestPackage(hook=plugin_manager.hook, envname="myenv", upgrade=["myupgrade1"]),
         TestPackage(hook=plugin_manager.hook, envname="myenv", upgrade=["myupgrade2"]),
+        TestPackage(hook=plugin_manager.hook, envname="myenv_lower", lower=["mylower"]),
+    ]
+
+    expected_headers = [
+        "Environment",
+        "Setup successful",
+        "Passing tests",
+        "Upgraded packages",
+        "Lowered packages",
+        "Package version",
     ]
     gen_report(tester_list)
-    mock_tabulate.assert_called_with(
-        [],
-        headers=[
-            "Environment",
-            "Passing tests",
-            "Upgraded packages",
-            "Package version",
-        ],
-        tablefmt="rst",
-    )
+    mock_tabulate.assert_called_with([], headers=expected_headers, tablefmt="rst")
 
     gen_report(tester_list, output_type="github")
-    mock_tabulate.assert_called_with(
-        [],
-        headers=[
-            "Environment",
-            "Passing tests",
-            "Upgraded packages",
-            "Package version",
-        ],
-        tablefmt="github",
-    )
-
-    with pytest.raises(ValueError) as e:
+    mock_tabulate.assert_called_with([], expected_headers, tablefmt="github")
+
+    with pytest.raises(ValueError):
         gen_report(tester_list, output_type="bad")
diff --git a/tests/test_utils.py b/tests/test_utils.py
index a9f0c63..4a822ec 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -8,12 +8,13 @@
 from edgetest.schema import BASE_SCHEMA, EdgetestValidator, Schema
 from edgetest.utils import (
     _convert_toml_array_to_string,
+    _isin_case_dashhyphen_ins,
     gen_requirements_config,
+    get_lower_bounds,
     parse_cfg,
     parse_toml,
     upgrade_pyproject_toml,
     upgrade_setup_cfg,
-    _isin_case_dashhyphen_ins,
 )
 
 REQS = """
@@ -27,6 +28,12 @@
     myupgrade
 command =
     pytest tests -m 'not integration'
+
+[edgetest.envs.myenv_lower]
+lower =
+    mylower
+command =
+    pytest tests -m 'not integration'
 """
 
 CFG_REQS = """
@@ -59,6 +66,12 @@
     myupgrade
 command =
     pytest tests
+
+[edgetest.envs.myenv_lower]
+lower =
+    mylower
+command =
+    pytest tests
 """
 
 CFG_CUSTOM = """
@@ -74,6 +87,10 @@
 [edgetest.envs.myenv]
 upgrade =
     myupgrade
+
+[edgetest.envs.myenv_lower]
+lower =
+    mylower
 """
 
 
@@ -83,6 +100,10 @@
     "myupgrade"
 ]
 command = "pytest tests -m 'not integration'"
+
+[edgetest.envs.myenv_lower]
+lower = ["mylower"]
+command = "pytest tests -m 'not integration'"
 """
 
 
@@ -111,6 +132,10 @@
 [edgetest.envs.myenv]
 upgrade = ["myupgrade"]
 command = "pytest tests"
+
+[edgetest.envs.myenv_lower]
+lower = ["mylower"]
+command = "pytest tests"
 """
 
 TOML_CUSTOM = """
@@ -123,6 +148,9 @@
 
 [edgetest.envs.myenv]
 upgrade = ["myupgrade"]
+
+[edgetest.envs.myenv_lower]
+lower = ["mylower"]
 """
 
 
@@ -149,6 +177,18 @@
 ]
 """
 
+REQS_NORMAL = """
+pandas>=1.0.0,<=2.0
+numpy<=0.24,>=0.01
+"""
+
+
+def test_get_lower_bounds():
+    """Test getting lower bound from reqs."""
+    assert get_lower_bounds(REQS_NORMAL, "pandas\nnumpy\n") == "pandas==1.0.0\nnumpy==0.01\n"
+    assert get_lower_bounds(REQS_NORMAL, "pandas") == "pandas==1.0.0\n"
+    assert get_lower_bounds(REQS_NORMAL, "") == ""
+    assert get_lower_bounds(REQS, "mydep2") == ""
 
 @patch("edgetest.utils.Path")
 def test_parse_reqs(mock_pathlib):
@@ -184,7 +224,12 @@ def test_parse_cfg(tmpdir):
                 "name": "myenv",
                 "upgrade": "\nmyupgrade",
                 "command": "\npytest tests -m 'not integration'",
-            }
+            },
+            {
+                "name": "myenv_lower",
+                "lower": "\nmylower",
+                "command": "\npytest tests -m 'not integration'",
+            },
         ]
     }
 
@@ -209,7 +254,13 @@ def test_parse_cfg_default(tmpdir):
                 "upgrade": "\nmyupgrade",
                 "extras": "\ntests",
                 "command": "\npytest tests",
-            }
+            },
+            {
+                "name": "myenv_lower",
+                "lower": "\nmylower",
+                "extras": "\ntests",
+                "command": "\npytest tests",
+            },
         ]
     }
 
@@ -287,7 +338,13 @@ def test_parse_custom_cfg(tmpdir):
                 "upgrade": "\nmyupgrade",
                 "extras": "\ntests",
                 "command": "\npytest tests -m 'not integration'",
-            }
+            },
+            {
+                "name": "myenv_lower",
+                "lower": "\nmylower",
+                "extras": "\ntests",
+                "command": "\npytest tests -m 'not integration'",
+            },
         ],
     }
 
@@ -316,7 +373,12 @@ def test_parse_toml(tmpdir):
                 "name": "myenv",
                 "upgrade": "myupgrade",
                 "command": "pytest tests -m 'not integration'",
-            }
+            },
+            {
+                "name": "myenv_lower",
+                "lower": "mylower",
+                "command": "pytest tests -m 'not integration'",
+            },
         ]
     }
 
@@ -341,7 +403,13 @@ def test_parse_toml_default(tmpdir):
                 "upgrade": "myupgrade",
                 "extras": "tests",
                 "command": "pytest tests",
-            }
+            },
+            {
+                "name": "myenv_lower",
+                "lower": "mylower",
+                "extras": "tests",
+                "command": "pytest tests",
+            },
         ]
     }
 
@@ -419,7 +487,13 @@ def test_parse_custom_toml(tmpdir):
                 "upgrade": "myupgrade",
                 "extras": "tests",
                 "command": "pytest tests -m 'not integration'",
-            }
+            },
+            {
+                "name": "myenv_lower",
+                "lower": "mylower",
+                "extras": "tests",
+                "command": "pytest tests -m 'not integration'",
+            },
         ],
     }
 
@@ -493,4 +567,4 @@ def test_isin_case_dashhyphen_ins():
     assert _isin_case_dashhyphen_ins("python-dateutil", vals)
     assert _isin_case_dashhyphen_ins("Python_Dateutil", vals)
     assert not _isin_case_dashhyphen_ins("Python_Dateut1l", vals)
-    assert not _isin_case_dashhyphen_ins("pandaspython-dateutil", vals)
\ No newline at end of file
+    assert not _isin_case_dashhyphen_ins("pandaspython-dateutil", vals)