Repository: meltano/meltano

Issue:
Use `httpserver` Pytest fixture for Cloud CLI tests
The dependency on `pytest-httpserver` was introduced in:
- https://github.com/meltano/meltano/pull/7463

We can simplify our tests for the Cloud CLI by using it instead of `aioresponses`. With it, we can have a fixture which always handles auth, rather than having each test re-declare handling for that HTTP request. We can also avoid nesting code within the with block required by `aioresponses`.

To use `pytest-httpserver`, the Cloud CLI test config should be updated to use `localhost` for its auth and API base URLs.


Discussion:

Resolving patch:
diff --git a/noxfile.py b/noxfile.py
--- a/noxfile.py
+++ b/noxfile.py
@@ -41,7 +41,6 @@
 python_versions = ("3.7", "3.8", "3.9", "3.10", "3.11")
 main_python_version = "3.10"
 pytest_deps = (
-    "aioresponses",
     "colorama",  # colored output in Windows
     "freezegun",
     "hypothesis",
diff --git a/src/cloud-cli/meltano/cloud/cli/project.py b/src/cloud-cli/meltano/cloud/cli/project.py
--- a/src/cloud-cli/meltano/cloud/cli/project.py
+++ b/src/cloud-cli/meltano/cloud/cli/project.py
@@ -275,7 +275,7 @@ async def use_project(
         context.projects = await _get_projects(context.config)
         _check_for_duplicate_project_names(context.projects)
         if project_name not in {x["project_name"] for x in context.projects}:
-            raise click.UsageError(
+            raise click.ClickException(
                 f"Unable to use project named {project_name!r} - no available "
                 "project matches name.",
             )


Test patch:
diff --git a/src/cloud-cli/tests/api/auth/test_auth.py b/src/cloud-cli/tests/api/auth/test_auth.py
--- a/src/cloud-cli/tests/api/auth/test_auth.py
+++ b/src/cloud-cli/tests/api/auth/test_auth.py
@@ -1,14 +1,17 @@
 from __future__ import annotations
 
-import json
+import typing as t
+from http import HTTPStatus
 from pathlib import Path
 
 import pytest
-from aioresponses import aioresponses
 
 from meltano.cloud.api.auth import MeltanoCloudAuth
 from meltano.cloud.api.config import MeltanoCloudConfig
 
+if t.TYPE_CHECKING:
+    from pytest_httpserver import HTTPServer
+
 
 class TestMeltanoCloudAuth:
     """Test the Meltano Cloud API Authentication."""
@@ -20,15 +23,20 @@ def config(self, tmp_path: Path):
         return MeltanoCloudConfig(config_path=path)
 
     @pytest.fixture()
-    def subject(self, monkeypatch: pytest.MonkeyPatch, config: MeltanoCloudConfig):
+    def subject(
+        self,
+        monkeypatch: pytest.MonkeyPatch,
+        config: MeltanoCloudConfig,
+        httpserver_base_url: str,
+    ):
         monkeypatch.setenv("MELTANO_CLOUD_AUTH_CALLBACK_PORT", "8080")
-        monkeypatch.setenv("MELTANO_CLOUD_BASE_AUTH_URL", "http://meltano-cloud-test")
+        monkeypatch.setenv("MELTANO_CLOUD_BASE_AUTH_URL", httpserver_base_url)
         monkeypatch.setenv("MELTANO_CLOUD_APP_CLIENT_ID", "meltano-cloud-test")
         return MeltanoCloudAuth(config=config)
 
-    def test_login_url(self, subject: MeltanoCloudAuth):
+    def test_login_url(self, subject: MeltanoCloudAuth, httpserver_base_url: str):
         assert subject.login_url == (
-            "http://meltano-cloud-test/oauth2/authorize?"  # noqa: WPS323
+            f"{httpserver_base_url}/oauth2/authorize?"  # noqa: WPS323
             "client_id=meltano-cloud-test&"
             "response_type=token&"
             "scope=email+openid+profile"
@@ -52,13 +60,7 @@ async def test_logged_in_success(
     ):
         monkeypatch.setattr(subject.config, "access_token", "meltano-cloud-test")
         monkeypatch.setattr(subject.config, "id_token", "meltano-cloud-test")
-        with aioresponses() as m:
-            m.get(
-                "http://meltano-cloud-test/oauth2/userInfo",
-                status=200,
-                body=json.dumps({"sub": "meltano-cloud-test"}),
-            )
-            assert await subject.logged_in() is True
+        assert await subject.logged_in() is True
 
     async def test_logged_in_no_tokens(
         self,
@@ -67,24 +69,17 @@ async def test_logged_in_no_tokens(
     ):
         monkeypatch.setattr(subject.config, "access_token", None)
         monkeypatch.setattr(subject.config, "id_token", None)
-        with aioresponses() as m:
-            m.get(
-                "http://meltano-cloud-test/oauth2/userInfo",
-                status=200,
-                body=json.dumps({"sub": "meltano-cloud-test"}),
-            )
-            assert await subject.logged_in() is False
+        assert await subject.logged_in() is False
 
     async def test_logged_in_fail(
         self,
         subject: MeltanoCloudAuth,
         monkeypatch: pytest.MonkeyPatch,
+        httpserver: HTTPServer,
     ):
         monkeypatch.setattr(subject.config, "access_token", "meltano-cloud-test")
         monkeypatch.setattr(subject.config, "id_token", "meltano-cloud-test")
-        with aioresponses() as m:
-            m.get(
-                "http://meltano-cloud-test/oauth2/userInfo",
-                status=401,
-            )
-            assert await subject.logged_in() is False
+        httpserver.expect_oneshot_request("/oauth2/userInfo").respond_with_data(
+            status=HTTPStatus.UNAUTHORIZED,
+        )
+        assert await subject.logged_in() is False
diff --git a/src/cloud-cli/tests/cli/conftest.py b/src/cloud-cli/tests/cli/conftest.py
--- a/src/cloud-cli/tests/cli/conftest.py
+++ b/src/cloud-cli/tests/cli/conftest.py
@@ -1,12 +1,13 @@
 from __future__ import annotations
 
-from pathlib import Path
+import typing as t
 
-import jwt
 import pytest
 
 from meltano.cloud.api import MeltanoCloudClient
-from meltano.cloud.api.config import MeltanoCloudConfig
+
+if t.TYPE_CHECKING:
+    from meltano.cloud.api.config import MeltanoCloudConfig
 
 
 @pytest.fixture()
@@ -21,41 +22,6 @@ def internal_project_id():
     return "pytest-123"
 
 
-@pytest.fixture()
-def auth_token():
-    return "meltano-cloud-test"
-
-
-@pytest.fixture()
-def id_token(tenant_resource_key: str, internal_project_id: str, auth_token: str):
-    """Return a fake ID token.
-
-    Includes the tenant_resource_key and internal_project_id as a custom attribute.
-    """
-    return jwt.encode(
-        {
-            "sub": auth_token,
-            "custom:trk_and_pid": f"{tenant_resource_key}::{internal_project_id}",
-        },
-        key="",
-    )
-
-
-@pytest.fixture()
-def config(config_path: Path, id_token: str) -> MeltanoCloudConfig:
-    """Return a MeltanoCloudConfig instance with fake credentials."""
-    config = MeltanoCloudConfig(  # noqa: S106
-        config_path=config_path,
-        base_auth_url="http://auth-test.meltano.cloud",
-        base_url="https://internal.api-test.meltano.cloud/",
-        app_client_id="meltano-cloud-test",
-        access_token="meltano-cloud-test",  # noqa: S106
-        id_token=id_token,
-    )
-    config.write_to_file()
-    return config
-
-
 @pytest.fixture()
 def client(config: MeltanoCloudConfig) -> MeltanoCloudClient:
     """Return a MeltanoCloudClient instance with fake credentials."""
diff --git a/src/cloud-cli/tests/cli/test_history.py b/src/cloud-cli/tests/cli/test_history.py
--- a/src/cloud-cli/tests/cli/test_history.py
+++ b/src/cloud-cli/tests/cli/test_history.py
@@ -4,12 +4,9 @@
 
 import datetime
 import json
-import re
 import typing as t
-from urllib.parse import urljoin
 
 import pytest
-from aioresponses import aioresponses
 from click.testing import CliRunner
 from freezegun import freeze_time
 from hypothesis import given
@@ -25,7 +22,8 @@
 )
 
 if t.TYPE_CHECKING:
-    from meltano.cloud.api import MeltanoCloudClient
+    from pytest_httpserver import HTTPServer
+
     from meltano.cloud.api.config import MeltanoCloudConfig
 
 
@@ -95,32 +93,8 @@ class TestHistoryCommand:
     """Test the history command."""
 
     @pytest.fixture()
-    def auth_url(self, config: MeltanoCloudConfig) -> str:
-        return f"{config.base_auth_url}/oauth2/userInfo"
-
-    @pytest.fixture()
-    def logged_in(self, auth_url: str) -> t.Generator[aioresponses, None, None]:
-        with aioresponses() as m:
-            m.get(
-                auth_url,
-                status=200,
-                body=json.dumps({"sub": "meltano-cloud-test"}),
-            )
-            yield m
-
-    @pytest.fixture()
-    def url(
-        self,
-        tenant_resource_key: str,
-        internal_project_id: str,
-        client: MeltanoCloudClient,
-    ) -> str:
-        path = f"history/v1/{tenant_resource_key}/{internal_project_id}"
-        return urljoin(client.api_url, path)
-
-    @pytest.fixture()
-    def url_pattern(self, url: str) -> re.Pattern:
-        return re.compile(f"^{url}(\\?.*)?$")
+    def path(self, tenant_resource_key: str, internal_project_id: str) -> str:
+        return f"/history/v1/{tenant_resource_key}/{internal_project_id}"
 
     @pytest.fixture()
     def response_body(self) -> dict:
@@ -152,16 +126,16 @@ def response_body(self) -> dict:
 
     def test_history_json(
         self,
-        url: str,
-        url_pattern: re.Pattern,
+        path: str,
         config: MeltanoCloudConfig,
-        logged_in: aioresponses,
+        httpserver: HTTPServer,
         response_body: dict,
     ):
-        logged_in.get(url_pattern, status=200, payload=response_body)
-
-        runner = CliRunner()
-        result = runner.invoke(
+        httpserver.expect_oneshot_request(
+            path,
+            query_string={"page_size": "10"},
+        ).respond_with_json(response_body)
+        result = CliRunner().invoke(
             cli,
             (
                 "--config-path",
@@ -171,22 +145,21 @@ def test_history_json(
             ),
         )
         assert result.exit_code == 0
-        logged_in.assert_called_with(url=url, params={"page_size": 10})
-
         data = json.loads(result.output)
         assert len(data) == 2
 
     def test_filter_schedule(
         self,
-        url: str,
-        url_pattern: re.Pattern,
+        path: str,
         config: MeltanoCloudConfig,
-        logged_in: aioresponses,
         response_body: dict,
+        httpserver: HTTPServer,
     ):
-        logged_in.get(url_pattern, status=200, payload=response_body)
-        runner = CliRunner()
-        result = runner.invoke(
+        httpserver.expect_oneshot_request(
+            path,
+            query_string={"page_size": "10", "schedule": "daily"},
+        ).respond_with_json(response_body)
+        result = CliRunner().invoke(
             cli,
             (
                 "--config-path",
@@ -197,10 +170,6 @@ def test_filter_schedule(
         )
 
         assert result.exit_code == 0
-        logged_in.assert_called_with(
-            url=url,
-            params={"page_size": 10, "schedule": "daily"},
-        )
 
     @freeze_time(datetime.datetime(2023, 5, 20, tzinfo=UTC))
     @pytest.mark.parametrize(
@@ -219,15 +188,19 @@ def test_lookback(
         self,
         lookback: str,
         expected_start_time: str,
-        url: str,
-        url_pattern: re.Pattern,
+        path: str,
         config: MeltanoCloudConfig,
-        logged_in: aioresponses,
         response_body: dict,
+        httpserver: HTTPServer,
     ):
-        logged_in.get(url_pattern, status=200, payload=response_body)
-        runner = CliRunner()
-        result = runner.invoke(
+        httpserver.expect_oneshot_request(
+            path,
+            query_string={
+                "page_size": "10",
+                "start_time": expected_start_time,
+            },
+        ).respond_with_json(response_body)
+        result = CliRunner().invoke(
             cli,
             (
                 "--config-path",
@@ -236,15 +209,8 @@ def test_lookback(
                 f"--lookback={lookback}",
             ),
         )
-
         assert result.exit_code == 0
-        logged_in.assert_called_with(
-            url=url,
-            params={
-                "page_size": 10,
-                "start_time": expected_start_time,
-            },
-        )
+        httpserver.check_assertions()
 
     def test_invalid_lookback(self):
         runner = CliRunner()
diff --git a/src/cloud-cli/tests/cli/test_logs.py b/src/cloud-cli/tests/cli/test_logs.py
--- a/src/cloud-cli/tests/cli/test_logs.py
+++ b/src/cloud-cli/tests/cli/test_logs.py
@@ -2,17 +2,15 @@
 
 from __future__ import annotations
 
-import json
 import typing as t
-from urllib.parse import urljoin
 
-from aioresponses import aioresponses
 from click.testing import CliRunner
 
 from meltano.cloud.cli import cloud as cli
 
 if t.TYPE_CHECKING:
-    from meltano.cloud.api import MeltanoCloudClient
+    from pytest_httpserver import HTTPServer
+
     from meltano.cloud.api.config import MeltanoCloudConfig
 
 
@@ -23,35 +21,25 @@ def test_logs_print(
         self,
         tenant_resource_key: str,
         internal_project_id: str,
-        client: MeltanoCloudClient,
         config: MeltanoCloudConfig,
+        httpserver: HTTPServer,
     ):
         """Test the `logs print` subcommand."""
         execution_id = "12345"
-        path = f"logs/v1/{tenant_resource_key}/{internal_project_id}/{execution_id}"
-
-        with aioresponses() as m:
-            m.get(
-                f"{config.base_auth_url}/oauth2/userInfo",
-                status=200,
-                body=json.dumps({"sub": "meltano-cloud-test"}),
-            )
-            m.get(
-                urljoin(client.api_url, path),
-                status=200,
-                body=b"[2023-05-01 00:00:00] Starting Job...",
-            )
-
-            result = CliRunner().invoke(
-                cli,
-                [
-                    "--config-path",
-                    str(config.config_path),
-                    "logs",
-                    "print",
-                    "--execution-id",
-                    execution_id,
-                ],
-            )
-            assert result.exit_code == 0
-            assert "[2023-05-01 00:00:00] Starting Job..." in result.output
+        path = f"/logs/v1/{tenant_resource_key}/{internal_project_id}/{execution_id}"
+        httpserver.expect_oneshot_request(path).respond_with_data(
+            b"[2023-05-01 00:00:00] Starting Job...",
+        )
+        result = CliRunner().invoke(
+            cli,
+            [
+                "--config-path",
+                str(config.config_path),
+                "logs",
+                "print",
+                "--execution-id",
+                execution_id,
+            ],
+        )
+        assert result.exit_code == 0
+        assert "[2023-05-01 00:00:00] Starting Job..." in result.output
diff --git a/src/cloud-cli/tests/cli/test_project.py b/src/cloud-cli/tests/cli/test_project.py
--- a/src/cloud-cli/tests/cli/test_project.py
+++ b/src/cloud-cli/tests/cli/test_project.py
@@ -4,15 +4,12 @@
 
 import json
 import platform
-import re
 import subprocess
 import sys
 import typing as t
 from pathlib import Path
-from urllib.parse import urljoin
 
 import pytest
-from aioresponses import aioresponses
 from click.testing import CliRunner
 
 from meltano.cloud.cli import cloud as cli
@@ -21,7 +18,6 @@
 if t.TYPE_CHECKING:
     from pytest_httpserver import HTTPServer
 
-    from meltano.cloud.api import MeltanoCloudClient
     from meltano.cloud.api.config import MeltanoCloudConfig
     from meltano.cloud.api.types import CloudProject
 
@@ -29,15 +25,6 @@
 class TestProjectCommand:
     """Test the logs command."""
 
-    @pytest.fixture()
-    def url_pattern(
-        self,
-        tenant_resource_key: str,
-        client: MeltanoCloudClient,
-    ) -> re.Pattern:
-        path = f"projects/v1/{tenant_resource_key}"
-        return re.compile(f"^{urljoin(client.api_url, path)}(\\?.*)?$")
-
     @pytest.fixture()
     def projects(self, tenant_resource_key: str) -> list[CloudProject]:
         return [
@@ -84,27 +71,19 @@ def projects(self, tenant_resource_key: str) -> list[CloudProject]:
         ]
 
     @pytest.fixture()
-    def projects_get_reponse(
+    def path(self, tenant_resource_key: str) -> str:
+        return f"/projects/v1/{tenant_resource_key}"
+
+    def test_project_list_table(
         self,
-        url_pattern: re.Pattern,
-        projects: list[CloudProject],
         config: MeltanoCloudConfig,
-    ) -> None:
-        with aioresponses() as m:
-            m.get(
-                f"{config.base_auth_url}/oauth2/userInfo",
-                status=200,
-                body=json.dumps({"sub": "meltano-cloud-test"}),
-            )
-            m.get(
-                url_pattern,
-                status=200,
-                payload={"results": projects, "pagination": None},
-            )
-            yield
-
-    @pytest.mark.usefixtures("projects_get_reponse")
-    def test_project_list_table(self, config: MeltanoCloudConfig):
+        path: str,
+        httpserver: HTTPServer,
+        projects: list[CloudProject],
+    ):
+        httpserver.expect_oneshot_request(path).respond_with_json(
+            {"results": projects, "pagination": None},
+        )
         result = CliRunner().invoke(
             cli,
             ("--config-path", config.config_path, "project", "list"),
@@ -122,8 +101,16 @@ def test_project_list_table(self, config: MeltanoCloudConfig):
             "╰───────────┴─────────────────────────────────┴────────────────────────────────────────────────╯\n"  # noqa: E501
         )  # noqa: E501
 
-    @pytest.mark.usefixtures("projects_get_reponse")
-    def test_project_list_table_with_default_project(self, config: MeltanoCloudConfig):
+    def test_project_list_table_with_default_project(
+        self,
+        config: MeltanoCloudConfig,
+        path: str,
+        httpserver: HTTPServer,
+        projects: list[CloudProject],
+    ):
+        httpserver.expect_oneshot_request(path).respond_with_json(
+            {"results": projects, "pagination": None},
+        )
         config.internal_project_id = "01GWQ76M7EN1GKYGKV8P6BFKNV"  # Set default project
         result = CliRunner().invoke(
             cli,
@@ -142,12 +129,16 @@ def test_project_list_table_with_default_project(self, config: MeltanoCloudConfi
             "╰───────────┴─────────────────────────────────┴────────────────────────────────────────────────╯\n"  # noqa: E501
         )  # noqa: E501
 
-    @pytest.mark.usefixtures("projects_get_reponse")
     def test_project_list_json(
         self,
-        projects: list[CloudProject],
         config: MeltanoCloudConfig,
+        path: str,
+        httpserver: HTTPServer,
+        projects: list[CloudProject],
     ):
+        httpserver.expect_oneshot_request(path).respond_with_json(
+            {"results": projects, "pagination": None},
+        )
         result = CliRunner().invoke(
             cli,
             (
@@ -165,32 +156,25 @@ def test_project_list_json(
 
     def test_project_use_by_name(
         self,
-        url_pattern: re.Pattern,
+        path: str,
+        httpserver: HTTPServer,
         config: MeltanoCloudConfig,
         projects: list[CloudProject],
     ):
-        with aioresponses() as m:
-            m.get(
-                f"{config.base_auth_url}/oauth2/userInfo",
-                status=200,
-                body=json.dumps({"sub": "meltano-cloud-test"}),
-            )
-            m.get(
-                url_pattern,
-                status=200,
-                payload={"results": [projects[0]], "pagination": None},
-            )
-            result = CliRunner().invoke(
-                cli,
-                (
-                    "--config-path",
-                    config.config_path,
-                    "project",
-                    "use",
-                    "--name",
-                    "Meltano Cubed",
-                ),
-            )
+        httpserver.expect_oneshot_request(path).respond_with_json(
+            {"results": [projects[0]], "pagination": None},
+        )
+        result = CliRunner().invoke(
+            cli,
+            (
+                "--config-path",
+                config.config_path,
+                "project",
+                "use",
+                "--name",
+                "Meltano Cubed",
+            ),
+        )
         assert result.exit_code == 0, result.output
         assert result.output == (
             "Set 'Meltano Cubed' as the default Meltano Cloud project for "
@@ -209,22 +193,14 @@ def test_project_use_by_name(
     )
     def test_project_use_by_name_interactive(
         self,
-        tenant_resource_key: str,
+        path: str,
+        httpserver: HTTPServer,
         config: MeltanoCloudConfig,
         projects: list[CloudProject],
-        httpserver: HTTPServer,
     ):
-        httpserver.expect_request("/oauth2/userInfo").respond_with_json(
-            {"sub": "meltano-cloud-test"},
-        )
-        httpserver.expect_request(
-            f"/projects/v1/{tenant_resource_key}",
-        ).respond_with_json(
+        httpserver.expect_oneshot_request(path).respond_with_json(
             {"results": [*projects[:2], *projects[3:]], "pagination": None},
         )
-        config.base_auth_url = f"http://localhost:{httpserver.port}"
-        config.base_url = f"http://localhost:{httpserver.port}"
-        config.write_to_file()
         result = subprocess.run(
             (
                 sys.executable,
@@ -252,30 +228,23 @@ def test_project_use_by_name_interactive(
 
     def test_project_use_by_name_interactive_duplicate_name(
         self,
-        url_pattern: re.Pattern,
         config: MeltanoCloudConfig,
+        path: str,
+        httpserver: HTTPServer,
         projects: list[CloudProject],
     ):
-        with aioresponses() as m:
-            m.get(
-                f"{config.base_auth_url}/oauth2/userInfo",
-                status=200,
-                body=json.dumps({"sub": "meltano-cloud-test"}),
-            )
-            m.get(
-                url_pattern,
-                status=200,
-                payload={"results": projects, "pagination": None},
-            )
-            result = CliRunner().invoke(
-                cli,
-                (
-                    "--config-path",
-                    config.config_path,
-                    "project",
-                    "use",
-                ),
-            )
+        httpserver.expect_oneshot_request(path).respond_with_json(
+            {"results": projects, "pagination": None},
+        )
+        result = CliRunner().invoke(
+            cli,
+            (
+                "--config-path",
+                config.config_path,
+                "project",
+                "use",
+            ),
+        )
         assert result.exit_code == 1
         assert (
             "Error: Multiple Meltano Cloud projects have the same name."
@@ -284,32 +253,25 @@ def test_project_use_by_name_interactive_duplicate_name(
 
     def test_project_use_by_name_like_id(
         self,
-        url_pattern: re.Pattern,
+        path: str,
+        httpserver: HTTPServer,
         config: MeltanoCloudConfig,
         projects: list[CloudProject],
     ):
-        with aioresponses() as m:
-            m.get(
-                f"{config.base_auth_url}/oauth2/userInfo",
-                status=200,
-                body=json.dumps({"sub": "meltano-cloud-test"}),
-            )
-            m.get(
-                url_pattern,
-                status=200,
-                payload={"results": [projects[4]], "pagination": None},
-            )
-            result = CliRunner().invoke(
-                cli,
-                (
-                    "--config-path",
-                    config.config_path,
-                    "project",
-                    "use",
-                    "--name",
-                    "01GWQRDA1HZNTSW7JK0KNGCYS9",
-                ),
-            )
+        httpserver.expect_oneshot_request(path).respond_with_json(
+            {"results": [*projects[:2], *projects[3:]], "pagination": None},
+        )
+        result = CliRunner().invoke(
+            cli,
+            (
+                "--config-path",
+                config.config_path,
+                "project",
+                "use",
+                "--name",
+                "01GWQRDA1HZNTSW7JK0KNGCYS9",
+            ),
+        )
         assert result.exit_code == 0, result.output
         assert result.output == (
             "Set '01GWQRDA1HZNTSW7JK0KNGCYS9' as the default Meltano Cloud "
@@ -320,34 +282,18 @@ def test_project_use_by_name_like_id(
             == "01GWQREZ7G0526JZS9JY5H3BH9"
         )
 
-    def test_project_use_by_id(
-        self,
-        url_pattern: re.Pattern,
-        config: MeltanoCloudConfig,
-        projects: list[CloudProject],
-    ):
-        with aioresponses() as m:
-            m.get(
-                f"{config.base_auth_url}/oauth2/userInfo",
-                status=200,
-                body=json.dumps({"sub": "meltano-cloud-test"}),
-            )
-            m.get(
-                url_pattern,
-                status=200,
-                payload={"results": [projects[0]], "pagination": None},
-            )
-            result = CliRunner().invoke(
-                cli,
-                (
-                    "--config-path",
-                    config.config_path,
-                    "project",
-                    "use",
-                    "--id",
-                    "01GWQ7520WNMQT0PQ6KHCC4EE1",
-                ),
-            )
+    def test_project_use_by_id(self, config: MeltanoCloudConfig):
+        result = CliRunner().invoke(
+            cli,
+            (
+                "--config-path",
+                config.config_path,
+                "project",
+                "use",
+                "--id",
+                "01GWQ7520WNMQT0PQ6KHCC4EE1",
+            ),
+        )
         assert result.exit_code == 0, result.output
         assert result.output == (
             "Set the project with ID '01GWQ7520WNMQT0PQ6KHCC4EE1' as the "
@@ -360,32 +306,25 @@ def test_project_use_by_id(
 
     def test_project_use_ambigous_name_error(
         self,
-        url_pattern: re.Pattern,
+        path: str,
+        httpserver: HTTPServer,
         config: MeltanoCloudConfig,
         projects: list[CloudProject],
     ):
-        with aioresponses() as m:
-            m.get(
-                f"{config.base_auth_url}/oauth2/userInfo",
-                status=200,
-                body=json.dumps({"sub": "meltano-cloud-test"}),
-            )
-            m.get(
-                url_pattern,
-                status=200,
-                payload={"results": projects[1:3], "pagination": None},
-            )
-            result = CliRunner().invoke(
-                cli,
-                (
-                    "--config-path",
-                    config.config_path,
-                    "project",
-                    "use",
-                    "--name",
-                    "Post-Modern Data Stack in a Box",
-                ),
-            )
+        httpserver.expect_oneshot_request(path).respond_with_json(
+            {"results": projects[1:3], "pagination": None},
+        )
+        result = CliRunner().invoke(
+            cli,
+            (
+                "--config-path",
+                config.config_path,
+                "project",
+                "use",
+                "--name",
+                "Post-Modern Data Stack in a Box",
+            ),
+        )
         assert result.exit_code == 1
         assert result.output == (
             "Error: Multiple Meltano Cloud projects have the same name. "
diff --git a/src/cloud-cli/tests/cli/test_run.py b/src/cloud-cli/tests/cli/test_run.py
--- a/src/cloud-cli/tests/cli/test_run.py
+++ b/src/cloud-cli/tests/cli/test_run.py
@@ -2,17 +2,15 @@
 
 from __future__ import annotations
 
-import json
 import typing as t
-from urllib.parse import urljoin
 
-from aioresponses import aioresponses
 from click.testing import CliRunner
 
 from meltano.cloud.cli import cloud as cli
 
 if t.TYPE_CHECKING:
-    from meltano.cloud.api import MeltanoCloudClient
+    from pytest_httpserver import HTTPServer
+
     from meltano.cloud.api.config import MeltanoCloudConfig
 
 
@@ -23,39 +21,30 @@ def test_run_ok(
         self,
         tenant_resource_key: str,
         internal_project_id: str,
-        client: MeltanoCloudClient,
         config: MeltanoCloudConfig,
+        httpserver: HTTPServer,
     ):
         """Test the `run` subcommand."""
         deployment = "sandbox"
         job_or_schedule = "daily"
         path = (
-            f"run/v1/external/"
+            f"/run/v1/external/"
             f"{tenant_resource_key}/{internal_project_id}/"
             f"{deployment}/{job_or_schedule}"
         )
-
-        with aioresponses() as m:
-            m.get(
-                f"{config.base_auth_url}/oauth2/userInfo",
-                status=200,
-                body=json.dumps({"sub": "meltano-cloud-test"}),
-            )
-            m.post(
-                urljoin(client.api_url, path),
-                status=200,
-                body=b"Running a Meltano project in Meltano Cloud",
-            )
-            result = CliRunner().invoke(
-                cli,
-                [
-                    "--config-path",
-                    str(config.config_path),
-                    "run",
-                    job_or_schedule,
-                    "--deployment",
-                    deployment,
-                ],
-            )
-            assert result.exit_code == 0
-            assert "Running a Meltano project in Meltano Cloud" in result.output
+        httpserver.expect_oneshot_request(path).respond_with_data(
+            b"Running a Meltano project in Meltano Cloud",
+        )
+        result = CliRunner().invoke(
+            cli,
+            [
+                "--config-path",
+                str(config.config_path),
+                "run",
+                job_or_schedule,
+                "--deployment",
+                deployment,
+            ],
+        )
+        assert result.exit_code == 0
+        assert "Running a Meltano project in Meltano Cloud" in result.output
diff --git a/src/cloud-cli/tests/cli/test_schedules.py b/src/cloud-cli/tests/cli/test_schedules.py
--- a/src/cloud-cli/tests/cli/test_schedules.py
+++ b/src/cloud-cli/tests/cli/test_schedules.py
@@ -5,17 +5,16 @@
 import json
 import re
 import typing as t
-from urllib.parse import urljoin
 
 import pytest
-from aioresponses import aioresponses
 from click.testing import CliRunner
 from freezegun import freeze_time
 
 from meltano.cloud.cli import cloud as cli
 
 if t.TYPE_CHECKING:
-    from meltano.cloud.api import MeltanoCloudClient
+    from pytest_httpserver import HTTPServer
+
     from meltano.cloud.api.config import MeltanoCloudConfig
     from meltano.cloud.api.types import CloudProjectSchedule
 
@@ -27,22 +26,12 @@ def test_schedule_enable(
         self,
         tenant_resource_key: str,
         internal_project_id: str,
-        client: MeltanoCloudClient,
         config: MeltanoCloudConfig,
+        httpserver: HTTPServer,
     ):
-        url = urljoin(
-            client.api_url,
-            "/".join(
-                (
-                    "schedules",
-                    "v1",
-                    tenant_resource_key,
-                    internal_project_id,
-                    "dev",
-                    "daily",
-                    "enabled",
-                ),
-            ),
+        path = (
+            f"/schedules/v1/{tenant_resource_key}/{internal_project_id}"
+            "/dev/daily/enabled"
         )
         runner = CliRunner()
         for cmd in ("enable", "disable"):
@@ -52,19 +41,13 @@ def test_schedule_enable(
                 (cmd, "--schedule=daily", "--deployment=dev"),
                 ("--schedule=daily", cmd, "--deployment=dev"),
             ):
-                with aioresponses() as m:
-                    m.get(
-                        f"{config.base_auth_url}/oauth2/userInfo",
-                        status=200,
-                        body=json.dumps({"sub": "meltano-cloud-test"}),
-                    )
-                    m.put(url, status=204)
-                    result = runner.invoke(
-                        cli,
-                        ("--config-path", config.config_path, "schedule", *opts),
-                    )
-                    assert result.exit_code == 0, result.output
-                    assert not result.output
+                httpserver.expect_oneshot_request(path).respond_with_data(status=204)
+                result = runner.invoke(
+                    cli,
+                    ("--config-path", config.config_path, "schedule", *opts),
+                )
+                assert result.exit_code == 0, result.output
+                assert not result.output
 
     @pytest.fixture()
     def schedules(self) -> list[CloudProjectSchedule]:
@@ -95,24 +78,13 @@ def schedules_get_reponse(
         schedules: list[CloudProjectSchedule],
         tenant_resource_key: str,
         internal_project_id: str,
-        client: MeltanoCloudClient,
-        config: MeltanoCloudConfig,
+        httpserver: HTTPServer,
     ) -> None:
-        path = f"schedules/v1/{tenant_resource_key}/{internal_project_id}"
-        url = urljoin(client.api_url, path)
-        pattern = re.compile(f"^{url}(\\?.*)?$")
-        with aioresponses() as m:
-            m.get(
-                f"{config.base_auth_url}/oauth2/userInfo",
-                status=200,
-                body=json.dumps({"sub": "meltano-cloud-test"}),
-            )
-            m.get(
-                pattern,
-                status=200,
-                payload={"results": schedules, "pagination": None},
-            )
-            yield
+        path = f"/schedules/v1/{tenant_resource_key}/{internal_project_id}"
+        pattern = re.compile(f"^{path}(\\?.*)?$")
+        httpserver.expect_oneshot_request(pattern).respond_with_json(
+            {"results": schedules, "pagination": None},
+        )
 
     @freeze_time("2023-03-24 19:30:00")
     @pytest.mark.usefixtures("schedules_get_reponse")
@@ -156,19 +128,17 @@ def test_schedule_describe(
         self,
         tenant_resource_key: str,
         internal_project_id: str,
-        client: MeltanoCloudClient,
         config: MeltanoCloudConfig,
+        httpserver: HTTPServer,
     ):
         deployment_name = "test deployment name"
         schedule_name = "test schedule name"
         interval = "15,45 */8 * * 1,3,5"
-        path = urljoin(
-            client.api_url,
-            (
-                f"schedules/v1/{tenant_resource_key}/{internal_project_id}/"
-                f"{deployment_name}/{schedule_name}"
-            ),
+        path = (
+            f"/schedules/v1/{tenant_resource_key}/{internal_project_id}/"
+            f"{deployment_name}/{schedule_name}"
         )
+
         runner = CliRunner()
         cli_args = (
             "--config-path",
@@ -192,76 +162,54 @@ def test_schedule_describe(
             "2023-03-27 16:45\n"
             "2023-03-29 00:15\n"
         )
-        with aioresponses() as m:
-            m.get(
-                f"{config.base_auth_url}/oauth2/userInfo",
-                status=200,
-                body=json.dumps({"sub": "meltano-cloud-test"}),
-            )
-            m.get(
-                path,
-                status=200,
-                payload={
-                    "deployment_name": deployment_name,
-                    "schedule_name": schedule_name,
-                    "interval": interval,
-                    "enabled": True,
-                },
-            )
-            result = runner.invoke(cli, cli_args)
-            assert result.exit_code == 0, result.output
-            assert (
-                result.output
-                == (
-                    "Deployment name: test deployment name\n"
-                    "Schedule name:   test schedule name\n"
-                    "Interval:        15,45 */8 * * 1,3,5\n"
-                    "Enabled:         True\n"
-                    "\n"
-                    "Approximate starting date and time (UTC) of next 9 scheduled runs:\n"  # noqa: E501
-                )
-                + upcoming_datetimes
-            )
-
-            m.get(
-                f"{config.base_auth_url}/oauth2/userInfo",
-                status=200,
-                body=json.dumps({"sub": "meltano-cloud-test"}),
-            )
-            m.get(
-                path,
-                status=200,
-                payload={
-                    "deployment_name": deployment_name,
-                    "schedule_name": schedule_name,
-                    "interval": interval,
-                    "enabled": True,
-                },
-            )
-            result = runner.invoke(cli, (*cli_args, "--only-upcoming"))
-            assert result.exit_code == 0, result.output
-            assert result.output == upcoming_datetimes
-
-            m.get(
-                f"{config.base_auth_url}/oauth2/userInfo",
-                status=200,
-                body=json.dumps({"sub": "meltano-cloud-test"}),
-            )
-            m.get(
-                path,
-                status=200,
-                payload={
-                    "deployment_name": deployment_name,
-                    "schedule_name": schedule_name,
-                    "interval": interval,
-                    "enabled": False,
-                },
-            )
-            result = runner.invoke(cli, cli_args)
-            assert result.exit_code == 0, result.output
-            assert result.output == (
+        httpserver.expect_oneshot_request(path).respond_with_json(
+            {
+                "deployment_name": deployment_name,
+                "schedule_name": schedule_name,
+                "interval": interval,
+                "enabled": True,
+            },
+        )
+        result = runner.invoke(cli, cli_args)
+        assert result.exit_code == 0, result.output
+        assert (
+            result.output
+            == (
                 "Deployment name: test deployment name\n"
                 "Schedule name:   test schedule name\n"
                 "Interval:        15,45 */8 * * 1,3,5\n"
-                "Enabled:         False\n"
+                "Enabled:         True\n"
+                "\n"
+                "Approximate starting date and time (UTC) of next 9 scheduled runs:\n"  # noqa: E501
             )
+            + upcoming_datetimes
+        )
+
+        httpserver.expect_oneshot_request(path).respond_with_json(
+            {
+                "deployment_name": deployment_name,
+                "schedule_name": schedule_name,
+                "interval": interval,
+                "enabled": True,
+            },
+        )
+        result = runner.invoke(cli, (*cli_args, "--only-upcoming"))
+        assert result.exit_code == 0, result.output
+        assert result.output == upcoming_datetimes
+
+        httpserver.expect_oneshot_request(path).respond_with_json(
+            {
+                "deployment_name": deployment_name,
+                "schedule_name": schedule_name,
+                "interval": interval,
+                "enabled": False,
+            },
+        )
+        result = runner.invoke(cli, cli_args)
+        assert result.exit_code == 0, result.output
+        assert result.output == (
+            "Deployment name: test deployment name\n"
+            "Schedule name:   test schedule name\n"
+            "Interval:        15,45 */8 * * 1,3,5\n"
+            "Enabled:         False\n"
+        )
diff --git a/src/cloud-cli/tests/conftest.py b/src/cloud-cli/tests/conftest.py
--- a/src/cloud-cli/tests/conftest.py
+++ b/src/cloud-cli/tests/conftest.py
@@ -3,9 +3,13 @@
 import os
 from pathlib import Path
 
+import jwt
 import pytest
+from pytest_httpserver import HTTPServer
 from pytest_structlog import StructuredLogCapture
 
+from meltano.cloud.api.config import MeltanoCloudConfig
+
 
 @pytest.fixture(scope="session", autouse=True)
 def config_path(tmpdir_factory: pytest.TempdirFactory) -> Path:
@@ -23,3 +27,67 @@ def config_path(tmpdir_factory: pytest.TempdirFactory) -> Path:
 @pytest.fixture(autouse=True)
 def log(log: StructuredLogCapture) -> StructuredLogCapture:
     return log
+
+
+@pytest.fixture(scope="session", autouse=True)
+def httpserver() -> HTTPServer:
+    with HTTPServer() as http_server:
+        http_server.expect_request("/oauth2/userInfo").respond_with_json(
+            {"sub": "meltano-cloud-test"},
+        )
+        yield http_server
+
+
+@pytest.fixture(autouse=True)
+def clear_httpserver_log(httpserver: HTTPServer) -> None:
+    httpserver.clear_log()
+
+
+@pytest.fixture(autouse=True)
+def clear_httpserver_tmp_hanlders(httpserver: HTTPServer) -> None:
+    httpserver.oneshot_handlers.clear()
+    httpserver.ordered_handlers.clear()
+
+
+@pytest.fixture()
+def auth_token():
+    return "meltano-cloud-test"
+
+
+@pytest.fixture()
+def id_token(tenant_resource_key: str, internal_project_id: str, auth_token: str):
+    """Return a fake ID token.
+
+    Includes the tenant_resource_key and internal_project_id as a custom attribute.
+    """
+    return jwt.encode(
+        {
+            "sub": auth_token,
+            "custom:trk_and_pid": f"{tenant_resource_key}::{internal_project_id}",
+        },
+        key="",
+    )
+
+
+@pytest.fixture(scope="session")
+def httpserver_base_url(httpserver: HTTPServer) -> str:
+    return httpserver.url_for("")
+
+
+@pytest.fixture()
+def config(
+    config_path: Path,
+    id_token: str,
+    httpserver_base_url: str,
+) -> MeltanoCloudConfig:
+    """Return a MeltanoCloudConfig instance with fake credentials."""
+    config = MeltanoCloudConfig(  # noqa: S106
+        config_path=config_path,
+        base_auth_url=httpserver_base_url,
+        base_url=httpserver_base_url,
+        app_client_id="meltano-cloud-test",
+        access_token="meltano-cloud-test",  # noqa: S106
+        id_token=id_token,
+    )
+    config.write_to_file()
+    return config