diff --git a/reviewboard/hostingsvcs/testing/testcases.py b/reviewboard/hostingsvcs/testing/testcases.py
index 3887843e150aa73b05ff5f0d710bac0ed977fe3d..cce939b7f8ca3836f2307ede54f650f09e0da976 100644
--- a/reviewboard/hostingsvcs/testing/testcases.py
+++ b/reviewboard/hostingsvcs/testing/testcases.py
@@ -5,22 +5,56 @@
 import io
 import json
 from contextlib import contextmanager
-from typing import Optional, TYPE_CHECKING
+from typing import Generic, TypeVar, TypedDict, TYPE_CHECKING, cast, overload
 from urllib.error import HTTPError
 from urllib.parse import urlparse
 
-from kgb import SpyAgency
+import kgb
 
-from reviewboard.hostingsvcs.base import (HostingServiceHTTPResponse,
-                                          hosting_service_registry)
+from reviewboard.hostingsvcs.base import (
+    BaseHostingService,
+    hosting_service_registry,
+)
 from reviewboard.hostingsvcs.models import HostingServiceAccount
 from reviewboard.testing import TestCase
 
 if TYPE_CHECKING:
-    from reviewboard.hostingsvcs.base.http import HTTPHeaders
-
-
-class HttpTestContext(object):
+    from collections.abc import Callable, Generator, Mapping, Sequence
+    from typing import Any, Literal
+
+    from kgb.calls import SpyCall
+    from typelets.json import JSONValue
+    from typing_extensions import NotRequired, TypeAlias
+
+    from reviewboard.hostingsvcs.base.client import HostingServiceClient
+    from reviewboard.hostingsvcs.base.forms import (
+        BaseHostingServiceRepositoryForm,
+    )
+    from reviewboard.hostingsvcs.base.hosting_service import (
+        HostingServiceCredentials,
+        RepositoryFields,
+    )
+    from reviewboard.hostingsvcs.base.http import (
+        HostingServiceHTTPRequest,
+        HostingServiceHTTPResponse,
+        HTTPHeaders,
+    )
+    from reviewboard.scmtools.models import Repository
+    from reviewboard.site.models import LocalSite
+
+    HttpRequestMethod: TypeAlias = Callable[
+        [HostingServiceClient, HostingServiceHTTPRequest],
+        HostingServiceHTTPResponse]
+
+
+#: Type variable for the hosting service.
+#:
+#: Version Added:
+#:     8.0
+_THostingService = TypeVar('_THostingService', bound=BaseHostingService)
+
+
+class HttpTestContext(Generic[_THostingService]):
     """State and functions for an HTTP test.
 
     This is provided when calling
@@ -44,7 +78,19 @@
             The hosting service instance used for the test.
     """
 
-    def __init__(self, test_case, hosting_account, http_request_func):
+    ######################
+    # Instance variables #
+    ######################
+
+    #: The hosting service instance.
+    service: _THostingService
+
+    def __init__(
+        self,
+        test_case: HostingServiceTestCase[_THostingService],
+        hosting_account: HostingServiceAccount,
+        http_request_func: HttpRequestMethod,
+    ) -> None:
         """Initialize the test context.
 
         Args:
@@ -59,7 +105,7 @@
                 The function used to handle HTTP requests.
         """
         self.hosting_account = hosting_account
-        self.service = hosting_account.service
+        self.service = cast(_THostingService, hosting_account.service)
         self.client = self.service.client
 
         self.http_requests = []
@@ -69,14 +115,17 @@
         self._http_request_func = http_request_func
 
     @property
-    def http_calls(self):
+    def http_calls(self) -> Sequence[SpyCall]:
         """The HTTP calls made by the service.
 
         This is a list of spy calls from KGB.
         """
-        return self._http_request_func.calls
+        return self._http_request_func.calls  # type:ignore
 
-    def create_repository(self, **kwargs):
+    def create_repository(
+        self,
+        **kwargs,
+    ) -> Repository:
         """Create a repository using the current hosting account.
 
         This wraps :py:meth:`HostingServiceTestCase.create_repository`,
@@ -100,8 +149,8 @@
         index: int = 0,
         url: str = '',
         method: str = 'GET',
-        body: Optional[str] = None,
-        headers: Optional[HTTPHeaders] = None,
+        body: (bytes | None) = None,
+        headers: (HTTPHeaders | None) = None,
         **kwargs,
     ) -> None:
         """Assert that an HTTP call was made.
@@ -129,7 +178,7 @@
             method (str, optional);
                 The HTTP method expected for the call.
 
-            body (str, optional):
+            body (bytes, optional):
                 The expected body for the call (used for POST/PUT requests).
 
             headers (dict, optional):
@@ -143,18 +192,20 @@
         # Check arguments against the request.
         request = self.http_requests[index]
 
-        if url:
-            if request.url != url:
-                raise failureException('HTTP call %s: URL: %r != %r'
-                                       % (index, request.url, url))
+        if url and request.url != url:
+            raise failureException(
+                f'HTTP call {index}: URL: {request.url!r} != {url!r}'
+            )
 
         if request.method != method:
-            raise failureException('HTTP call %s: method: %r != %r'
-                                   % (index, request.method, method))
+            raise failureException(
+                f'HTTP call {index}: method: {request.method!r} != {method!r}'
+            )
 
         if request.body != body:
-            raise failureException('HTTP call %s: body: %r != %r'
-                                   % (index, request.body, body))
+            raise failureException(
+                f'HTTP call {index}: body: {request.body!r} != {body!r}'
+            )
 
         # Check arguments against the generated credentials.
         if 'username' in kwargs and 'password' in kwargs:
@@ -175,26 +226,53 @@
                                      self._test_case.default_http_credentials)
 
         if self.http_credentials[index] != credentials:
-            raise failureException('HTTP call %s: credentials: %r != %r'
-                                   % (index, self.http_credentials[index],
-                                      credentials))
+            raise failureException(
+                f'HTTP call {index}: credentials: '
+                f'{self.http_credentials[index]!r} != {credentials!r}'
+            )
 
         # Check arguments against the call.
         call = self.http_calls[index]
 
         if call.kwargs['headers'] != headers:
-            raise failureException('HTTP call %s: headers: %r != %r'
-                                   % (index, call.kwargs['headers'], body))
+            raise failureException(
+                f'HTTP call {index}: headers: {call.kwargs["headers"]!r} != '
+                f'{headers!r}'
+            )
 
         for key, value in kwargs.items():
             if call.kwargs[key] != value:
-                raise failureException('HTTP call %s: %s: %r != %r'
-                                       % (index, key, call.kwargs[key], value))
-
-
-class HostingServiceTestCase(SpyAgency, TestCase):
+                raise failureException(
+                    f'HTTP call {index}: {key!r}: {call.kwargs[key]!r} != '
+                    f'{value!r}'
+                )
+
+
+class HttpTestPath(TypedDict):
+    """Data for a test path provided to the hosting service test case.
+
+    Version Added:
+        8.0
+    """
+
+    #: The headers to return with the response.
+    headers: NotRequired[HTTPHeaders | None]
+
+    #: The payload to return in the response.
+    payload: NotRequired[bytes | None]
+
+    #: The status code for the response.
+    status_code: NotRequired[int | None]
+
+
+class HostingServiceTestCase(kgb.SpyAgency,
+                             TestCase,
+                             Generic[_THostingService]):
     """Base class for unit tests for hosting services."""
 
+    #: The hosting service class.
+    service_class: type[_THostingService]
+
     #: The registered name of the service.
     service_name = None
 
@@ -225,7 +303,7 @@
     fixtures = ['test_scmtools']
 
     @property
-    def default_http_credentials(self):
+    def default_http_credentials(self) -> HostingServiceCredentials:
         """The default credentials sent in HTTP requests."""
         return {
             'username': self.default_username,
@@ -233,24 +311,29 @@
         }
 
     @classmethod
-    def setUpClass(cls):
-        super(HostingServiceTestCase, cls).setUpClass()
-
-        if cls.service_name:
-            cls.service_class = \
-                hosting_service_registry.get_hosting_service(cls.service_name)
-        else:
-            cls.service_class = None
-
-    def setUp(self):
-        super(HostingServiceTestCase, self).setUp()
-
-        self.assertIsNotNone(self.service_class)
+    def setUpClass(cls) -> None:
+        """Set up the test case class."""
+        super().setUpClass()
+
+        assert cls.service_name is not None
+        cls.service_class = cast(
+            type[_THostingService],
+            hosting_service_registry.get_hosting_service(cls.service_name))
+
+    def setUp(self) -> None:
+        """Set up the test case."""
+        super().setUp()
 
     @contextmanager
-    def setup_http_test(self, http_request_func=None, payload=None,
-                        headers=None, status_code=None, hosting_account=None,
-                        expected_http_calls=None):
+    def setup_http_test(
+        self,
+        http_request_func: (HttpRequestMethod | None) = None,
+        payload: (bytes | None) = None,
+        headers: (HTTPHeaders | None) = None,
+        status_code: (int | None) = None,
+        hosting_account: (HostingServiceAccount | None) = None,
+        expected_http_calls: (int | None) = None,
+    ) -> Generator[HttpTestContext[_THostingService], Any, None]:
         """Set up state for HTTP-related tests.
 
         This takes the hard work out of testing hosting service functionality
@@ -306,7 +389,7 @@
             if payload is not None:
                 raise ValueError(
                     'http_request_func and payload cannot both be provided')
-            elif payload is not None:
+            elif status_code is not None:
                 raise ValueError(
                     'http_request_func and status_code cannot both be '
                     'provided')
@@ -329,7 +412,7 @@
                      client_cls.http_request,
                      client_cls.open_http_request):
             if hasattr(func, 'unspy'):
-                func.unspy()
+                self.unspy(func)
 
         self.spy_on(client_cls.http_request,
                     owner=client_cls)
@@ -342,16 +425,24 @@
             hosting_account=hosting_account,
             http_request_func=client_cls.http_request)
 
-        def _build_http_request(client, *args, **kwargs):
-            result = client.build_http_request.call_original(client, *args,
-                                                             **kwargs)
+        def _build_http_request(
+            client: HostingServiceClient,
+            *args,
+            **kwargs,
+        ) -> HostingServiceHTTPRequest:
+            spy = self.get_spy(client.build_http_request)
+            result = spy.call_original(client, *args, **kwargs)
             ctx.http_requests.append(result)
 
             return result
 
-        def _get_http_credentials(client, *args, **kwargs):
-            result = client.get_http_credentials.call_original(client, *args,
-                                                               **kwargs)
+        def _get_http_credentials(
+            client: HostingServiceClient,
+            *args,
+            **kwargs,
+        ) -> HostingServiceCredentials:
+            spy = self.get_spy(client.get_http_credentials)
+            result = spy.call_original(client, *args, **kwargs)
             ctx.http_credentials.append(result)
 
             return result
@@ -368,7 +459,10 @@
         if expected_http_calls is not None:
             self.assertEqual(len(ctx.http_calls), expected_http_calls)
 
-    def make_handler_for_paths(self, paths):
+    def make_handler_for_paths(
+        self,
+        paths: Mapping[str | None, HttpTestPath],
+    ) -> HttpRequestMethod:
         """Return an HTTP handler function for serving the supplied paths.
 
         This is meant to be passed to :py:meth:`setup_http_test`.
@@ -409,17 +503,20 @@
                })
         """
         # Validate the paths to make sure payloads are in the right format.
-        for path, path_info in paths.items():
+        for _path, path_info in paths.items():
             payload = path_info.get('payload')
 
             if payload is not None and not isinstance(payload, bytes):
                 raise TypeError('payload must be a byte string or None')
 
-        def _handler(client, request):
+        def _handler(
+            client: HostingServiceClient,
+            request: HostingServiceHTTPRequest,
+        ) -> HostingServiceHTTPResponse:
             url = request.url
             parts = urlparse(url)
 
-            full_path = '%s?%s' % (parts.path, parts.query)
+            full_path = f'{parts.path}?{parts.query}'
             path_info = paths.get(full_path)
 
             if path_info is None:
@@ -429,21 +526,22 @@
                     path_info = paths.get(None)
 
                     if path_info is None:
-                        self.fail('Unexpected path "%s"' % full_path)
+                        self.fail(f'Unexpected path "{full_path}"')
 
             status_code = path_info.get('status_code') or 200
             payload = path_info.get('payload') or b''
             headers = path_info.get('headers') or {}
 
             if status_code >= 400:
-                raise HTTPError(url, status_code, '', headers,
-                                io.BytesIO(payload))
+                raise HTTPError(
+                    url=url,
+                    code=status_code,
+                    msg='',
+                    hdrs=headers,  # type:ignore
+                    fp=io.BytesIO(payload))
             else:
-                if self.service_class is not None:
-                    http_response_cls = \
-                        self.service_class.client_class.http_response_cls
-                else:
-                    http_response_cls = HostingServiceHTTPResponse
+                http_response_cls = \
+                    self.service_class.client_class.http_response_cls
 
                 return http_response_cls(
                     request=request,
@@ -454,7 +552,27 @@
 
         return _handler
 
-    def dump_json(self, data, for_response=True):
+    @overload
+    def dump_json(
+        self,
+        data: JSONValue,
+        for_response: Literal[False],
+    ) -> str:
+        ...
+
+    @overload
+    def dump_json(
+        self,
+        data: JSONValue,
+        for_response: Literal[True] = True,
+    ) -> bytes:
+        ...
+
+    def dump_json(
+        self,
+        data: JSONValue,
+        for_response: bool = True,
+    ) -> bytes | str:
         """Dump JSON-compatible data to the proper string type.
 
         If ``for_response`` is ``True`` (the default), the resulting string
@@ -469,7 +587,7 @@
                 payload.
 
         Returns:
-            unicode or bytes:
+            str or bytes:
             The serialized string.
         """
         result = json.dumps(data)
@@ -479,11 +597,16 @@
 
         return result
 
-    def get_form(self, plan=None, fields={}, repository=None):
+    def get_form(
+        self,
+        plan: (str | None) = None,
+        fields: (dict[str, Any] | None) = None,
+        repository: (Repository | None) = None,
+    ) -> BaseHostingServiceRepositoryForm:
         """Return the configuration form for the hosting service.
 
         Args:
-            plan (unicode, optional):
+            plan (str, optional):
                 The hosting plan that the configuration form is for.
 
             fields (dict, optional):
@@ -497,22 +620,28 @@
             BaseHostingServiceRepositoryForm:
             The resulting hosting service form.
         """
+        assert self.service_class is not None
         form_cls = self.service_class.get_field(name='form', plan=plan)
+
         self.assertIsNotNone(form_cls)
 
-        form = form_cls(data=fields,
+        form = form_cls(data=fields or {},
                         repository=repository,
                         hosting_service_cls=self.service_class)
         self.assertTrue(form.is_valid())
 
         return form
 
-    def create_hosting_account(self, use_url=None, local_site=None,
-                               data=None):
+    def create_hosting_account(
+        self,
+        use_url: (bool | None) = None,
+        local_site: (LocalSite | None) = None,
+        data: (dict[str, Any] | None) = None,
+    ) -> HostingServiceAccount:
         """Create a hosting account to test with.
 
         Args:
-            use_url (unicode, optional):
+            use_url (bool, optional):
                 Whether the account should be attached to a given hosting URL,
                 for self-hosted services. If set, this will use
                 ``https://example.com``.
@@ -550,7 +679,10 @@
 
         return account
 
-    def create_repository(self, **kwargs):
+    def create_repository(
+        self,
+        **kwargs,
+    ) -> Repository:
         """Create a repository for a test.
 
         This wraps :py:meth:`TestCase.create_repository()
@@ -572,23 +704,29 @@
         if 'extra_data' not in kwargs:
             kwargs['extra_data'] = self.default_repository_extra_data.copy()
 
-        return super(HostingServiceTestCase, self).create_repository(**kwargs)
+        return super().create_repository(**kwargs)
 
-    def get_repository_fields(self, tool_name, fields, plan=None,
-                              with_url=None, hosting_account=None):
+    def get_repository_fields(
+        self,
+        tool_name: str,
+        fields: dict[str, Any],
+        plan: (str | None) = None,
+        with_url: (bool | None) = None,
+        hosting_account: (HostingServiceAccount | None) = None,
+    ) -> RepositoryFields:
         """Return populated fields for a repository.
 
         Args:
-            tool_name (unicode):
+            tool_name (str):
                 The name of the SCM Tool used for the repository.
 
             fields (dict):
                 A dictionary of fields for the hosting service form.
 
-            plan (unicode, optional):
+            plan (str, optional):
                 The optional hosting plan to use for the configuration.
 
-            with_url (unicode, optional):
+            with_url (bool, optional):
                 Whether the account should be attached to a given hosting URL,
                 for self-hosted services. If set, this will use
                 ``https://example.com``. This is ignored if ``hosting_account``
@@ -612,7 +750,10 @@
         service = hosting_account.service
         self.assertIsNotNone(service)
 
-        field_vars = form.clean().copy()
+        cleaned_data = form.clean()
+        assert cleaned_data is not None
+
+        field_vars = cleaned_data.copy()
         field_vars.update(hosting_account.data)
 
         return service.get_repository_fields(
diff --git a/reviewboard/hostingsvcs/tests/test_assembla.py b/reviewboard/hostingsvcs/tests/test_assembla.py
index 899522f2770bb76dbc49f35f2a2678c3b511ea36..2c6989da3baaf072846bbf7dd18e21e87c678946 100644
--- a/reviewboard/hostingsvcs/tests/test_assembla.py
+++ b/reviewboard/hostingsvcs/tests/test_assembla.py
@@ -1,11 +1,14 @@
 """Unit tests for the Assembla hosting service."""
 
+from __future__ import annotations
+
 from reviewboard.admin.server import get_hostname
+from reviewboard.hostingsvcs.assembla import Assembla
 from reviewboard.hostingsvcs.testing import HostingServiceTestCase
 from reviewboard.scmtools.crypto_utils import encrypt_password
 
 
-class AssemblaTestCase(HostingServiceTestCase):
+class AssemblaTestCase(HostingServiceTestCase[Assembla]):
     """Base class for Assembla test suites."""
 
     service_name = 'assembla'
@@ -19,7 +22,7 @@
 class AssemblaTests(AssemblaTestCase):
     """Unit tests for the Assembla hosting service."""
 
-    def test_service_support(self):
+    def test_service_support(self) -> None:
         """Testing Assembla service support capabilities"""
         self.assertTrue(self.service_class.needs_authorization)
         self.assertTrue(self.service_class.supports_bug_trackers)
@@ -27,7 +30,7 @@
         self.assertEqual(self.service_class.supported_scmtools,
                          ['Perforce', 'Subversion'])
 
-    def test_get_repository_fields_with_perforce(self):
+    def test_get_repository_fields_with_perforce(self) -> None:
         """Testing Assembla.get_repository_fields for Perforce"""
         self.assertEqual(
             self.get_repository_fields(
@@ -41,7 +44,7 @@
                 'encoding': 'utf8',
             })
 
-    def test_get_repository_fields_with_subversion(self):
+    def test_get_repository_fields_with_subversion(self) -> None:
         """Testing Assembla.get_repository_fields for Subversion"""
         self.assertEqual(
             self.get_repository_fields(
@@ -55,7 +58,7 @@
                 'path': 'https://subversion.assembla.com/svn/myproject/',
             })
 
-    def test_authorize(self):
+    def test_authorize(self) -> None:
         """Testing Assembla.authorize"""
         account = self.create_hosting_account(data={})
         service = account.service
@@ -68,7 +71,7 @@
         self.assertNotEqual(account.data['password'], 'abc123')
         self.assertTrue(service.is_authorized())
 
-    def test_check_repository_perforce(self):
+    def test_check_repository_perforce(self) -> None:
         """Testing Assembla.check_repository with Perforce"""
         try:
             account = self.create_hosting_account()
@@ -96,7 +99,7 @@
         except ImportError:
             self.skipTest('Perforce support is not installed')
 
-    def test_check_repository_subversion(self):
+    def test_check_repository_subversion(self) -> None:
         """Testing Assembla.check_repository with Subversion"""
         try:
             account = self.create_hosting_account()
@@ -127,12 +130,13 @@
 class AssemblaFormTests(AssemblaTestCase):
     """Unit tests for reviewboard.hostingsvcs.assembla.AssemblaForm."""
 
-    def setUp(self):
-        super(AssemblaFormTests, self).setUp()
+    def setUp(self) -> None:
+        """Set up the test case."""
+        super().setUp()
 
         self.account = self.create_hosting_account()
 
-    def test_save_form_perforce(self):
+    def test_save_form_perforce(self) -> None:
         """Testing AssemblaForm with Perforce"""
         try:
             repository = self.create_repository(hosting_account=self.account,
@@ -154,7 +158,7 @@
         except ImportError:
             self.skipTest('Perforce support is not installed')
 
-    def test_save_form_perforce_with_portfolio(self):
+    def test_save_form_perforce_with_portfolio(self) -> None:
         """Testing AssemblaForm with Perforce and Assembla portfolio IDs"""
         try:
             repository = self.create_repository(hosting_account=self.account,
@@ -179,7 +183,7 @@
         except ImportError:
             self.skipTest('Perforce support is not installed')
 
-    def test_save_form_subversion(self):
+    def test_save_form_subversion(self) -> None:
         """Testing AssemblaForm with Subversion"""
         try:
             repository = self.create_repository(
diff --git a/reviewboard/hostingsvcs/tests/test_beanstalk.py b/reviewboard/hostingsvcs/tests/test_beanstalk.py
index cb4b087771fc69c76ee0a7e6942824f9abac09fe..84581292277d29c9c184226ef833a967a942235e 100644
--- a/reviewboard/hostingsvcs/tests/test_beanstalk.py
+++ b/reviewboard/hostingsvcs/tests/test_beanstalk.py
@@ -1,11 +1,14 @@
 """Unit tests for the Beanstalk hosting service."""
 
+from __future__ import annotations
+
+from reviewboard.hostingsvcs.beanstalk import Beanstalk
 from reviewboard.hostingsvcs.testing import HostingServiceTestCase
 from reviewboard.scmtools.crypto_utils import (decrypt_password,
                                                encrypt_password)
 
 
-class BeanstalkTests(HostingServiceTestCase):
+class BeanstalkTests(HostingServiceTestCase[Beanstalk]):
     """Unit tests for the Beanstalk hosting service."""
 
     service_name = 'beanstalk'
@@ -20,12 +23,12 @@
         'beanstalk_repo_name': 'myrepo',
     }
 
-    def test_service_support(self):
+    def test_service_support(self) -> None:
         """Testing Beanstalk service support capabilities"""
         self.assertFalse(self.service_class.supports_bug_trackers)
         self.assertTrue(self.service_class.supports_repositories)
 
-    def test_get_repository_fields_for_git(self):
+    def test_get_repository_fields_for_git(self) -> None:
         """Testing Beanstalk.get_repository_fields for Git"""
         self.assertEqual(
             self.get_repository_fields(
@@ -41,7 +44,7 @@
                                 'myrepo.git'),
             })
 
-    def test_get_repository_fields_for_subversion(self):
+    def test_get_repository_fields_for_subversion(self) -> None:
         """Testing Beanstalk.get_repository_fields for Subversion"""
         self.assertEqual(
             self.get_repository_fields(
@@ -55,7 +58,7 @@
                 'path': 'https://mydomain.svn.beanstalkapp.com/myrepo/',
             })
 
-    def test_authorize(self):
+    def test_authorize(self) -> None:
         """Testing Beanstalk.authorize"""
         account = self.create_hosting_account(data={})
         service = account.service
@@ -69,7 +72,7 @@
         self.assertEqual(decrypt_password(account.data['password']), 'abc123')
         self.assertTrue(service.is_authorized())
 
-    def test_check_repository(self):
+    def test_check_repository(self) -> None:
         """Testing Beanstalk.check_repository"""
         with self.setup_http_test(payload=b'{}',
                                   expected_http_calls=1) as ctx:
@@ -81,7 +84,7 @@
             url=('https://mydomain.beanstalkapp.com/api/repositories/'
                  'myrepo.json'))
 
-    def test_get_file_with_svn_and_base_commit_id(self):
+    def test_get_file_with_svn_and_base_commit_id(self) -> None:
         """Testing Beanstalk.get_file with Subversion and base commit ID"""
         self._test_get_file(
             tool_name='Subversion',
@@ -89,7 +92,7 @@
             base_commit_id='456',
             expected_revision='123')
 
-    def test_get_file_with_svn_and_revision(self):
+    def test_get_file_with_svn_and_revision(self) -> None:
         """Testing Beanstalk.get_file with Subversion and revision"""
         self._test_get_file(
             tool_name='Subversion',
@@ -97,7 +100,7 @@
             base_commit_id=None,
             expected_revision='123')
 
-    def test_get_file_with_git_and_base_commit_id(self):
+    def test_get_file_with_git_and_base_commit_id(self) -> None:
         """Testing Beanstalk.get_file with Git and base commit ID"""
         self._test_get_file(
             tool_name='Git',
@@ -105,7 +108,7 @@
             base_commit_id='456',
             expected_revision='123')
 
-    def test_get_file_with_git_and_revision(self):
+    def test_get_file_with_git_and_revision(self) -> None:
         """Testing Beanstalk.get_file with Git and revision"""
         self._test_get_file(
             tool_name='Git',
@@ -113,7 +116,7 @@
             base_commit_id=None,
             expected_revision='123')
 
-    def test_get_file_with_svn_and_keywords_collapsed(self):
+    def test_get_file_with_svn_and_keywords_collapsed(self) -> None:
         """Testing Beanstalk.get_file with Subversion and keywords in file
         collapsed
         """
@@ -134,7 +137,7 @@
                 b'$Revision::       $\n'
             ))
 
-    def test_get_file_exists_with_svn_and_base_commit_id(self):
+    def test_get_file_exists_with_svn_and_base_commit_id(self) -> None:
         """Testing Beanstalk.get_file_exists with Subversion and base commit ID
         """
         self._test_get_file_exists(
@@ -144,7 +147,7 @@
             expected_revision='123',
             expected_found=True)
 
-    def test_get_file_exists_with_svn_and_revision(self):
+    def test_get_file_exists_with_svn_and_revision(self) -> None:
         """Testing Beanstalk.get_file_exists with Subversion and revision"""
         self._test_get_file_exists(
             tool_name='Subversion',
@@ -153,7 +156,7 @@
             expected_revision='123',
             expected_found=True)
 
-    def test_get_file_exists_with_git_and_base_commit_id(self):
+    def test_get_file_exists_with_git_and_base_commit_id(self) -> None:
         """Testing Beanstalk.get_file_exists with Git and base commit ID"""
         self._test_get_file_exists(
             tool_name='Git',
@@ -162,7 +165,7 @@
             expected_revision='456',
             expected_found=True)
 
-    def test_get_file_exists_with_git_and_revision(self):
+    def test_get_file_exists_with_git_and_revision(self) -> None:
         """Testing Beanstalk.get_file_exists with Git and revision"""
         self._test_get_file_exists(
             tool_name='Git',
@@ -171,7 +174,7 @@
             expected_revision='123',
             expected_found=True)
 
-    def test_normalize_patch_with_svn_and_expanded_keywords(self):
+    def test_normalize_patch_with_svn_and_expanded_keywords(self) -> None:
         """Testing Beanstalk.normalize_patch with Subversion and expanded
         keywords
         """
@@ -225,7 +228,7 @@
 
         self.assertTrue(repository.hosting_service.normalize_patch.called)
 
-    def test_normalize_patch_with_svn_and_no_expanded_keywords(self):
+    def test_normalize_patch_with_svn_and_no_expanded_keywords(self) -> None:
         """Testing Beanstalk.normalize_patch with Subversion and no expanded
         keywords
         """
@@ -279,24 +282,39 @@
 
         self.assertTrue(repository.hosting_service.normalize_patch.called)
 
-    def _test_get_file(self, tool_name, revision, base_commit_id,
-                       expected_revision, file_contents=b'My data',
-                       expected_file_contents=b'My data',
-                       expected_fetch_keywords=False):
+    def _test_get_file(
+        self,
+        tool_name: str,
+        revision: str,
+        base_commit_id: str,
+        expected_revision: str,
+        file_contents: bytes = b'My data',
+        expected_file_contents: bytes = b'My data',
+        expected_fetch_keywords: bool = False,
+    ) -> None:
         """Test file fetching.
 
         Args:
-            tool_name (unicode):
+            tool_name (str):
                 The name of the SCM Tool to test with.
 
-            revision (unicode, optional):
+            revision (str):
                 The revision to check.
 
-            base_commit_id (unicode, optional):
+            base_commit_id (str):
                 The base commit to fetch against.
 
-            expected_revision (unicode, optional):
+            expected_revision (str):
                 The revision expected in the payload.
+
+            file_contents (bytes, optional):
+                The file contents to return.
+
+            expected_file_contents (bytes, optional):
+                The expected result for the file contents.
+
+            expected_fetch_keywords (bool, optional):
+                Whether to expect keywords to be fetched.
         """
         url_prefix = 'https://mydomain.beanstalkapp.com'
 
@@ -351,24 +369,30 @@
         if expected_fetch_keywords:
             ctx.assertHTTPCall(1, url='%s%s' % (url_prefix, svn_props_url))
 
-    def _test_get_file_exists(self, tool_name, revision, base_commit_id,
-                              expected_revision, expected_found):
+    def _test_get_file_exists(
+        self,
+        tool_name: str,
+        revision: str,
+        base_commit_id: str,
+        expected_revision: str,
+        expected_found: bool,
+    ) -> None:
         """Test file existence checks.
 
         Args:
-            tool_name (unicode):
+            tool_name (str):
                 The name of the SCM Tool to test with.
 
-            revision (unicode, optional):
+            revision (str, optional):
                 The revision to check.
 
-            base_commit_id (unicode, optional):
+            base_commit_id (str):
                 The base commit to fetch against.
 
-            expected_revision (unicode, optional):
+            expected_revision (str):
                 The revision expected in the payload.
 
-            expected_found (bool, optional):
+            expected_found (bool):
                 Whether a truthy response should be expected.
         """
         if expected_found:
diff --git a/reviewboard/hostingsvcs/tests/test_bitbucket.py b/reviewboard/hostingsvcs/tests/test_bitbucket.py
index 954539995be6aaaee9802624a59b95d4effc169b..10f1307b370ca6a8dcfdac37315937d4c5e40d43 100644
--- a/reviewboard/hostingsvcs/tests/test_bitbucket.py
+++ b/reviewboard/hostingsvcs/tests/test_bitbucket.py
@@ -1,13 +1,16 @@
 """Unit tests for the Bitbucket hosting service."""
 
+from __future__ import annotations
+
 import json
+from typing import TYPE_CHECKING
 
 from django.contrib.auth.models import User
 from django.test.client import RequestFactory
 from django.utils.safestring import SafeText
 from djblets.testing.decorators import add_fixtures
 
-from reviewboard.hostingsvcs.bitbucket import BitbucketAuthForm
+from reviewboard.hostingsvcs.bitbucket import Bitbucket, BitbucketAuthForm
 from reviewboard.hostingsvcs.errors import (AuthorizationError,
                                             RepositoryError)
 from reviewboard.hostingsvcs.hook_utils import logger
@@ -20,8 +23,11 @@
 from reviewboard.site.models import LocalSite
 from reviewboard.site.urlresolvers import local_site_reverse
 
-
-class BitbucketTestCase(HostingServiceTestCase):
+if TYPE_CHECKING:
+    from django.test.client import _MonkeyPatchedWSGIResponse
+
+
+class BitbucketTestCase(HostingServiceTestCase[Bitbucket]):
     """Base class for Bitbucket test suites."""
 
     service_name = 'bitbucket'
@@ -39,12 +45,12 @@
 class BitbucketTests(BitbucketTestCase):
     """Unit tests for the Bitbucket hosting service."""
 
-    def test_service_support(self):
+    def test_service_support(self) -> None:
         """Testing Bitbucket service support capabilities"""
         self.assertTrue(self.service_class.supports_bug_trackers)
         self.assertTrue(self.service_class.supports_repositories)
 
-    def test_get_repository_fields_with_git_and_personal_plan(self):
+    def test_get_repository_fields_with_git_and_personal_plan(self) -> None:
         """Testing Bitbucket.get_repository_fields for Git and plan=personal"""
         self.assertEqual(
             self.get_repository_fields(
@@ -60,7 +66,7 @@
                                 'myrepo.git'),
             })
 
-    def test_get_repository_fields_with_git_and_team_plan(self):
+    def test_get_repository_fields_with_git_and_team_plan(self) -> None:
         """Testing Bitbucket.get_repository_fields for Git and plan=team"""
         self.assertEqual(
             self.get_repository_fields(
@@ -77,7 +83,7 @@
                                 'myrepo.git'),
             })
 
-    def test_get_repository_fields_with_git_and_other_user_plan(self):
+    def test_get_repository_fields_with_git_and_other_user_plan(self) -> None:
         """Testing Bitbucket.get_repository_fields for Git and plan=other-user
         """
         self.assertEqual(
@@ -95,7 +101,7 @@
                                 'myrepo.git'),
             })
 
-    def test_get_bug_tracker_field_with_personal_plan(self):
+    def test_get_bug_tracker_field_with_personal_plan(self) -> None:
         """Testing Bitbucket.get_bug_tracker_field with plan=personal"""
         self.assertTrue(self.service_class.get_bug_tracker_requires_username(
             plan='personal'))
@@ -108,7 +114,7 @@
                 }),
             'https://bitbucket.org/myuser/myrepo/issue/%s/')
 
-    def test_get_bug_tracker_field_with_team_plan(self):
+    def test_get_bug_tracker_field_with_team_plan(self) -> None:
         """Testing Bitbucket.get_bug_tracker_field with plan=team"""
         self.assertFalse(self.service_class.get_bug_tracker_requires_username(
             plan='team'))
@@ -121,7 +127,7 @@
                 }),
             'https://bitbucket.org/myteam/myrepo/issue/%s/')
 
-    def test_get_bug_tracker_field_with_other_user_plan(self):
+    def test_get_bug_tracker_field_with_other_user_plan(self) -> None:
         """Testing Bitbucket.get_bug_tracker_field with plan=other-user"""
         self.assertFalse(self.service_class.get_bug_tracker_requires_username(
             plan='other-user'))
@@ -134,7 +140,7 @@
                 }),
             'https://bitbucket.org/someuser/myrepo/issue/%s/')
 
-    def test_get_repository_hook_instructions(self):
+    def test_get_repository_hook_instructions(self) -> None:
         """Testing BitBucket.get_repository_hook_instructions"""
         account = self.create_hosting_account()
         repository = self.create_repository(hosting_account=account)
@@ -159,7 +165,7 @@
         self.assertIn('Review Board supports closing', content)
         self.assertIn('<code>Review Board</code>', content)
 
-    def test_check_repository_with_personal_plan(self):
+    def test_check_repository_with_personal_plan(self) -> None:
         """Testing Bitbucket.check_repository with plan=personal"""
         with self.setup_http_test(payload=b'{"scm": "git"}',
                                   expected_http_calls=1) as ctx:
@@ -172,7 +178,7 @@
             url=('https://bitbucket.org/api/2.0/repositories/myuser/myrepo'
                  '?fields=scm'))
 
-    def test_check_repository_with_team_plan(self):
+    def test_check_repository_with_team_plan(self) -> None:
         """Testing Bitbucket.check_repository with plan=team"""
         with self.setup_http_test(payload=b'{"scm": "git"}',
                                   expected_http_calls=1) as ctx:
@@ -186,7 +192,7 @@
             url=('https://bitbucket.org/api/2.0/repositories/myteam/myrepo'
                  '?fields=scm'))
 
-    def test_check_repository_with_other_user_plan(self):
+    def test_check_repository_with_other_user_plan(self) -> None:
         """Testing Bitbucket.check_repository with plan=other-user"""
         with self.setup_http_test(payload=b'{"scm": "git"}',
                                   expected_http_calls=1) as ctx:
@@ -201,7 +207,7 @@
             url=('https://bitbucket.org/api/2.0/repositories/someuser/myrepo'
                  '?fields=scm'))
 
-    def test_check_repository_with_slash(self):
+    def test_check_repository_with_slash(self) -> None:
         """Testing Bitbucket.check_repository with /"""
         expected_message = \
             'Please specify just the name of the repository, not a path.'
@@ -213,7 +219,7 @@
                     bitbucket_team_repo_name='myteam/myrepo',
                     plan='team')
 
-    def test_check_repository_with_dot_git(self):
+    def test_check_repository_with_dot_git(self) -> None:
         """Testing Bitbucket.check_repository with .git"""
         expected_message = \
             'Please specify just the name of the repository without ".git".'
@@ -225,7 +231,7 @@
                     bitbucket_team_repo_name='myrepo.git',
                     plan='team')
 
-    def test_check_repository_with_type_mismatch(self):
+    def test_check_repository_with_type_mismatch(self) -> None:
         """Testing Bitbucket.check_repository with type mismatch"""
         error_message = (
             'The Bitbucket repository being configured does not match the '
@@ -247,7 +253,7 @@
             url=('https://bitbucket.org/api/2.0/repositories/myteam/myrepo'
                  '?fields=scm'))
 
-    def test_authorize(self):
+    def test_authorize(self) -> None:
         """Testing Bitbucket.authorize"""
         hosting_account = self.create_hosting_account(data={})
 
@@ -270,7 +276,7 @@
             username='myuser',
             password='abc123')
 
-    def test_authorize_with_bad_credentials(self):
+    def test_authorize_with_bad_credentials(self) -> None:
         """Testing Bitbucket.authorize with bad credentials"""
         hosting_account = self.create_hosting_account(data={})
         expected_message = (
@@ -298,7 +304,7 @@
             username='myuser',
             password='abc123')
 
-    def test_get_file_with_git_and_base_commit_id(self):
+    def test_get_file_with_git_and_base_commit_id(self) -> None:
         """Testing Bitbucket.get_file with Git and base commit ID"""
         self._test_get_file(
             tool_name='Git',
@@ -306,7 +312,7 @@
             base_commit_id='456',
             expected_revision='456')
 
-    def test_get_file_with_git_and_revision(self):
+    def test_get_file_with_git_and_revision(self) -> None:
         """Testing Bitbucket.get_file with Git and revision"""
         with self.assertRaises(FileNotFoundError):
             self._test_get_file(tool_name='Git',
@@ -314,7 +320,7 @@
                                 base_commit_id=None,
                                 expected_revision='123')
 
-    def test_get_file_exists_with_git_and_base_commit_id(self):
+    def test_get_file_exists_with_git_and_base_commit_id(self) -> None:
         """Testing Bitbucket.get_file_exists with Git and base commit ID"""
         self._test_get_file_exists(
             tool_name='Git',
@@ -323,7 +329,7 @@
             expected_revision='456',
             expected_found=True)
 
-    def test_get_file_exists_with_git_and_revision(self):
+    def test_get_file_exists_with_git_and_revision(self) -> None:
         """Testing Bitbucket.get_file_exists with Git and revision"""
         self._test_get_file_exists(
             tool_name='Git',
@@ -333,7 +339,7 @@
             expected_found=False,
             expected_http_called=False)
 
-    def test_get_file_exists_with_git_and_404(self):
+    def test_get_file_exists_with_git_and_404(self) -> None:
         """Testing BitBucket.get_file_exists with Git and a 404 error"""
         self._test_get_file_exists(
             tool_name='Git',
@@ -342,7 +348,7 @@
             expected_revision='456',
             expected_found=False)
 
-    def test_get_branches(self):
+    def test_get_branches(self) -> None:
         """Testing Bitbucket.get_branches"""
         branches_api_response_1 = self.dump_json({
             'next': ('https://bitbucket.org/api/2.0/repositories/myuser/'
@@ -441,7 +447,7 @@
                        commit='d286691517e6325fea5c7a21d5e44568f7d33647'),
             ])
 
-    def test_get_commits(self):
+    def test_get_commits(self) -> None:
         """Testing Bitbucket.get_commits"""
         payload = self.dump_json({
             'size': 2,
@@ -506,7 +512,7 @@
         for commit in commits:
             self.assertIsNone(commit.diff)
 
-    def test_get_commits_with_start(self):
+    def test_get_commits_with_start(self) -> None:
         """Testing Bitbucket.get_commits with start="""
         payload = self.dump_json({
             'size': 2,
@@ -573,7 +579,7 @@
         for commit in commits:
             self.assertIsNone(commit.diff)
 
-    def test_get_commits_with_branch(self):
+    def test_get_commits_with_branch(self) -> None:
         """Testing Bitbucket.get_commits with branch="""
         payload = self.dump_json({
             'size': 2,
@@ -639,7 +645,7 @@
         for commit in commits:
             self.assertIsNone(commit.diff)
 
-    def test_get_commits_with_start_and_branch(self):
+    def test_get_commits_with_start_and_branch(self) -> None:
         """Testing Bitbucket.get_commits with start= and branch="""
         payload = self.dump_json({
             'size': 2,
@@ -707,7 +713,7 @@
         for commit in commits:
             self.assertIsNone(commit.diff)
 
-    def test_get_change(self):
+    def test_get_change(self) -> None:
         """Testing BitBucket.get_change"""
         commit_sha = '1c44b461cebe5874a857c51a4a13a849a4d1e52d'
         parent_sha = '44568f7d33647d286691517e6325fea5c7a21d5e'
@@ -754,21 +760,26 @@
                    parent=parent_sha))
         self.assertEqual(commit.diff, b'This is a test \xc7.\n')
 
-    def _test_get_file(self, tool_name, revision, base_commit_id,
-                       expected_revision):
+    def _test_get_file(
+        self,
+        tool_name: str,
+        revision: str,
+        base_commit_id: str,
+        expected_revision: str,
+    ) -> None:
         """Test file fetching.
 
         Args:
-            tool_name (unicode):
+            tool_name (str):
                 The name of the SCM Tool to test with.
 
-            revision (unicode, optional):
+            revision (str):
                 The revision to check.
 
-            base_commit_id (unicode, optional):
+            base_commit_id (str):
                 The base commit to fetch against.
 
-            expected_revision (unicode, optional):
+            expected_revision (str):
                 The revision expected in the payload.
         """
         with self.setup_http_test(payload=b'My data',
@@ -788,25 +799,31 @@
         self.assertIsInstance(result, bytes)
         self.assertEqual(result, b'My data')
 
-    def _test_get_file_exists(self, tool_name, revision, base_commit_id,
-                              expected_revision, expected_found,
-                              expected_http_called=True):
+    def _test_get_file_exists(
+        self,
+        tool_name: str,
+        revision: str,
+        base_commit_id: str,
+        expected_revision: str,
+        expected_found: bool,
+        expected_http_called: bool = True,
+    ) -> None:
         """Test file existence checks.
 
         Args:
-            tool_name (unicode):
+            tool_name (str):
                 The name of the SCM Tool to test with.
 
-            revision (unicode, optional):
+            revision (str):
                 The revision to check.
 
-            base_commit_id (unicode, optional):
+            base_commit_id (str):
                 The base commit to fetch against.
 
-            expected_revision (unicode, optional):
+            expected_revision (str):
                 The revision expected in the payload.
 
-            expected_found (bool, optional):
+            expected_found (bool):
                 Whether a truthy response should be expected.
 
             expected_http_called (bool, optional):
@@ -847,7 +864,7 @@
 class BitbucketAuthFormTests(BitbucketTestCase):
     """Unit tests for BitbucketAuthForm."""
 
-    def test_clean_hosting_account_username_with_username(self):
+    def test_clean_hosting_account_username_with_username(self) -> None:
         """Testing BitbucketAuthForm.clean_hosting_account_username with
         username
         """
@@ -860,7 +877,7 @@
 
         self.assertTrue(form.is_valid())
 
-    def test_clean_hosting_account_username_with_email(self):
+    def test_clean_hosting_account_username_with_email(self) -> None:
         """Testing BitbucketAuthForm.clean_hosting_account_username with
         e-mail address
         """
@@ -886,17 +903,17 @@
     COMMITS_URL = ('/api/2.0/repositories/test/test/commits'
                    '?exclude=abc123&include=def123')
 
-    def test_close_submitted_hook(self):
+    def test_close_submitted_hook(self) -> None:
         """Testing BitBucket close_submitted hook"""
         self._test_post_commit_hook()
 
     @add_fixtures(['test_site'])
-    def test_close_submitted_hook_with_local_site(self):
+    def test_close_submitted_hook_with_local_site(self) -> None:
         """Testing BitBucket close_submitted hook with a Local Site"""
         self._test_post_commit_hook(
             LocalSite.objects.get(name=self.local_site_name))
 
-    def test_close_submitted_hook_with_truncated_commits(self):
+    def test_close_submitted_hook_with_truncated_commits(self) -> None:
         """Testing BitBucket close_submitted hook with truncated list of
         commits
         """
@@ -988,7 +1005,7 @@
         changedesc = review_request2.changedescs.get()
         self.assertEqual(changedesc.text, 'Pushed to master (9fad897)')
 
-    def test_close_submitted_hook_with_truncated_commits_limits(self):
+    def test_close_submitted_hook_with_truncated_commits_limits(self) -> None:
         """Testing BitBucket close_submitted hook with truncated list of
         commits obeys limits
         """
@@ -1060,7 +1077,7 @@
                          review_request1.PENDING_REVIEW)
         self.assertEqual(review_request2.changedescs.count(), 0)
 
-    def test_close_submitted_hook_with_truncated_and_auth_error(self):
+    def test_close_submitted_hook_with_truncated_and_auth_error(self) -> None:
         """Testing BitBucket close_submitted hook with truncated list of
         commits and authentication error talking to Bitbucket
         """
@@ -1118,7 +1135,7 @@
                          review_request1.PENDING_REVIEW)
         self.assertEqual(review_request2.changedescs.count(), 0)
 
-    def test_close_submitted_hook_with_too_many_login_failures(self):
+    def test_close_submitted_hook_with_too_many_login_failures(self) -> None:
         """Testing BitBucket close_submitted hook with truncated list of
         commits and too many login failures talking to Bitbucket
         """
@@ -1184,7 +1201,7 @@
                          review_request1.PENDING_REVIEW)
         self.assertEqual(review_request2.changedescs.count(), 0)
 
-    def test_close_submitted_hook_with_invalid_repo(self):
+    def test_close_submitted_hook_with_invalid_repo(self) -> None:
         """Testing BitBucket close_submitted hook with invalid repository"""
         repository = self.create_repository()
 
@@ -1212,7 +1229,7 @@
         self.assertEqual(review_request.changedescs.count(), 0)
 
     @add_fixtures(['test_site'])
-    def test_close_submitted_hook_with_invalid_site(self):
+    def test_close_submitted_hook_with_invalid_site(self) -> None:
         """Testing BitBucket close_submitted hook with invalid Local Site"""
         local_site = LocalSite.objects.get(name=self.local_site_name)
         account = self.create_hosting_account(local_site=local_site)
@@ -1245,7 +1262,7 @@
         self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
         self.assertEqual(review_request.changedescs.count(), 0)
 
-    def test_close_submitted_hook_with_invalid_service_id(self):
+    def test_close_submitted_hook_with_invalid_service_id(self) -> None:
         """Testing BitBucket close_submitted hook with invalid hosting
         service ID
         """
@@ -1279,7 +1296,7 @@
         self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
         self.assertEqual(review_request.changedescs.count(), 0)
 
-    def test_close_submitted_hook_with_invalid_review_request(self):
+    def test_close_submitted_hook_with_invalid_review_request(self) -> None:
         """Testing BitBucket close_submitted hook with invalid review request
         """
         self.spy_on(logger.error)
@@ -1314,7 +1331,10 @@
             'close_all_review_requests: Review request #%s does not exist.',
             9999))
 
-    def _test_post_commit_hook(self, local_site=None):
+    def _test_post_commit_hook(
+        self,
+        local_site: (LocalSite | None) = None,
+    ) -> None:
         """Testing posting to a commit hook.
 
         This will simulate pushing a commit and posting the resulting webhook
@@ -1355,15 +1375,19 @@
         changedesc = review_request.changedescs.get()
         self.assertEqual(changedesc.text, 'Pushed to master (1c44b46)')
 
-    def _post_commit_hook_payload(self, post_url, review_request_url,
-                                  truncated=False):
+    def _post_commit_hook_payload(
+        self,
+        post_url: str,
+        review_request_url: str,
+        truncated: bool = False,
+    ) -> _MonkeyPatchedWSGIResponse:
         """Post a payload for a hook for testing.
 
         Args:
-            post_url (unicode):
+            post_url (str):
                 The URL to post to.
 
-            review_request_url (unicode):
+            review_request_url (str):
                 The URL of the review request being represented in the
                 payload.
 
diff --git a/reviewboard/hostingsvcs/tests/test_bugzilla.py b/reviewboard/hostingsvcs/tests/test_bugzilla.py
index 0266910da5c112e38bd2b54edb4b535542dd8c01..afc63c46a54b2f3eae75495914dc2b496d144586 100644
--- a/reviewboard/hostingsvcs/tests/test_bugzilla.py
+++ b/reviewboard/hostingsvcs/tests/test_bugzilla.py
@@ -1,20 +1,23 @@
 """Unit tests for the Bugzilla hosting service."""
 
+from __future__ import annotations
+
+from reviewboard.hostingsvcs.bugzilla import Bugzilla
 from reviewboard.hostingsvcs.testing import HostingServiceTestCase
 
 
-class BugzillaTests(HostingServiceTestCase):
+class BugzillaTests(HostingServiceTestCase[Bugzilla]):
     """Unit tests for the Bugzilla hosting service."""
 
     service_name = 'bugzilla'
     fixtures = ['test_scmtools']
 
-    def test_service_support(self):
+    def test_service_support(self) -> None:
         """Testing Bugzilla service support capabilities"""
         self.assertTrue(self.service_class.supports_bug_trackers)
         self.assertFalse(self.service_class.supports_repositories)
 
-    def test_bug_tracker_field(self):
+    def test_bug_tracker_field(self) -> None:
         """Testing Bugzilla.get_bug_tracker_field"""
         self.assertFalse(
             self.service_class.get_bug_tracker_requires_username())
diff --git a/reviewboard/hostingsvcs/tests/test_codebasehq.py b/reviewboard/hostingsvcs/tests/test_codebasehq.py
index 50705c7758a791e058514cfff145d46c30ceed4b..36b3b43a18529f75546c3f01de4bfc4c96d180a8 100644
--- a/reviewboard/hostingsvcs/tests/test_codebasehq.py
+++ b/reviewboard/hostingsvcs/tests/test_codebasehq.py
@@ -1,5 +1,8 @@
 """Unit tests for the Codebase HQ hosting service."""
 
+from __future__ import annotations
+
+from reviewboard.hostingsvcs.codebasehq import CodebaseHQ
 from reviewboard.hostingsvcs.errors import RepositoryError
 from reviewboard.hostingsvcs.testing import HostingServiceTestCase
 from reviewboard.scmtools.crypto_utils import (decrypt_password,
@@ -7,7 +10,7 @@
 from reviewboard.scmtools.errors import FileNotFoundError
 
 
-class CodebaseHQTests(HostingServiceTestCase):
+class CodebaseHQTests(HostingServiceTestCase[CodebaseHQ]):
     """Unit tests for the Codebase HQ hosting service."""
 
     service_name = 'codebasehq'
@@ -24,13 +27,13 @@
         'codebasehq_repo_name': 'myrepo',
     }
 
-    def test_service_support(self):
+    def test_service_support(self) -> None:
         """Testing CodebaseHQ service support capabilities"""
         self.assertFalse(self.service_class.supports_post_commit)
         self.assertTrue(self.service_class.supports_bug_trackers)
         self.assertTrue(self.service_class.supports_repositories)
 
-    def test_get_repository_fields_for_git(self):
+    def test_get_repository_fields_for_git(self) -> None:
         """Testing CodebaseHQ.get_repository_fields for Git"""
         self.assertEqual(
             self.get_repository_fields(
@@ -44,7 +47,7 @@
                 'path': 'git@codebasehq.com:mydomain/myproj/myrepo.git',
             })
 
-    def test_get_repository_fields_for_mercurial(self):
+    def test_get_repository_fields_for_mercurial(self) -> None:
         """Testing CodebaseHQ.get_repository_fields for Mercurial"""
         self.assertEqual(
             self.get_repository_fields(
@@ -59,7 +62,7 @@
                          'myproj/repositories/myrepo/'),
             })
 
-    def test_get_repository_fields_for_subversion(self):
+    def test_get_repository_fields_for_subversion(self) -> None:
         """Testing CodebaseHQ.get_repository_fields for Subversion"""
         self.assertEqual(
             self.get_repository_fields(
@@ -73,7 +76,7 @@
                 'path': 'https://mydomain.codebasehq.com/myproj/myrepo.svn',
             })
 
-    def test_get_bug_tracker_field(self):
+    def test_get_bug_tracker_field(self) -> None:
         """Testing CodebaseHQ.get_bug_tracker_field"""
         self.assertEqual(
             self.service_class.get_bug_tracker_field(
@@ -84,22 +87,22 @@
                 }),
             'https://mydomain.codebasehq.com/projects/myproj/tickets/%s')
 
-    def test_check_repository_git(self):
+    def test_check_repository_git(self) -> None:
         """Testing CodebaseHQ.check_repository for Git"""
         self._test_check_repository(codebase_scm_type='git',
                                     tool_name='Git')
 
-    def test_check_repository_mercurial(self):
+    def test_check_repository_mercurial(self) -> None:
         """Testing CodebaseHQ.check_repository for Mercurial"""
         self._test_check_repository(codebase_scm_type='hg',
                                     tool_name='Mercurial')
 
-    def test_check_repository_subversion(self):
+    def test_check_repository_subversion(self) -> None:
         """Testing CodebaseHQ.check_repository for Subversion"""
         self._test_check_repository(codebase_scm_type='svn',
                                     tool_name='Subversion')
 
-    def test_check_repository_with_mismatching_type(self):
+    def test_check_repository_with_mismatching_type(self) -> None:
         """Testing CodebaseHQ.check_repository with mismatching repository type
         """
         self._test_check_repository(codebase_scm_type='svn',
@@ -107,7 +110,7 @@
                                     expect_success=False,
                                     expected_name_for_error='Subversion')
 
-    def test_authorize(self):
+    def test_authorize(self) -> None:
         """Testing CodebaseHQ.authorize"""
         hosting_account = self.create_hosting_account(data={})
 
@@ -142,86 +145,89 @@
                          'mypass')
         self.assertTrue(ctx.service.is_authorized())
 
-    def test_get_file_with_mercurial(self):
+    def test_get_file_with_mercurial(self) -> None:
         """Testing CodebaseHQ.get_file with Mercurial"""
         self._test_get_file(tool_name='Mercurial')
 
-    def test_get_file_with_mercurial_not_found(self):
+    def test_get_file_with_mercurial_not_found(self) -> None:
         """Testing CodebaseHQ.get_file with Mercurial with file not found"""
         self._test_get_file(tool_name='Mercurial', file_exists=False)
 
-    def test_get_file_with_git(self):
+    def test_get_file_with_git(self) -> None:
         """Testing CodebaseHQ.get_file with Git"""
         self._test_get_file(tool_name='Git', expect_git_blob_url=True)
 
-    def test_get_file_with_git_not_found(self):
+    def test_get_file_with_git_not_found(self) -> None:
         """Testing CodebaseHQ.get_file with Git with file not found"""
         self._test_get_file(tool_name='Git', expect_git_blob_url=True,
                             file_exists=False)
 
-    def test_get_file_with_subversion(self):
+    def test_get_file_with_subversion(self) -> None:
         """Testing CodebaseHQ.get_file with Subversion"""
         self._test_get_file(tool_name='Subversion')
 
-    def test_get_file_with_subversion_not_found(self):
+    def test_get_file_with_subversion_not_found(self) -> None:
         """Testing CodebaseHQ.get_file with Subversion with file not found"""
         self._test_get_file(tool_name='Subversion', file_exists=False)
 
-    def test_get_file_exists_with_mercurial(self):
+    def test_get_file_exists_with_mercurial(self) -> None:
         """Testing CodebaseHQ.get_file_exists with Mercurial"""
         self._test_get_file_exists(tool_name='Mercurial')
 
-    def test_get_file_exists_with_mercurial_not_found(self):
+    def test_get_file_exists_with_mercurial_not_found(self) -> None:
         """Testing CodebaseHQ.get_file_exists with Mercurial with file not
         found
         """
         self._test_get_file_exists(tool_name='Mercurial', file_exists=False)
 
-    def test_get_file_exists_with_git(self):
+    def test_get_file_exists_with_git(self) -> None:
         """Testing CodebaseHQ.get_file_exists with Git"""
         self._test_get_file_exists(tool_name='Git', expect_git_blob_url=True)
 
-    def test_get_file_exists_with_git_not_found(self):
+    def test_get_file_exists_with_git_not_found(self) -> None:
         """Testing CodebaseHQ.get_file_exists with Git with file not found"""
         self._test_get_file_exists(tool_name='Git', expect_git_blob_url=True,
                                    file_exists=False)
 
-    def test_get_file_exists_with_subversion(self):
+    def test_get_file_exists_with_subversion(self) -> None:
         """Testing CodebaseHQ.get_file_exists with Subversion"""
         self._test_get_file_exists(tool_name='Subversion')
 
-    def test_get_file_exists_with_subversion_not_found(self):
+    def test_get_file_exists_with_subversion_not_found(self) -> None:
         """Testing CodebaseHQ.get_file_exists with Subversion with file not
         found
         """
         self._test_get_file_exists(tool_name='Subversion', file_exists=False)
 
-    def _test_check_repository(self, codebase_scm_type, tool_name,
-                               expect_success=True,
-                               expected_name_for_error=None):
+    def _test_check_repository(
+        self,
+        codebase_scm_type: str,
+        tool_name: str,
+        expect_success: bool = True,
+        expected_name_for_error: (str | None) = None,
+    ) -> None:
         """Test repository checks.
 
         Args:
-            codebase_scm_type (unicode):
+            codebase_scm_type (str):
                 The name of the SCM type in the CodebaseHQ API to return in
                 payloads.
 
-            tool_name (unicode):
+            tool_name (str):
                 The name of the SCM Tool to test with.
 
             expect_success (bool, optional):
                 Whether to simulate a truthy response.
 
-            expected_name_for_error (unicode, optional):
+            expected_name_for_error (str, optional):
                 The name of the SCM Tool to expect in the error response,
                 if ``expect_success`` is ``False``.
         """
         payload = (
-            '<?xml version="1.0" encoding="UTF-8"?>\n'
-            '<repository>\n'
-            ' <scm>%s</scm>\n'
-            '</repository>\n'
-            % codebase_scm_type
+            f'<?xml version="1.0" encoding="UTF-8"?>\n'
+            f'<repository>\n'
+            f' <scm>{codebase_scm_type}</scm>\n'
+            f'</repository>\n'
         ).encode('utf-8')
 
         check_repository_kwargs = {
@@ -236,9 +242,8 @@
                 ctx.service.check_repository(**check_repository_kwargs)
             else:
                 message = (
-                    "The repository type doesn't match what you selected. Did "
-                    "you mean %s?"
-                    % expected_name_for_error
+                    f"The repository type doesn't match what you selected. "
+                    f'Did you mean {expected_name_for_error}?'
                 )
 
                 with self.assertRaisesMessage(RepositoryError, message):
@@ -253,12 +258,16 @@
                 'Accept': 'application/xml',
             })
 
-    def _test_get_file(self, tool_name, expect_git_blob_url=False,
-                       file_exists=True):
+    def _test_get_file(
+        self,
+        tool_name: str,
+        expect_git_blob_url: bool = False,
+        file_exists: bool = True,
+    ) -> None:
         """Test file fetching.
 
         Args:
-            tool_name (unicode):
+            tool_name (str):
                 The name of the SCM Tool to test with.
 
             expect_git_blob_url (bool, optional):
@@ -309,12 +318,16 @@
                 'Accept': 'application/xml',
             })
 
-    def _test_get_file_exists(self, tool_name, expect_git_blob_url=False,
-                              file_exists=True):
+    def _test_get_file_exists(
+        self,
+        tool_name: str,
+        expect_git_blob_url: bool = False,
+        file_exists: bool = True,
+    ) -> None:
         """Test file existence checks.
 
         Args:
-            tool_name (unicode):
+            tool_name (str):
                 The name of the SCM Tool to test with.
 
             expect_git_blob_url (bool, optional):
diff --git a/reviewboard/hostingsvcs/tests/test_fogbugz.py b/reviewboard/hostingsvcs/tests/test_fogbugz.py
index af2f7c50414382a9ee374786cf1bbaaac01f8869..8ee2f2b4c8c2f6160aa82584e0e7d09d90139935 100644
--- a/reviewboard/hostingsvcs/tests/test_fogbugz.py
+++ b/reviewboard/hostingsvcs/tests/test_fogbugz.py
@@ -1,20 +1,23 @@
 """Unit tests for the FogBugz hosting service."""
 
+from __future__ import annotations
+
+from reviewboard.hostingsvcs.fogbugz import FogBugz
 from reviewboard.hostingsvcs.testing import HostingServiceTestCase
 
 
-class FogBugzTests(HostingServiceTestCase):
+class FogBugzTests(HostingServiceTestCase[FogBugz]):
     """Unit tests for the FogBugz hosting service."""
 
     service_name = 'fogbugz'
     fixtures = ['test_scmtools']
 
-    def test_service_support(self):
+    def test_service_support(self) -> None:
         """Testing FogBugz service support capabilities"""
         self.assertTrue(self.service_class.supports_bug_trackers)
         self.assertFalse(self.service_class.supports_repositories)
 
-    def test_get_bug_tracker_field(self):
+    def test_get_bug_tracker_field(self) -> None:
         """Testing FogBugz.get_bug_tracker_field"""
         self.assertFalse(
             self.service_class.get_bug_tracker_requires_username())
diff --git a/reviewboard/hostingsvcs/tests/test_gerrit.py b/reviewboard/hostingsvcs/tests/test_gerrit.py
index 590395f62a67c98deee9aaee5a4111a9864f38b3..ecefd2aacbe513d26e9322e7fc5523174de37e9c 100644
--- a/reviewboard/hostingsvcs/tests/test_gerrit.py
+++ b/reviewboard/hostingsvcs/tests/test_gerrit.py
@@ -1,22 +1,32 @@
 """Unit tests for the Gerrit hosting service."""
 
+from __future__ import annotations
+
 import base64
 import re
 import sys
+from typing import TYPE_CHECKING, cast
 from urllib.request import HTTPDigestAuthHandler, OpenerDirector
 
 from reviewboard.hostingsvcs.errors import (AuthorizationError,
                                             HostingServiceAPIError,
                                             HostingServiceError,
                                             RepositoryError)
-from reviewboard.hostingsvcs.gerrit import Gerrit, GerritForm
+from reviewboard.hostingsvcs.gerrit import Gerrit, GerritClient, GerritForm
 from reviewboard.hostingsvcs.testing import HostingServiceTestCase
 from reviewboard.scmtools.core import Branch, Commit
 from reviewboard.scmtools.crypto_utils import encrypt_password
 from reviewboard.scmtools.errors import FileNotFoundError
 
-
-class GerritTestCase(HostingServiceTestCase):
+if TYPE_CHECKING:
+    from typing import NoReturn
+
+    from typelets.json import JSONValue
+
+    from reviewboard.hostingsvcs.testing.testcases import HttpTestPath
+
+
+class GerritTestCase(HostingServiceTestCase[Gerrit]):
     """Base class for Gerrit unit tests."""
 
     service_name = 'gerrit'
@@ -38,7 +48,7 @@
 class GerritFormTests(GerritTestCase):
     """Unit tests for GerritForm."""
 
-    def test_clean_sets_gerrit_domain(self):
+    def test_clean_sets_gerrit_domain(self) -> None:
         """Testing GerritForm.clean sets gerrit_domain"""
         form = GerritForm(
             {
@@ -53,7 +63,7 @@
         self.assertEqual(form.cleaned_data['gerrit_domain'],
                          'gerrit.example.com')
 
-    def test_clean_with_errors(self):
+    def test_clean_with_errors(self) -> None:
         """Testing GerritForm.clean with errors"""
         form = GerritForm(
             {
@@ -77,32 +87,35 @@
 class GerritClientTests(GerritTestCase):
     """Unit tests for GerritClient."""
 
-    def setUp(self):
-        super(GerritClientTests, self).setUp()
+    client: GerritClient
+
+    def setUp(self) -> None:
+        """Set up the test case."""
+        super().setUp()
 
         hosting_account = self.create_hosting_account()
-        self.client = hosting_account.service.client
+        self.client = cast(GerritClient, hosting_account.service.client)
 
-    def test_auth_headers(self):
+    def test_auth_headers(self) -> None:
         """Testing GerritClient.http_request sets auth headers"""
-        class DummyResponse(object):
+        class DummyResponse:
             headers = {}
 
-            def getcode(self):
+            def getcode(self) -> int:
                 return 200
 
-            def geturl(self):
+            def geturl(self) -> str:
                 return 'http://gerrit.example.com/'
 
-            def read(self):
+            def read(self) -> bytes:
                 return b''
 
-        def _open(*args, **kwargs):
-            _open_args.extend(args)
+        def _open(*args, **kwargs) -> DummyResponse:
+            open_args.extend(args)
 
             return DummyResponse()
 
-        _open_args = []
+        open_args = []
         self.spy_on(OpenerDirector.open,
                     owner=OpenerDirector,
                     call_fake=_open)
@@ -111,7 +124,7 @@
                                  username='test-user',
                                  password='test-pass')
 
-        opener, request = _open_args
+        opener, request = open_args
         handler = opener.handlers[0]
         self.assertIsInstance(handler, HTTPDigestAuthHandler)
         self.assertEqual(
@@ -144,14 +157,14 @@
         'gerrit_project_name': 'Project',
     }
 
-    def test_service_support(self):
+    def test_service_support(self) -> None:
         """Testing Gerrit service support capabilities"""
         self.assertFalse(self.service_class.supports_bug_trackers)
         self.assertTrue(self.service_class.supports_repositories)
         self.assertFalse(self.service_class.supports_ssh_key_association)
         self.assertTrue(self.service_class.supports_post_commit)
 
-    def test_authorize(self):
+    def test_authorize(self) -> None:
         """Testing Gerrit.authorize"""
         hosting_account = self.create_hosting_account(data={})
 
@@ -173,7 +186,7 @@
 
         ctx.assertHTTPCall(0, url='http://gerrit.example.com/a/projects/')
 
-    def test_authorize_with_error(self):
+    def test_authorize_with_error(self) -> None:
         """Testing Gerrit.authorize handles authentication failure"""
         expected_message = (
             'Unable to authenticate to Gerrit at '
@@ -181,7 +194,11 @@
             'used may be invalid.'
         )
 
-        def _http_request(client, *args, **kwargs):
+        def _http_request(
+            client: GerritClient,
+            *args,
+            **kwargs,
+        ) -> NoReturn:
             raise HostingServiceError('', http_code=401)
 
         with self.setup_http_test(_http_request, expected_http_calls=1) as ctx:
@@ -202,7 +219,7 @@
 
         self.assertFalse(ctx.hosting_account.data['authorized'])
 
-    def test_check_repository(self):
+    def test_check_repository(self) -> None:
         """Testing Gerrit.check_repository"""
         payload = self._make_json_rsp({
             'gerrit-reviewboard': {
@@ -221,9 +238,13 @@
                            url='http://gerrit.example.com/a/projects/Project')
         ctx.assertHTTPCall(1, url='http://gerrit.example.com/a/plugins/')
 
-    def test_check_repository_with_404(self):
+    def test_check_repository_with_404(self) -> None:
         """Testing Gerrit.check_repository with a non-existent repository"""
-        def _http_request(client, *args, **kwargs):
+        def _http_request(
+            client: GerritClient,
+            *args,
+            **kwargs,
+        ) -> NoReturn:
             raise HostingServiceAPIError('', 404)
 
         expected_message = (
@@ -241,7 +262,7 @@
         ctx.assertHTTPCall(0,
                            url='http://gerrit.example.com/a/projects/Project')
 
-    def test_check_repository_with_no_plugin(self):
+    def test_check_repository_with_no_plugin(self) -> None:
         """Testing Gerrit.check_repository with no plugin"""
         expected_message = (
             'The "gerrit-reviewboard" plugin is not installed on the server. '
@@ -260,7 +281,7 @@
                            url='http://gerrit.example.com/a/projects/Project')
         ctx.assertHTTPCall(1, url='http://gerrit.example.com/a/plugins/')
 
-    def test_check_repository_with_bad_plugin_version(self):
+    def test_check_repository_with_bad_plugin_version(self) -> None:
         """Testing Gerrit.check_repository with an outdated plugin"""
         payload = self._make_json_rsp({
             'gerrit-reviewboard': {
@@ -286,7 +307,7 @@
                            url='http://gerrit.example.com/a/projects/Project')
         ctx.assertHTTPCall(1, url='http://gerrit.example.com/a/plugins/')
 
-    def test_get_file_exists(self):
+    def test_get_file_exists(self) -> None:
         """Testing Gerrit.get_file_exists"""
         blob_id = 'a' * 40
 
@@ -306,9 +327,13 @@
             url=('http://gerrit.example.com/a/projects/Project/blobs/%s/'
                  % blob_id))
 
-    def test_get_file_exists_with_404(self):
+    def test_get_file_exists_with_404(self) -> None:
         """Testing Gerrit.get_file_exists with a non-existent file"""
-        def _http_request(client, *args, **kwargs):
+        def _http_request(
+            client: GerritClient,
+            *args,
+            **kwargs,
+        ) -> NoReturn:
             raise HostingServiceAPIError('', http_code=404)
 
         blob_id = 'a' * 40
@@ -324,7 +349,7 @@
             url=('http://gerrit.example.com/a/projects/Project/blobs/%s/'
                  % blob_id))
 
-    def test_get_file(self):
+    def test_get_file(self) -> None:
         """Testing Gerrit.get_file"""
         blob_id = 'a' * 40
 
@@ -343,9 +368,13 @@
                  'content/'
                  % blob_id))
 
-    def test_get_file_with_404(self):
+    def test_get_file_with_404(self) -> None:
         """Testing Gerrit.get_file with a non-existent blob ID"""
-        def _http_request(client, *args, **kwargs):
+        def _http_request(
+            client: GerritClient,
+            *args,
+            **kwargs,
+        ) -> NoReturn:
             raise HostingServiceAPIError('', http_code=404)
 
         blob_id = 'a' * 40
@@ -362,7 +391,7 @@
                  'content/'
                  % blob_id))
 
-    def test_get_file_with_undecodable_response(self):
+    def test_get_file_with_undecodable_response(self) -> None:
         """Testing Gerrit.get_file with an undecodable response"""
         blob_id = 'a' * 40
 
@@ -394,7 +423,7 @@
                  'content/'
                  % blob_id))
 
-    def test_get_branches(self):
+    def test_get_branches(self) -> None:
         """Testing Gerrit.get_branches"""
         payload = self._make_json_rsp([
             {
@@ -440,7 +469,7 @@
             ]
         )
 
-    def test_get_commits(self):
+    def test_get_commits(self) -> None:
         """Testing Gerrit.get_commits"""
         payload = self._make_json_rsp([
             {
@@ -504,10 +533,10 @@
         for commit in commits:
             self.assertIsNone(commit.diff)
 
-    def test_get_change(self):
+    def test_get_change(self) -> None:
         """Testing Gerrit.get_change"""
         revision = '77c174669b7018936f16b98547445624c6738e1e'
-        paths = {
+        paths: dict[str | None, HttpTestPath] = {
             '/a/projects/Project/commits/%s/diff/' % revision: {
                 'payload': b'fake diff',
             },
@@ -550,7 +579,10 @@
             url=('http://gerrit.example.com/a/projects/Project/commits/'
                  '77c174669b7018936f16b98547445624c6738e1e/diff/'))
 
-    def _make_json_rsp(self, data):
+    def _make_json_rsp(
+        self,
+        data: JSONValue,
+    ) -> bytes:
         """Return a Gerrit JSON response payload for the given data.
 
         Args:
diff --git a/reviewboard/hostingsvcs/tests/test_github.py b/reviewboard/hostingsvcs/tests/test_github.py
index 9c85e28cddfdfcda89f2ce7ee3e0812c4438764a..c5da7ca036de8aa6c1be45b6ae35e23e9e5c0cf9 100644
--- a/reviewboard/hostingsvcs/tests/test_github.py
+++ b/reviewboard/hostingsvcs/tests/test_github.py
@@ -1,7 +1,10 @@
 """Unit tests for the GitHub hosting service."""
 
+from __future__ import annotations
+
 import hashlib
 import hmac
+from typing import TYPE_CHECKING
 
 from django.contrib.auth.models import User
 from django.core.exceptions import ObjectDoesNotExist
@@ -12,6 +15,7 @@
 from reviewboard.scmtools.core import Branch, Commit
 from reviewboard.hostingsvcs.errors import (AuthorizationError,
                                             RepositoryError)
+from reviewboard.hostingsvcs.github import GitHub
 from reviewboard.hostingsvcs.hook_utils import logger
 from reviewboard.hostingsvcs.repository import RemoteRepository
 from reviewboard.hostingsvcs.testing import HostingServiceTestCase
@@ -22,8 +26,16 @@
 from reviewboard.site.models import LocalSite
 from reviewboard.site.urlresolvers import local_site_reverse
 
-
-class GitHubTestCase(HostingServiceTestCase):
+if TYPE_CHECKING:
+    from collections.abc import Mapping
+    from typing import Any
+
+    from django.test.client import _MonkeyPatchedWSGIResponse
+
+    from reviewboard.hostingsvcs.testing.testcases import HttpTestPath
+
+
+class GitHubTestCase(HostingServiceTestCase[GitHub]):
     """Base class for GitHub test suites."""
 
     service_name = 'github'
@@ -41,13 +53,13 @@
 class GitHubTests(GitHubTestCase):
     """Unit tests for the GitHub hosting service."""
 
-    def test_service_support(self):
+    def test_service_support(self) -> None:
         """Testing GitHub service support capabilities"""
         self.assertTrue(self.service_class.supports_bug_trackers)
         self.assertTrue(self.service_class.supports_repositories)
         self.assertFalse(self.service_class.supports_ssh_key_association)
 
-    def test_get_repository_fields_with_public_plan(self):
+    def test_get_repository_fields_with_public_plan(self) -> None:
         """Testing GitHub.get_repository_fields with the public plan"""
         self.assertEqual(
             self.get_repository_fields(
@@ -62,7 +74,7 @@
                 'mirror_path': 'git@github.com:myuser/myrepo.git',
             })
 
-    def test_get_repository_fields_with_public_org_plan(self):
+    def test_get_repository_fields_with_public_org_plan(self) -> None:
         """Testing GitHub.get_repository_fields with the public-org plan"""
         self.assertEqual(
             self.get_repository_fields(
@@ -78,7 +90,7 @@
                 'mirror_path': 'git@github.com:myorg/myrepo.git',
             })
 
-    def test_get_repository_fields_with_private_plan(self):
+    def test_get_repository_fields_with_private_plan(self) -> None:
         """Testing GitHub.get_repository_fields with the private plan"""
         self.assertEqual(
             self.get_repository_fields(
@@ -93,7 +105,7 @@
                 'mirror_path': '',
             })
 
-    def test_get_repository_fields_with_private_org_plan(self):
+    def test_get_repository_fields_with_private_org_plan(self) -> None:
         """Testing GitHub.get_repository_fields with the private-org plan"""
         self.assertEqual(
             self.get_repository_fields(
@@ -109,14 +121,14 @@
                 'mirror_path': '',
             })
 
-    def test_get_repo_api_url_with_public_plan(self):
+    def test_get_repo_api_url_with_public_plan(self) -> None:
         """Testing GitHub._get_repo_api_url with the public plan"""
         url = self._get_repo_api_url('public', {
             'github_public_repo_name': 'testrepo',
         })
         self.assertEqual(url, 'https://api.github.com/repos/myuser/testrepo')
 
-    def test_get_repo_api_url_with_public_org_plan(self):
+    def test_get_repo_api_url_with_public_org_plan(self) -> None:
         """Testing GitHub._get_repo_api_url with the public-org plan"""
         url = self._get_repo_api_url('public-org', {
             'github_public_org_name': 'myorg',
@@ -124,14 +136,14 @@
         })
         self.assertEqual(url, 'https://api.github.com/repos/myorg/testrepo')
 
-    def test_get_repo_api_url_with_private_plan(self):
+    def test_get_repo_api_url_with_private_plan(self) -> None:
         """Testing GitHub._get_repo_api_url with the private plan"""
         url = self._get_repo_api_url('private', {
             'github_private_repo_name': 'testrepo',
         })
         self.assertEqual(url, 'https://api.github.com/repos/myuser/testrepo')
 
-    def test_get_repo_api_url_with_private_org_plan(self):
+    def test_get_repo_api_url_with_private_org_plan(self) -> None:
         """Testing GitHub._get_repo_api_url with the private-org plan"""
         url = self._get_repo_api_url('private-org', {
             'github_private_org_name': 'myorg',
@@ -139,7 +151,7 @@
         })
         self.assertEqual(url, 'https://api.github.com/repos/myorg/testrepo')
 
-    def test_get_bug_tracker_field_with_public_plan(self):
+    def test_get_bug_tracker_field_with_public_plan(self) -> None:
         """Testing GitHub.get_bug_tracker_field with the public plan"""
         self.assertTrue(
             self.service_class.get_bug_tracker_requires_username('public'))
@@ -150,7 +162,7 @@
             }),
             'http://github.com/myuser/myrepo/issues#issue/%s')
 
-    def test_get_bug_tracker_field_with_public_org_plan(self):
+    def test_get_bug_tracker_field_with_public_org_plan(self) -> None:
         """Testing GitHub.get_bug_tracker_field with the public-org plan"""
         self.assertFalse(
             self.service_class.get_bug_tracker_requires_username('public-org'))
@@ -161,7 +173,7 @@
             }),
             'http://github.com/myorg/myrepo/issues#issue/%s')
 
-    def test_get_bug_tracker_field_with_private_plan(self):
+    def test_get_bug_tracker_field_with_private_plan(self) -> None:
         """Testing GitHub.get_bug_tracker_field with the private plan"""
         self.assertTrue(
             self.service_class.get_bug_tracker_requires_username('private'))
@@ -172,7 +184,7 @@
             }),
             'http://github.com/myuser/myrepo/issues#issue/%s')
 
-    def test_get_bug_tracker_field_with_private_org_plan(self):
+    def test_get_bug_tracker_field_with_private_org_plan(self) -> None:
         """Testing GitHub.get_bug_tracker_field with the private-org plan"""
         self.assertFalse(self.service_class.get_bug_tracker_requires_username(
             'private-org'))
@@ -183,7 +195,7 @@
             }),
             'http://github.com/myorg/myrepo/issues#issue/%s')
 
-    def test_get_repository_hook_instructions(self):
+    def test_get_repository_hook_instructions(self) -> None:
         """Testing GitHub.get_repository_hook_instructions"""
         account = self.create_hosting_account()
         repository = self.create_repository(hosting_account=account)
@@ -208,31 +220,31 @@
             content)
         self.assertIn('Review Board supports closing', content)
 
-    def test_check_repository_public(self):
+    def test_check_repository_public(self) -> None:
         """Testing GitHub.check_repository with public repository"""
         self._test_check_repository(plan='public',
                                     github_public_repo_name='myrepo')
 
-    def test_check_repository_private(self):
+    def test_check_repository_private(self) -> None:
         """Testing GitHub.check_repository with private repository"""
         self._test_check_repository(plan='private',
                                     github_private_repo_name='myrepo')
 
-    def test_check_repository_public_org(self):
+    def test_check_repository_public_org(self) -> None:
         """Testing GitHub.check_repository with public org repository"""
         self._test_check_repository(plan='public-org',
                                     github_public_org_name='myorg',
                                     github_public_org_repo_name='myrepo',
                                     expected_owner='myorg')
 
-    def test_check_repository_private_org(self):
+    def test_check_repository_private_org(self) -> None:
         """Testing GitHub.check_repository with private org repository"""
         self._test_check_repository(plan='private-org',
                                     github_private_org_name='myorg',
                                     github_private_org_repo_name='myrepo',
                                     expected_owner='myorg')
 
-    def test_check_repository_public_not_found(self):
+    def test_check_repository_public_not_found(self) -> None:
         """Testing GitHub.check_repository with not found error and public
         repository
         """
@@ -245,7 +257,7 @@
             expected_error='A repository with this name was not found, '
                            'or your user may not own it.')
 
-    def test_check_repository_private_not_found(self):
+    def test_check_repository_private_not_found(self) -> None:
         """Testing GitHub.check_repository with not found error and private
         repository
         """
@@ -258,7 +270,7 @@
             expected_error='A repository with this name was not found, '
                            'or your user may not own it.')
 
-    def test_check_repository_public_org_not_found(self):
+    def test_check_repository_public_org_not_found(self) -> None:
         """Testing GitHub.check_repository with not found error and
         public organization repository
         """
@@ -272,7 +284,7 @@
             expected_error='A repository with this organization or name '
                            'was not found.')
 
-    def test_check_repository_private_org_not_found(self):
+    def test_check_repository_private_org_not_found(self) -> None:
         """Testing GitHub.check_repository with not found error and
         private organization repository
         """
@@ -287,7 +299,7 @@
                            'was not found, or your user may not have access '
                            'to it.')
 
-    def test_check_repository_public_plan_private_repo(self):
+    def test_check_repository_public_plan_private_repo(self) -> None:
         """Testing GitHub.check_repository with public plan and
         private repository
         """
@@ -300,7 +312,7 @@
             expected_error='This is a private repository, but you have '
                            'selected a public plan.')
 
-    def test_check_repository_private_plan_public_repo(self):
+    def test_check_repository_private_plan_public_repo(self) -> None:
         """Testing GitHub.check_repository with private plan and
         public repository
         """
@@ -313,7 +325,7 @@
             expected_error='This is a public repository, but you have '
                            'selected a private plan.')
 
-    def test_check_repository_public_org_plan_private_repo(self):
+    def test_check_repository_public_org_plan_private_repo(self) -> None:
         """Testing GitHub.check_repository with public organization plan and
         private repository
         """
@@ -327,7 +339,7 @@
             expected_error='This is a private repository, but you have '
                            'selected a public plan.')
 
-    def test_check_repository_private_org_plan_public_repo(self):
+    def test_check_repository_private_org_plan_public_repo(self) -> None:
         """Testing GitHub.check_repository with private organization plan and
         public repository
         """
@@ -341,12 +353,12 @@
             expected_error='This is a public repository, but you have '
                            'selected a private plan.')
 
-    def test_authorize(self):
+    def test_authorize(self) -> None:
         """Testing GitHub.authorize"""
-        paths = {
+        paths: dict[str | None, HttpTestPath] = {
             '/user': {
                 'headers': {
-                    str('X-OAuth-Scopes'): str('user, repo, admin:repo_hook'),
+                    'X-OAuth-Scopes': 'user, repo, admin:repo_hook',
                 },
                 'payload': b'{}',
             },
@@ -377,12 +389,12 @@
             decrypt_password(hosting_account.data['personal_token']),
             'abcde12345abcde12345abcde12345abcde12345')
 
-    def test_authorize_with_missing_scopes(self):
+    def test_authorize_with_missing_scopes(self) -> None:
         """Testing GitHub.authorize with missing scopes"""
         paths = {
             '/user': {
                 'headers': {
-                    str('X-OAuth-Scopes'): str('user, foobar'),
+                    'X-OAuth-Scopes': 'user, foobar',
                 },
                 'payload': b'{}',
             },
@@ -416,14 +428,14 @@
         self.assertNotIn('personal_token', hosting_account.data)
         self.assertNotIn('authorizations', hosting_account.data)
 
-    def test_is_authorized_with_personal_token(self):
+    def test_is_authorized_with_personal_token(self) -> None:
         """Testing GitHub.is_authorized with personal access token"""
         hosting_account = self.create_hosting_account(data={
             'personal_token': encrypt_password('abc123'),
         })
         self.assertTrue(hosting_account.is_authorized)
 
-    def test_is_authorized_with_legacy_authorization(self):
+    def test_is_authorized_with_legacy_authorization(self) -> None:
         """Testing GitHub.is_authorized with legacy authorization token"""
         hosting_account = self.create_hosting_account(data={
             'authorization': {
@@ -432,14 +444,14 @@
         })
         self.assertTrue(hosting_account.is_authorized)
 
-    def test_is_authorized_without_tokens(self):
+    def test_is_authorized_without_tokens(self) -> None:
         """Testing GitHub.is_authorized with legacy authorization token"""
         hosting_account = self.create_hosting_account(data={
             'authorizations': {},
         })
         self.assertFalse(hosting_account.is_authorized)
 
-    def test_api_with_personal_accesstoken(self):
+    def test_api_with_personal_accesstoken(self) -> None:
         """Testing GitHub API access with personal access token"""
         with self.setup_http_test(payload=b'{}',
                                   expected_http_calls=1) as ctx:
@@ -454,7 +466,7 @@
             username='myuser',
             password='my-personal-token')
 
-    def test_api_with_legacy_auth_token(self):
+    def test_api_with_legacy_auth_token(self) -> None:
         """Testing GitHub API access with legacy auth tokens"""
         with self.setup_http_test(payload=b'{}',
                                   expected_http_calls=1) as ctx:
@@ -471,7 +483,7 @@
             username='myuser',
             password='my-legacy-token')
 
-    def test_get_branches(self):
+    def test_get_branches(self) -> None:
         """Testing GitHub.get_branches"""
         payload = self.dump_json([
             {
@@ -525,7 +537,7 @@
                        default=False),
             ])
 
-    def test_get_branches_master_default(self):
+    def test_get_branches_master_default(self) -> None:
         """Testing GitHub.get_branches master default"""
         payload = self.dump_json([
             {
@@ -564,7 +576,7 @@
                        default=True),
             ])
 
-    def test_get_branches_main_default(self):
+    def test_get_branches_main_default(self) -> None:
         """Testing GitHub.get_branches main default"""
         payload = self.dump_json([
             {
@@ -603,7 +615,7 @@
                        default=True),
             ])
 
-    def test_get_branches_default_fallback(self):
+    def test_get_branches_default_fallback(self) -> None:
         """Testing GitHub.get_branches default fallback"""
         payload = self.dump_json([
             {
@@ -642,7 +654,7 @@
                        default=False),
             ])
 
-    def test_get_commits(self):
+    def test_get_commits(self) -> None:
         """Testing GitHub.get_commits"""
         payload = self.dump_json([
             {
@@ -720,13 +732,13 @@
         for commit in commits:
             self.assertIsNone(commit.diff)
 
-    def test_get_change(self):
+    def test_get_change(self) -> None:
         """Testing GitHub.get_change"""
         commit_sha = '1c44b461cebe5874a857c51a4a13a849a4d1e52d'
         parent_sha = '44568f7d33647d286691517e6325fea5c7a21d5e'
         tree_sha = '56e25e58380daf9b4dfe35677ae6043fe1743922'
 
-        paths = {
+        paths: dict[str | None, HttpTestPath] = {
             '/repos/myuser/myrepo/commits': {
                 'payload': self.dump_json([
                     {
@@ -886,7 +898,7 @@
             b'- foo\n'
             b'+ bar\n')
 
-    def test_get_change_with_not_found(self):
+    def test_get_change_with_not_found(self) -> None:
         """Testing GitHub.get_change with commit not found"""
         with self.setup_http_test(status_code=404,
                                   payload=b'{"message": "Not Found"}',
@@ -904,12 +916,12 @@
             username='myuser',
             password='abc123')
 
-    def test_get_remote_repositories_with_owner(self):
+    def test_get_remote_repositories_with_owner(self) -> None:
         """Testing GitHub.get_remote_repositories with requesting
         authenticated user's repositories
         """
         base_url = 'https://api.github.com/user/repos'
-        paths = {
+        paths: dict[str | None, HttpTestPath] = {
             '/user/repos': {
                 'payload': self.dump_json([
                     {
@@ -924,7 +936,7 @@
                     },
                 ]),
                 'headers': {
-                    str('Link'): str('<%s?page=2>; rel="next"' % base_url),
+                    'Link': f'<{base_url}?page=2>; rel="next"',
                 },
             },
             '/user/repos?page=2': {
@@ -941,7 +953,7 @@
                     },
                 ]),
                 'headers': {
-                    str('Link'): str('<%s?page=1>; rel="prev"' % base_url),
+                    'Link': f'<{base_url}?page=1>; rel="prev"',
                 },
             },
         }
@@ -992,7 +1004,7 @@
         self.assertEqual(repo.path, 'myrepo_path2')
         self.assertEqual(repo.mirror_path, 'myrepo_mirror2')
 
-    def test_get_remote_repositories_with_other_user(self):
+    def test_get_remote_repositories_with_other_user(self) -> None:
         """Testing GitHub.get_remote_repositories with requesting user's
         repositories
         """
@@ -1010,10 +1022,8 @@
         ])
 
         headers = {
-            str('Link'): str(
-                '<https://api.github.com/users/other/repos'
-                '?page=2>; rel="next"'
-            ),
+            'Link': '<https://api.github.com/users/other/repos'
+                    '?page=2>; rel="next"',
         }
 
         with self.setup_http_test(payload=repos1,
@@ -1037,7 +1047,7 @@
         self.assertEqual(public_repo.path, 'myrepo_path')
         self.assertEqual(public_repo.mirror_path, 'myrepo_mirror')
 
-    def test_get_remote_repositories_with_org(self):
+    def test_get_remote_repositories_with_org(self) -> None:
         """Testing GitHub.get_remote_repositories with requesting
         organization's repositories
         """
@@ -1094,7 +1104,7 @@
         self.assertEqual(private_repo.path, 'myrepo_path2')
         self.assertEqual(private_repo.mirror_path, 'myrepo_mirror2')
 
-    def test_get_remote_repositories_with_defaults(self):
+    def test_get_remote_repositories_with_defaults(self) -> None:
         """Testing GitHub.get_remote_repositories with default values"""
         with self.setup_http_test(payload=b'{}',
                                   expected_http_calls=1) as ctx:
@@ -1106,7 +1116,7 @@
             username='myuser',
             password='abc123')
 
-    def test_get_remote_repositories_with_filter(self):
+    def test_get_remote_repositories_with_filter(self) -> None:
         """Testing GitHub.get_remote_repositories with ?filter-type="""
         with self.setup_http_test(payload=b'[]',
                                   expected_http_calls=1) as ctx:
@@ -1119,7 +1129,7 @@
             username='myuser',
             password='abc123')
 
-    def test_get_remote_repository(self):
+    def test_get_remote_repository(self) -> None:
         """Testing GitHub.get_remote_repository"""
         payload = self.dump_json({
             'id': 1,
@@ -1151,7 +1161,7 @@
         self.assertEqual(remote_repository.path, 'myrepo_path')
         self.assertEqual(remote_repository.mirror_path, 'myrepo_mirror')
 
-    def test_get_remote_repository_invalid(self):
+    def test_get_remote_repository_invalid(self) -> None:
         """Testing GitHub.get_remote_repository with invalid repository ID"""
         with self.setup_http_test(status_code=404,
                                   payload=b'{"message": "Not Found"}',
@@ -1165,11 +1175,15 @@
             username='myuser',
             password='abc123')
 
-    def _test_check_repository(self, expected_owner='myuser', **kwargs):
+    def _test_check_repository(
+        self,
+        expected_owner: str = 'myuser',
+        **kwargs,
+    ) -> None:
         """Test checking for a repository.
 
         Args:
-            expected_owner (unicode):
+            expected_owner (str, optional):
                 The expected owner of the repository.
 
             **kwargs (dict):
@@ -1187,8 +1201,14 @@
             username='myuser',
             password='abc123')
 
-    def _test_check_repository_error(self, http_status, payload, expected_url,
-                                     expected_error, **kwargs):
+    def _test_check_repository_error(
+        self,
+        http_status: int,
+        payload: bytes,
+        expected_url: str,
+        expected_error: str,
+        **kwargs,
+    ) -> None:
         """Test error conditions when checking for a repository.
 
         Args:
@@ -1198,10 +1218,10 @@
             payload (bytes):
                 The payload to return, if ``http_status`` is 200.
 
-            expected_url (unicode):
+            expected_url (str):
                 The expected URL accessed (minus any query strings).
 
-            expected_error (unicode):
+            expected_error (str):
                 The expected error message from a raised exception.
 
             **kwargs (dict):
@@ -1224,18 +1244,22 @@
             username='myuser',
             password='abc123')
 
-    def _get_repo_api_url(self, plan, fields):
+    def _get_repo_api_url(
+        self,
+        plan: str,
+        fields: Mapping[str, Any],
+    ) -> str:
         """Return the base API URL for a repository.
 
         Args:
-            plan (unicode):
+            plan (str):
                 The name of the plan.
 
             fields (dict):
                 Fields containing repository information.
 
         Returns:
-            unicode:
+            str:
             The API URL for the repository.
         """
         account = self.create_hosting_account()
@@ -1257,12 +1281,12 @@
 
     fixtures = ['test_users', 'test_scmtools']
 
-    def test_close_submitted_hook(self):
+    def test_close_submitted_hook(self) -> None:
         """Testing GitHub close_submitted hook with event=push"""
         self._test_post_commit_hook()
 
     @add_fixtures(['test_site'])
-    def test_close_submitted_hook_with_local_site(self):
+    def test_close_submitted_hook_with_local_site(self) -> None:
         """Testing GitHub close_submitted hook with event=push and using a
         Local Site
         """
@@ -1270,13 +1294,15 @@
             LocalSite.objects.get(name=self.local_site_name))
 
     @add_fixtures(['test_site'])
-    def test_close_submitted_hook_with_unpublished_review_request(self):
+    def test_close_submitted_hook_with_unpublished_review_request(
+        self,
+    ) -> None:
         """Testing GitHub close_submitted hook with event=push and an
         un-published review request
         """
         self._test_post_commit_hook(publish=False)
 
-    def test_close_submitted_hook_ping(self):
+    def test_close_submitted_hook_ping(self) -> None:
         """Testing GitHub close_submitted hook with event=ping"""
         account = self.create_hosting_account()
         repository = self.create_repository(hosting_account=account)
@@ -1305,7 +1331,7 @@
         self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
         self.assertEqual(review_request.changedescs.count(), 0)
 
-    def test_close_submitted_hook_with_invalid_repo(self):
+    def test_close_submitted_hook_with_invalid_repo(self) -> None:
         """Testing GitHub close_submitted hook with event=push and invalid
         repository
         """
@@ -1334,7 +1360,7 @@
         self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
         self.assertEqual(review_request.changedescs.count(), 0)
 
-    def test_close_submitted_hook_with_invalid_site(self):
+    def test_close_submitted_hook_with_invalid_site(self) -> None:
         """Testing GitHub close_submitted hook with event=push and invalid
         Local Site
         """
@@ -1365,7 +1391,7 @@
         self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
         self.assertEqual(review_request.changedescs.count(), 0)
 
-    def test_close_submitted_hook_with_invalid_service_id(self):
+    def test_close_submitted_hook_with_invalid_service_id(self) -> None:
         """Testing GitHub close_submitted hook with event=push and invalid
         hosting service ID
         """
@@ -1399,7 +1425,7 @@
         self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
         self.assertEqual(review_request.changedescs.count(), 0)
 
-    def test_close_submitted_hook_with_invalid_event(self):
+    def test_close_submitted_hook_with_invalid_event(self) -> None:
         """Testing GitHub close_submitted hook with invalid event"""
         account = self.create_hosting_account()
         repository = self.create_repository(hosting_account=account)
@@ -1427,7 +1453,7 @@
         self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
         self.assertEqual(review_request.changedescs.count(), 0)
 
-    def test_close_submitted_hook_with_invalid_signature(self):
+    def test_close_submitted_hook_with_invalid_signature(self) -> None:
         """Testing GitHub close_submitted hook with invalid signature"""
         account = self.create_hosting_account()
         repository = self.create_repository(hosting_account=account)
@@ -1454,7 +1480,7 @@
         self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
         self.assertEqual(review_request.changedescs.count(), 0)
 
-    def test_close_submitted_hook_with_invalid_review_requests(self):
+    def test_close_submitted_hook_with_invalid_review_requests(self) -> None:
         """Testing GitHub close_submitted hook with event=push and invalid
         review requests
         """
@@ -1490,7 +1516,11 @@
             'close_all_review_requests: Review request #%s does not exist.',
             9999))
 
-    def _test_post_commit_hook(self, local_site=None, publish=True):
+    def _test_post_commit_hook(
+        self,
+        local_site: (LocalSite | None) = None,
+        publish: bool = True,
+    ) -> None:
         """Testing posting to a commit hook.
 
         This will simulate pushing a commit and posting the resulting webhook
@@ -1534,22 +1564,27 @@
         changedesc = review_request.changedescs.get()
         self.assertEqual(changedesc.text, 'Pushed to master (1c44b46)')
 
-    def _post_commit_hook_payload(self, post_url, review_request_url, secret,
-                                  event='push'):
+    def _post_commit_hook_payload(
+        self,
+        post_url: str,
+        review_request_url: str,
+        secret: str,
+        event: str = 'push',
+    ) -> _MonkeyPatchedWSGIResponse:
         """Post a payload for a hook for testing.
 
         Args:
-            post_url (unicode):
+            post_url (str):
                 The URL to post to.
 
-            review_request_url (unicode):
+            review_request_url (str):
                 The URL of the review request being represented in the
                 payload.
 
-            secret (unicode):
+            secret (str):
                 The HMAC secret for the message.
 
-            event (unicode, optional):
+            event (str, optional):
                 The webhook event.
 
         Results:
diff --git a/reviewboard/hostingsvcs/tests/test_gitlab.py b/reviewboard/hostingsvcs/tests/test_gitlab.py
index bf3784bc77adb523f90bca52b0e8d19dfc9a2574..2e1f7c6c28b7871c7e41b5cbfda5842d4548af94 100644
--- a/reviewboard/hostingsvcs/tests/test_gitlab.py
+++ b/reviewboard/hostingsvcs/tests/test_gitlab.py
@@ -1,17 +1,25 @@
 """Unit tests for the GitLab hosting service."""
 
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
 from django.utils.safestring import SafeText
 
 from reviewboard.hostingsvcs.errors import (AuthorizationError,
                                             RepositoryError)
-from reviewboard.hostingsvcs.gitlab import (GitLabAPIVersionError,
+from reviewboard.hostingsvcs.gitlab import (GitLab,
+                                            GitLabAPIVersionError,
                                             GitLabHostingURLWidget)
 from reviewboard.hostingsvcs.testing import HostingServiceTestCase
 from reviewboard.scmtools.core import Branch, Commit
 from reviewboard.scmtools.crypto_utils import encrypt_password
 
-
-class GitLabTestCase(HostingServiceTestCase):
+if TYPE_CHECKING:
+    from reviewboard.hostingsvcs.testing.testcases import HttpTestContext
+
+
+class GitLabTestCase(HostingServiceTestCase[GitLab]):
     """Base class for GitLab test suites."""
 
     service_name = 'gitlab'
@@ -29,13 +37,13 @@
 class GitLabTests(GitLabTestCase):
     """Unit tests for the GitLab hosting service."""
 
-    def test_service_support(self):
+    def test_service_support(self) -> None:
         """Testing GitLab service support capabilities"""
         self.assertTrue(self.service_class.supports_bug_trackers)
         self.assertTrue(self.service_class.supports_repositories)
         self.assertFalse(self.service_class.supports_ssh_key_association)
 
-    def test_get_repository_fields_with_personal_plan(self):
+    def test_get_repository_fields_with_personal_plan(self) -> None:
         """Testing GitLab.get_repository_fields with plan=personal"""
         self.assertEqual(
             self.get_repository_fields(
@@ -51,7 +59,7 @@
                 'mirror_path': 'https://example.com/myuser/myrepo.git',
             })
 
-    def test_get_repository_fields_with_group_plan(self):
+    def test_get_repository_fields_with_group_plan(self) -> None:
         """Testing GitLab.get_repository_fields with plan=group"""
         self.assertEqual(
             self.get_repository_fields(
@@ -68,7 +76,7 @@
                 'mirror_path': 'https://example.com/mygroup/myrepo.git',
             })
 
-    def test_get_bug_tracker_field_with_personal_plan(self):
+    def test_get_bug_tracker_field_with_personal_plan(self) -> None:
         """Testing GitLab.get_bug_tracker_field with plan=personal"""
         self.assertTrue(
             self.service_class.get_bug_tracker_requires_username('personal'))
@@ -80,7 +88,7 @@
             }),
             'https://example.com/myuser/myrepo/issues/%s')
 
-    def test_get_bug_tracker_field_with_group_plan(self):
+    def test_get_bug_tracker_field_with_group_plan(self) -> None:
         """Testing GitLab.get_bug_tracker_field with plan=group"""
         self.assertFalse(
             self.service_class.get_bug_tracker_requires_username('group'))
@@ -92,7 +100,7 @@
             }),
             'https://example.com/mygroup/myrepo/issues/%s')
 
-    def test_check_repository_personal_v3(self):
+    def test_check_repository_personal_v3(self) -> None:
         """Testing GitLab.check_repository with personal repository (API v3)"""
         ctx = self._test_check_repository_v3(
             plan='personal',
@@ -109,7 +117,7 @@
                 'PRIVATE-TOKEN': 'abc123',
             })
 
-    def test_check_repository_personal_v4(self):
+    def test_check_repository_personal_v4(self) -> None:
         """Testing GitLab.check_repository with personal repository (API v4)"""
         ctx = self._test_check_repository_v4(
             plan='personal',
@@ -126,7 +134,7 @@
                 'PRIVATE-TOKEN': 'abc123',
             })
 
-    def test_check_repository_group_v3(self):
+    def test_check_repository_group_v3(self) -> None:
         """Testing GitLab.check_repository with group repository (API v3)"""
         ctx = self._test_check_repository_v3(
             plan='group',
@@ -155,7 +163,7 @@
                 'PRIVATE-TOKEN': 'abc123',
             })
 
-    def test_check_repository_group_v4(self):
+    def test_check_repository_group_v4(self) -> None:
         """Testing GitLab.check_repository with group repository (API v4)"""
         ctx = self._test_check_repository_v4(plan='group',
                                              gitlab_group_name='mygroup',
@@ -173,7 +181,7 @@
                 'PRIVATE-TOKEN': 'abc123',
             })
 
-    def test_check_repository_personal_not_found_v4(self):
+    def test_check_repository_personal_not_found_v4(self) -> None:
         """Testing GitLab.check_repository with not found error and personal
         repository (API v4)
         """
@@ -194,7 +202,7 @@
                 'PRIVATE-TOKEN': 'abc123',
             })
 
-    def test_check_repository_group_repo_not_found_v4(self):
+    def test_check_repository_group_repo_not_found_v4(self) -> None:
         """Testing GitLab.check_repository with not found error and
         group repository (API v4)
         """
@@ -216,7 +224,7 @@
                 'PRIVATE-TOKEN': 'abc123',
             })
 
-    def test_check_repository_personal_not_found_v3(self):
+    def test_check_repository_personal_not_found_v3(self) -> None:
         """Testing GitLab.check_repository with not found error and personal
         repository (API v3)
         """
@@ -237,7 +245,7 @@
                 'PRIVATE-TOKEN': 'abc123',
             })
 
-    def test_check_repository_group_repo_not_found_v3(self):
+    def test_check_repository_group_repo_not_found_v3(self) -> None:
         """Testing GitLab.check_repository with not found error and
         group repository (API v3)
         """
@@ -270,7 +278,7 @@
                 'PRIVATE-TOKEN': 'abc123',
             })
 
-    def test_check_repository_group_not_found_v3(self):
+    def test_check_repository_group_not_found_v3(self) -> None:
         """Testing GitLab.check_repository with an incorrect group name (API
         v3)
         """
@@ -292,7 +300,7 @@
                 'PRIVATE-TOKEN': 'abc123',
             })
 
-    def test_authorize_v4(self):
+    def test_authorize_v4(self) -> None:
         """Testing GitLab.authorize (API v4)"""
         ctx = self._test_check_authorize(payload=b'{}',
                                          expected_http_calls=1)
@@ -305,7 +313,7 @@
                 'PRIVATE-TOKEN': 'foobarbaz',
             })
 
-    def test_authorize_v3(self):
+    def test_authorize_v3(self) -> None:
         """Testing GitLab.authorize (API v3)"""
         paths = {
             '/api/v4/projects': {
@@ -337,7 +345,7 @@
                 'PRIVATE-TOKEN': 'foobarbaz',
             })
 
-    def test_authorize_with_api_version_not_found(self):
+    def test_authorize_with_api_version_not_found(self) -> None:
         """Testing GitLab.authorize (API version not found)"""
         hosting_account = self.create_hosting_account(data={})
         self.assertFalse(hosting_account.is_authorized)
@@ -380,7 +388,7 @@
                 'PRIVATE-TOKEN': 'foobarbaz',
             })
 
-    def test_get_branches_v4(self):
+    def test_get_branches_v4(self) -> None:
         """Testing GitLab.get_branches (API v4)"""
         base_url = '/api/v4/projects/123456/repository/branches'
         paths = {
@@ -484,7 +492,7 @@
                        default=False),
             ])
 
-    def test_get_branches_v3(self):
+    def test_get_branches_v3(self) -> None:
         """Testing GitLab.get_branches (API v3)"""
         paths = {
             '/api/v3/projects/123456/repository/branches': {
@@ -549,15 +557,15 @@
                        default=False),
             ])
 
-    def test_get_commits_v4(self):
+    def test_get_commits_v4(self) -> None:
         """Testing GitLab.get_commits (API v4)"""
         self._test_get_commits(api_version='4')
 
-    def test_get_commits_v3(self):
+    def test_get_commits_v3(self) -> None:
         """Testing GitLab.get_commits (API v3)"""
         self._test_get_commits(api_version='3')
 
-    def test_get_change_v4(self):
+    def test_get_change_v4(self) -> None:
         """Testing GitLab.get_change (API v4)"""
         parent_sha = 'ae1d9fb46aa2b07ee9836d49862ec4e2c46fbbba'
         commit_sha = 'ed899a2f4b50b4370feeea94676502b42383c746'
@@ -731,7 +739,7 @@
                    parent='ae1d9fb46aa2b07ee9836d49862ec4e2c46fbbba'))
         self.assertEqual(commit.diff, diff_rsp)
 
-    def test_get_change_v4_files_lines(self):
+    def test_get_change_v4_files_lines(self) -> None:
         """Testing GitLab.get_change (API v4) in case they add files lines"""
         parent_sha = 'ae1d9fb46aa2b07ee9836d49862ec4e2c46fbbba'
         commit_sha = 'ed899a2f4b50b4370feeea94676502b42383c746'
@@ -905,7 +913,7 @@
                    parent='ae1d9fb46aa2b07ee9836d49862ec4e2c46fbbba'))
         self.assertEqual(commit.diff, diff_rsp)
 
-    def test_get_change_v4_index_line(self):
+    def test_get_change_v4_index_line(self) -> None:
         """Testing GitLab.get_change (API v4) in case they add index line"""
         parent_sha = 'ae1d9fb46aa2b07ee9836d49862ec4e2c46fbbba'
         commit_sha = 'ed899a2f4b50b4370feeea94676502b42383c746'
@@ -1025,7 +1033,7 @@
                    parent='ae1d9fb46aa2b07ee9836d49862ec4e2c46fbbba'))
         self.assertEqual(commit.diff, diff_rsp)
 
-    def test_get_change_v3(self):
+    def test_get_change_v3(self) -> None:
         """Testing GitLab.get_change (API v3)"""
         commit_sha = 'ed899a2f4b50b4370feeea94676502b42383c746'
         diff_rsp = (
@@ -1122,7 +1130,7 @@
                    parent='ae1d9fb46aa2b07ee9836d49862ec4e2c46fbbba'))
         self.assertEqual(commit.diff, diff_rsp)
 
-    def test_get_file_v4(self):
+    def test_get_file_v4(self) -> None:
         """Testing GitLab.get_file (API v4)"""
         self._test_get_file(
             api_version='4',
@@ -1130,7 +1138,7 @@
                 'https://example.com/api/v4/projects/123456/repository/'
                 'blobs/676502b42383c746ed899a2f4b50b4370feeea94/raw'))
 
-    def test_get_file_with_base_commit_v3(self):
+    def test_get_file_with_base_commit_v3(self) -> None:
         """Testing GitLab.get_file with base commit ID (API v3)"""
         self._test_get_file(
             api_version='3',
@@ -1140,7 +1148,7 @@
                 'blobs/ed899a2f4b50b4370feeea94676502b42383c746'
                 '?filepath=path/to/file.txt'))
 
-    def test_get_file_without_base_commit_v3(self):
+    def test_get_file_without_base_commit_v3(self) -> None:
         """Testing GitLab.get_file without base commit ID (API v3)"""
         self._test_get_file(
             api_version='3',
@@ -1148,7 +1156,7 @@
                 'https://example.com/api/v3/projects/123456/repository/'
                 'raw_blobs/676502b42383c746ed899a2f4b50b4370feeea94'))
 
-    def test_get_file_exists_with_exists_v4(self):
+    def test_get_file_exists_with_exists_v4(self) -> None:
         """Testing GitLab.get_file_exists with exists (API v4)"""
         self._test_get_file_exists(
             api_version='4',
@@ -1157,7 +1165,7 @@
                 'https://example.com/api/v4/projects/123456/repository/'
                 'blobs/676502b42383c746ed899a2f4b50b4370feeea94/raw'))
 
-    def test_get_file_exists_with_not_exists_v4(self):
+    def test_get_file_exists_with_not_exists_v4(self) -> None:
         """Testing GitLab.get_file_exists with not exists (API v4)"""
         self._test_get_file_exists(
             api_version='4',
@@ -1166,7 +1174,7 @@
                 'https://example.com/api/v4/projects/123456/repository/'
                 'blobs/676502b42383c746ed899a2f4b50b4370feeea94/raw'))
 
-    def test_get_file_exists_with_base_commit_and_exists_v3(self):
+    def test_get_file_exists_with_base_commit_and_exists_v3(self) -> None:
         """Testing GitLab.get_file_exists with base commit ID and exists
         (API v3)
         """
@@ -1179,7 +1187,7 @@
                 'blobs/ed899a2f4b50b4370feeea94676502b42383c746'
                 '?filepath=path/to/file.txt'))
 
-    def test_get_file_exists_without_base_commit_and_exists_v3(self):
+    def test_get_file_exists_without_base_commit_and_exists_v3(self) -> None:
         """Testing GitLab.get_file_exists without base commit ID and with
         exists
         (API v3)
@@ -1191,7 +1199,7 @@
                 'https://example.com/api/v3/projects/123456/repository/'
                 'raw_blobs/676502b42383c746ed899a2f4b50b4370feeea94'))
 
-    def test_get_file_exists_with_not_exists_v3(self):
+    def test_get_file_exists_with_not_exists_v3(self) -> None:
         """Testing GitLab.get_file_exists with not exists (API v3)"""
         self._test_get_file_exists(
             api_version='3',
@@ -1200,7 +1208,7 @@
                 'https://example.com/api/v3/projects/123456/repository/'
                 'raw_blobs/676502b42383c746ed899a2f4b50b4370feeea94'))
 
-    def test_get_api_version_with_v3(self):
+    def test_get_api_version_with_v3(self) -> None:
         """Testing GitLab._get_api_version with v3 API"""
         paths = {
             '/api/v3/projects?per_page=1': {
@@ -1213,7 +1221,7 @@
                 ctx.service._get_api_version('https://gitlab.example.com'),
                 '3')
 
-    def test_get_api_version_with_v4(self):
+    def test_get_api_version_with_v4(self) -> None:
         """Testing GitLab._get_api_version with v4 API"""
         paths = {
             '/api/v3/projects?per_page=1': {
@@ -1229,7 +1237,7 @@
                 ctx.service._get_api_version('https://gitlab.example.com'),
                 '4')
 
-    def test_api_head_with_auth_error(self):
+    def test_api_head_with_auth_error(self) -> None:
         """Testing GitLab._api_head with AuthorizationError"""
         message = 'The login or password is incorrect.'
 
@@ -1241,7 +1249,41 @@
                     hosting_url='https://gitlab.example.com',
                     path='foo')
 
-    def _test_check_authorize(self, *args, **kwargs):
+    def test_check_repository_with_api_version_not_found(self) -> None:
+        """Testing GitLab.check_repository (API version not found)"""
+        hosting_account = self.create_hosting_account(data={})
+        self.assertFalse(hosting_account.is_authorized)
+
+        message = (
+            'Could not determine the GitLab API version for '
+            'https://example.com due to an unexpected error (Unexpected path '
+            '"/api/v4/projects?per_page=1"). Check to make sure the URL can '
+            'be resolved from this server and that any SSL certificates are '
+            'valid and trusted.'
+        )
+
+        with self.setup_http_test(self.make_handler_for_paths({}),
+                                  hosting_account=hosting_account) as ctx:
+            with self.assertRaisesMessage(GitLabAPIVersionError, message):
+                ctx.service.check_repository(
+                    plan='group',
+                    gitlab_group_name='mygroup',
+                    gitlab_group_repo_name='myrepo')
+
+        ctx.assertHTTPCall(
+            0,
+            url='https://example.com/api/v4/projects?per_page=1',
+            username=None,
+            password=None,
+            headers={})
+        ctx.assertHTTPCall(
+            1,
+            url='https://example.com/api/v3/projects?per_page=1',
+            username=None,
+            password=None,
+            headers={})
+
+    def _test_check_authorize(self, *args, **kwargs) -> None:
         """Test authorizing a new account.
 
         Args:
@@ -1272,11 +1314,15 @@
 
         return ctx
 
-    def _test_check_repository_v4(self, expected_owner='myuser', **kwargs):
+    def _test_check_repository_v4(
+        self,
+        expected_owner: str = 'myuser',
+        **kwargs,
+    ) -> HttpTestContext[GitLab]:
         """Test checking for a repository using API v4.
 
         Args:
-            expected_owner (unicode):
+            expected_owner (str):
                 The expected user/group name owning the repository.
 
             **kwargs (dict):
@@ -1294,11 +1340,15 @@
 
         return ctx
 
-    def _test_check_repository_v3(self, expected_owner='myuser', **kwargs):
+    def _test_check_repository_v3(
+        self,
+        expected_owner: str = 'myuser',
+        **kwargs,
+    ) -> HttpTestContext[GitLab]:
         """Test checking for a repository using API v3.
 
         Args:
-            expected_owner (unicode):
+            expected_owner (str):
                 The expected user/group name owning the repository.
 
             **kwargs (dict):
@@ -1348,12 +1398,16 @@
 
         return ctx
 
-    def _test_check_repository_error_v4(self, expected_error,
-                                        expected_http_calls, **kwargs):
+    def _test_check_repository_error_v4(
+        self,
+        expected_error: str,
+        expected_http_calls: int,
+        **kwargs,
+    ) -> HttpTestContext[GitLab]:
         """Test error conditions when checking for a repository using API v4.
 
         Args:
-            expected_error (unicode):
+            expected_error (str):
                 The expected error message from a raised exception.
 
             expected_http_calls (int):
@@ -1376,12 +1430,16 @@
 
         return ctx
 
-    def _test_check_repository_error_v3(self, expected_error,
-                                        expected_http_calls, **kwargs):
+    def _test_check_repository_error_v3(
+        self,
+        expected_error: str,
+        expected_http_calls: int,
+        **kwargs,
+    ) -> HttpTestContext[GitLab]:
         """Test error conditions when checking for a repository using API v3.
 
         Args:
-            expected_error (unicode):
+            expected_error (str):
                 The expected error message from a raised exception.
 
             expected_http_calls (int):
@@ -1430,51 +1488,22 @@
 
         return ctx
 
-    def test_check_repository_with_api_version_not_found(self):
-        """Testing GitLab.check_repository (API version not found)"""
-        hosting_account = self.create_hosting_account(data={})
-        self.assertFalse(hosting_account.is_authorized)
-
-        message = (
-            'Could not determine the GitLab API version for '
-            'https://example.com due to an unexpected error (Unexpected path '
-            '"/api/v4/projects?per_page=1"). Check to make sure the URL can '
-            'be resolved from this server and that any SSL certificates are '
-            'valid and trusted.'
-        )
-
-        with self.setup_http_test(self.make_handler_for_paths({}),
-                                  hosting_account=hosting_account) as ctx:
-            with self.assertRaisesMessage(GitLabAPIVersionError, message):
-                ctx.service.check_repository(
-                    plan='group',
-                    gitlab_group_name='mygroup',
-                    gitlab_group_repo_name='myrepo')
-
-        ctx.assertHTTPCall(
-            0,
-            url='https://example.com/api/v4/projects?per_page=1',
-            username=None,
-            password=None,
-            headers={})
-        ctx.assertHTTPCall(
-            1,
-            url='https://example.com/api/v3/projects?per_page=1',
-            username=None,
-            password=None,
-            headers={})
-
-    def _test_get_file(self, api_version, expected_url, base_commit_id=None):
+    def _test_get_file(
+        self,
+        api_version: str,
+        expected_url: str,
+        base_commit_id: (str | None) = None,
+    ) -> None:
         """Common test for file retrieval.
 
         Args:
-            api_version (unicode):
+            api_version (str):
                 The API version to test against.
 
-            expected_url (unicode):
+            expected_url (str):
                 The expected URL to fetch for the request.
 
-            base_commit_id (unicode, optional):
+            base_commit_id (str, optional):
                 An optional base commit ID to specify during file retrieval.
         """
         with self.setup_http_test(payload=b'test data',
@@ -1500,21 +1529,26 @@
                 'PRIVATE-TOKEN': 'abc123',
             })
 
-    def _test_get_file_exists(self, api_version, should_exist, expected_url,
-                              base_commit_id=None):
+    def _test_get_file_exists(
+        self,
+        api_version: str,
+        should_exist: bool,
+        expected_url: str,
+        base_commit_id: (str | None) = None,
+    ) -> None:
         """Common test for file existence checks.
 
         Args:
-            api_version (unicode):
+            api_version (str):
                 The API version to test against.
 
             should_exist (bool):
                 Whether this should simulate that the file exists.
 
-            expected_url (unicode):
+            expected_url (str):
                 The expected URL to fetch for the request.
 
-            base_commit_id (unicode, optional):
+            base_commit_id (str, optional):
                 An optional base commit ID to specify during file existence
                 checks.
         """
@@ -1548,19 +1582,25 @@
                 'PRIVATE-TOKEN': 'abc123',
             })
 
-    def _test_get_branches(self, api_version):
+    def _test_get_branches(
+        self,
+        api_version: str,
+    ) -> None:
         """Common test for fetching branches.
 
         Args:
-            api_version (unicode):
+            api_version (str):
                 The API version to test against.
         """
 
-    def _test_get_commits(self, api_version):
+    def _test_get_commits(
+        self,
+        api_version: str,
+    ) -> None:
         """Common test for fetching lists of commits.
 
         Args:
-            api_version (unicode):
+            api_version (str):
                 The API version to test against.
         """
         payload = self.dump_json([
@@ -1629,14 +1669,18 @@
         for commit in commits:
             self.assertIsNone(commit.diff)
 
-    def _set_api_version(self, service, api_version):
+    def _set_api_version(
+        self,
+        service: GitLab,
+        api_version: str,
+    ) -> None:
         """Set the API version for a test.
 
         Args:
             service (reviewboard.hostingsvcs.gitlab.GitLab):
                 The GitLab hosting service instance.
 
-            api_version (unicode):
+            api_version (str):
                 The API version for the test.
         """
         self.spy_on(service._get_api_version,
@@ -1646,7 +1690,7 @@
 class GitLabHostingURLWidgetTests(GitLabTestCase):
     """Unit tests for reviewboard.hostingsvcs.gitlab.GitLabHostingURLWidget."""
 
-    def test_render(self):
+    def test_render(self) -> None:
         """Testing GitLabHostingURLWidget.render"""
         widget = GitLabHostingURLWidget()
         content = widget.render(
diff --git a/reviewboard/hostingsvcs/tests/test_gitorious.py b/reviewboard/hostingsvcs/tests/test_gitorious.py
index 6b1f5cde5834a750e88e1d8ed0e95626ffa14f2a..a0cb22416302bdfabad358a6f08908f429d7e3f6 100644
--- a/reviewboard/hostingsvcs/tests/test_gitorious.py
+++ b/reviewboard/hostingsvcs/tests/test_gitorious.py
@@ -1,20 +1,23 @@
 """Unit tests for the Gitorious hosting service."""
 
+from __future__ import annotations
+
+from reviewboard.hostingsvcs.gitorious import Gitorious
 from reviewboard.hostingsvcs.testing import HostingServiceTestCase
 
 
-class GitoriousTests(HostingServiceTestCase):
+class GitoriousTests(HostingServiceTestCase[Gitorious]):
     """Unit tests for the Gitorious hosting service."""
 
     service_name = 'gitorious'
 
-    def test_service_support(self):
+    def test_service_support(self) -> None:
         """Testing Gitorious service support capabilities"""
         self.assertFalse(self.service_class.supports_bug_trackers)
         self.assertTrue(self.service_class.supports_repositories)
         self.assertTrue(self.service_class.self_hosted)
 
-    def test_get_repository_fields(self):
+    def test_get_repository_fields(self) -> None:
         """Testing Gitorious.get_repository_fields"""
         self.assertEqual(
             self.get_repository_fields(
diff --git a/reviewboard/hostingsvcs/tests/test_kiln.py b/reviewboard/hostingsvcs/tests/test_kiln.py
index f5f38a112227ef5c779198f5a54e474b5a95a24d..e59aa355fef033a9f64e8bb494caec1c3e870885 100644
--- a/reviewboard/hostingsvcs/tests/test_kiln.py
+++ b/reviewboard/hostingsvcs/tests/test_kiln.py
@@ -1,11 +1,14 @@
 """Unit tests for the Kiln hosting service."""
 
+from __future__ import annotations
+
 from reviewboard.hostingsvcs.base.client import HostingServiceClient
 from reviewboard.hostingsvcs.errors import RepositoryError
+from reviewboard.hostingsvcs.kiln import Kiln
 from reviewboard.hostingsvcs.testing import HostingServiceTestCase
 
 
-class KilnTests(HostingServiceTestCase):
+class KilnTests(HostingServiceTestCase[Kiln]):
     """Unit tests for the Kiln hosting service."""
 
     service_name = 'kiln'
@@ -23,7 +26,7 @@
         'kiln_repo_name': 'myrepo',
     }
 
-    def test_service_support(self):
+    def test_service_support(self) -> None:
         """Testing Kiln service support capabilities"""
         self.assertTrue(self.service_class.supports_repositories)
         self.assertTrue(self.service_class.needs_authorization)
@@ -31,7 +34,7 @@
         self.assertFalse(self.service_class.supports_post_commit)
         self.assertFalse(self.service_class.supports_two_factor_auth)
 
-    def test_repo_field_values_git(self):
+    def test_repo_field_values_git(self) -> None:
         """Testing Kiln.get_repository_fields for Git"""
         self.assertEqual(
             self.get_repository_fields(
@@ -50,7 +53,7 @@
                                 'mygroup/myrepo'),
             })
 
-    def test_repo_field_values_mercurial(self):
+    def test_repo_field_values_mercurial(self) -> None:
         """Testing Kiln.get_repository_fields for Mercurial"""
         self.assertEqual(
             self.get_repository_fields(
@@ -69,7 +72,7 @@
                                 'mygroup/myrepo'),
             })
 
-    def test_authorize(self):
+    def test_authorize(self) -> None:
         """Testing Kiln.authorize"""
         hosting_account = self.create_hosting_account(data={})
 
@@ -109,7 +112,7 @@
         self.assertEqual(hosting_account.data['auth_token'], 'my-token')
         self.assertTrue(ctx.service.is_authorized())
 
-    def test_check_repository(self):
+    def test_check_repository(self) -> None:
         """Testing Kiln.check_repository"""
         payload = self.dump_json([{
             'sSlug': 'myproject',
@@ -135,7 +138,7 @@
             username=None,
             password=None)
 
-    def test_check_repository_with_incorrect_repo_info(self):
+    def test_check_repository_with_incorrect_repo_info(self) -> None:
         """Testing Kiln.check_repository with incorrect repo info"""
         payload = self.dump_json([{
             'sSlug': 'otherproject',
@@ -168,7 +171,7 @@
             username=None,
             password=None)
 
-    def test_get_file(self):
+    def test_get_file(self) -> None:
         """Testing Kiln.get_file"""
         paths = {
             '/Api/1.0/Project': {
@@ -212,7 +215,7 @@
             username=None,
             password=None)
 
-    def test_get_file_exists(self):
+    def test_get_file_exists(self) -> None:
         """Testing Kiln.get_file_exists"""
         paths = {
             '/Api/1.0/Project': {
diff --git a/reviewboard/hostingsvcs/tests/test_rbgateway.py b/reviewboard/hostingsvcs/tests/test_rbgateway.py
index c0a0be948820445c5ba50c960e5c47b657dca454..c5f39117b307cbd0eb5231f853ba95ef037bc82c 100644
--- a/reviewboard/hostingsvcs/tests/test_rbgateway.py
+++ b/reviewboard/hostingsvcs/tests/test_rbgateway.py
@@ -1,7 +1,10 @@
 """Unit tests for the ReviewBoardGateway hosting service."""
 
+from __future__ import annotations
+
 import hashlib
 import hmac
+from typing import TYPE_CHECKING
 
 from django.contrib.auth.models import User
 from django.test.client import RequestFactory
@@ -9,6 +12,7 @@
 from djblets.testing.decorators import add_fixtures
 
 from reviewboard.hostingsvcs.hook_utils import logger
+from reviewboard.hostingsvcs.rbgateway import ReviewBoardGateway
 from reviewboard.hostingsvcs.testing import HostingServiceTestCase
 from reviewboard.reviews.models import ReviewRequest
 from reviewboard.scmtools.core import Branch, Commit
@@ -18,8 +22,14 @@
 from reviewboard.site.models import LocalSite
 from reviewboard.site.urlresolvers import local_site_reverse
 
-
-class ReviewBoardGatewayTestCase(HostingServiceTestCase):
+if TYPE_CHECKING:
+    from django.test.client import _MonkeyPatchedWSGIResponse
+
+    from reviewboard.hostingsvcs.base.hosting_service import \
+        HostingServiceCredentials
+
+
+class ReviewBoardGatewayTestCase(HostingServiceTestCase[ReviewBoardGateway]):
     """Base test case for the ReviewBoardGateway hosting service."""
 
     service_name = 'rbgateway'
@@ -29,21 +39,24 @@
         'private_token': encrypt_password('abc123'),
     }
 
-    default_http_credentials = {
-        'headers': {
-            'PRIVATE-TOKEN': 'abc123',
-        },
-    }
-
     default_repository_extra_data = {
         'rbgateway_repo_name': 'myrepo',
     }
 
+    @property
+    def default_http_credentials(self) -> HostingServiceCredentials:
+        """The default credentials sent in HTTP requests."""
+        return {
+            'headers': {
+                'PRIVATE-TOKEN': 'abc123',
+            },
+        }
+
 
 class ReviewBoardGatewayClientTests(ReviewBoardGatewayTestCase):
     """Unit tests for ReviewBoardGatewayClient."""
 
-    def test_request_includes_private_token(self):
+    def test_request_includes_private_token(self) -> None:
         """Testing ReviewBoardGatewayClient API requests include PRIVATE-TOKEN
         header
         """
@@ -59,14 +72,14 @@
 class ReviewBoardGatewayTests(ReviewBoardGatewayTestCase):
     """Unit tests for the ReviewBoardGateway hosting service."""
 
-    def test_service_support(self):
+    def test_service_support(self) -> None:
         """Testing ReviewBoardGateway service support capabilities"""
         self.assertTrue(self.service_class.supports_repositories)
         self.assertTrue(self.service_class.supports_post_commit)
         self.assertFalse(self.service_class.supports_bug_trackers)
         self.assertFalse(self.service_class.supports_ssh_key_association)
 
-    def test_repo_field_values(self):
+    def test_repo_field_values(self) -> None:
         """Testing ReviewBoardGateway.get_repository_fields for Git"""
         self.assertEqual(
             self.get_repository_fields(
@@ -80,7 +93,7 @@
                 'path': 'https://example.com/repos/myrepo/path',
             })
 
-    def test_get_repository_hook_instructions(self):
+    def test_get_repository_hook_instructions(self) -> None:
         """Testing ReviewBoardGateway.get_repository_hook_instructions"""
         account = self.create_hosting_account()
         repository = self.create_repository(hosting_account=account)
@@ -103,7 +116,7 @@
             content)
         self.assertIn('Review Board supports closing', content)
 
-    def test_authorize(self):
+    def test_authorize(self) -> None:
         """Testing ReviewBoardGateway.authorize"""
         hosting_account = self.create_hosting_account(data={})
         self.assertFalse(hosting_account.is_authorized)
@@ -133,7 +146,7 @@
                 'Content-Length': '0',
             })
 
-    def test_check_repository(self):
+    def test_check_repository(self) -> None:
         """Testing ReviewBoardGateway.check_repository"""
         paths = {
             '/repos/myrepo/path': {
@@ -150,7 +163,7 @@
             url='https://example.com/repos/myrepo/path',
             headers=None)
 
-    def test_check_repository_with_invalid_repo(self):
+    def test_check_repository_with_invalid_repo(self) -> None:
         """Testing ReviewBoardGateway.check_repository with invalid
         repository
         """
@@ -164,7 +177,7 @@
             url='https://example.com/repos/invalid/path',
             headers=None)
 
-    def test_get_branches_git(self):
+    def test_get_branches_git(self) -> None:
         """Testing ReviewBoardGateway.get_branches for a Git repository"""
         payload = self.dump_json([
             {
@@ -197,7 +210,7 @@
                        commit='83904e6acb60e7ec0dcaae6c09a579ab44d0cf38'),
             ])
 
-    def test_get_branches_hg(self):
+    def test_get_branches_hg(self) -> None:
         """Testing ReviewBoardGateway.get_branches for an Hg repository"""
         payload = self.dump_json([
             {
@@ -230,7 +243,7 @@
                        commit='0731875ed7a14bdd53503b27b30a08a0452068cf'),
             ])
 
-    def test_get_commits(self):
+    def test_get_commits(self) -> None:
         """Testing ReviewBoardGateway.get_commits"""
         payload = self.dump_json([
             {
@@ -292,7 +305,7 @@
         for commit in commits:
             self.assertIsNone(commit.diff)
 
-    def test_get_change(self):
+    def test_get_change(self) -> None:
         """Testing ReviewBoardGateway.get_change"""
         diff = (
             'diff --git a/test b/test\n'
@@ -343,20 +356,20 @@
 
     fixtures = ['test_users', 'test_scmtools']
 
-    def test_close_submitted_hook_git(self):
+    def test_close_submitted_hook_git(self) -> None:
         """Testing the ReviewBoardGateway close-submitted hook with a Git
         repository
         """
         self._test_post_commit_hook(tool_name='Git')
 
-    def test_close_submiteed_hook_git_unpublished(self):
+    def test_close_submiteed_hook_git_unpublished(self) -> None:
         """Testing the ReviewBoardGateway close-submitted hook with an
         unpublished review request in a Git repository
         """
         self._test_post_commit_hook(tool_name='Git', publish=False)
 
     @add_fixtures(['test_site'])
-    def test_close_submiteed_hook_git_local_site_unpublished(self):
+    def test_close_submiteed_hook_git_local_site_unpublished(self) -> None:
         """Testing the ReviewBoardGateway close-submitted hook with an
         unpublished review request in a Git repository on a Local Site
         """
@@ -365,7 +378,7 @@
             local_site=LocalSite.objects.get(name=self.local_site_name),
             publish=False)
 
-    def test_close_submitted_hook_git_tag_target(self):
+    def test_close_submitted_hook_git_tag_target(self) -> None:
         """Testing the ReviewBoardGateway close-submitted hook with an
         Git repository and a tag target
         """
@@ -374,7 +387,7 @@
             expected_close_msg='Pushed to release-1.0.7 (bbbbbbb)',
             target_tags=['release-1.0.7', 'some-tag'])
 
-    def test_close_submitted_hook_git_no_target(self):
+    def test_close_submitted_hook_git_no_target(self) -> None:
         """Testing the ReviewBoardGateway close-submitted hook with an
         Git repository and no target information
         """
@@ -383,14 +396,14 @@
             expected_close_msg='Pushed to bbbbbbb',
             target_branch=None)
 
-    def test_close_submitted_hook_hg(self):
+    def test_close_submitted_hook_hg(self) -> None:
         """Testing the ReviewBoardGateway close-submitted hook with an
         Mercurial repository
         """
         self._test_post_commit_hook(tool_name='Mercurial')
 
     @add_fixtures(['test_site'])
-    def test_close_submitted_hook_hg_local_site(self):
+    def test_close_submitted_hook_hg_local_site(self) -> None:
         """Testing the ReviewBoardGateway close-submitted hook with an
         Mercurial repository on a Local Site
         """
@@ -398,14 +411,14 @@
             tool_name='Mercurial',
             local_site=LocalSite.objects.get(name=self.local_site_name))
 
-    def test_close_submitted_hook_hg_unpublished(self):
+    def test_close_submitted_hook_hg_unpublished(self) -> None:
         """Testing the ReviewBoardGateway close-submitted hook with an
         unpublished review request in a Mercurial repository
         """
         self._test_post_commit_hook(tool_name='Mercurial', publish=False)
 
     @add_fixtures(['test_site'])
-    def test_close_submitted_hook_hg_local_site_unpublished(self):
+    def test_close_submitted_hook_hg_local_site_unpublished(self) -> None:
         """Testing the ReviewBoardGateway close-submitted hook with an
         unpublished review request in a Mercurial repository on a Local Site
         """
@@ -414,7 +427,7 @@
             local_site=LocalSite.objects.get(name=self.local_site_name),
             publish=False)
 
-    def test_close_submitted_hook_hg_bookmark_target(self):
+    def test_close_submitted_hook_hg_bookmark_target(self) -> None:
         """Testing the ReviewBoardGateway close-submitted hook with an
         Mercurial repository and a bookmark target
         """
@@ -423,7 +436,7 @@
             expected_close_msg='Pushed to dev-work (bbbbbbb)',
             target_bookmarks=['dev-work'])
 
-    def test_close_submitted_hook_hg_tag_target(self):
+    def test_close_submitted_hook_hg_tag_target(self) -> None:
         """Testing the ReviewBoardGateway close-submitted hook with an
         Mercurial repository and a tag target
         """
@@ -433,7 +446,7 @@
             target_branch='default',
             target_tags=['@', 'tip'])
 
-    def test_close_submitted_hook_hg_no_target(self):
+    def test_close_submitted_hook_hg_no_target(self) -> None:
         """Testing the ReviewBoardGateway close-submitted hook with an
         Mercurial repository and no target information
         """
@@ -442,7 +455,7 @@
             expected_close_msg='Pushed to bbbbbbb',
             target_branch=None)
 
-    def test_close_submitted_hook_invalid_signature(self):
+    def test_close_submitted_hook_invalid_signature(self) -> None:
         """Testing the ReviewBoardGateway close-submitted hook with an invalid
         signature
         """
@@ -477,7 +490,7 @@
         self.assertEqual(rsp.status_code, 400)
         self.assertEqual(rsp.content, b'Bad signature.')
 
-    def test_close_submitted_hook_malformed_payload(self):
+    def test_close_submitted_hook_malformed_payload(self) -> None:
         """Testing the ReviewBoardGateway close-submitted hook with a malformed
         signature
         """
@@ -509,7 +522,7 @@
         self.assertEqual(rsp.status_code, 400)
         self.assertEqual(rsp.content, b'Invalid payload format.')
 
-    def test_close_submitted_hook_incomplete_payload(self):
+    def test_close_submitted_hook_incomplete_payload(self) -> None:
         account = self.create_hosting_account()
         repository = self.create_repository(tool_name='Git',
                                             hosting_account=account)
@@ -540,7 +553,7 @@
         self.assertEqual(rsp.status_code, 400)
         self.assertEqual(rsp.content, b'Invalid payload; expected "commits".')
 
-    def test_close_submitted_hook_invalid_event(self):
+    def test_close_submitted_hook_invalid_event(self) -> None:
         """Testing the ReviewBoardGateway close-submitted hook endpoint with an
         invalid event
         """
@@ -576,7 +589,7 @@
         self.assertEqual(rsp.content,
                          b'Only "ping" and "push" events are supported.')
 
-    def test_close_submitted_hook_with_invalid_review_request(self):
+    def test_close_submitted_hook_with_invalid_review_request(self) -> None:
         """Testing the ReviewBoardGateway close-submitted hook endpoint with an
         invalid review request
         """
@@ -613,7 +626,7 @@
             'close_all_review_requests: Review request #%s does not exist.',
             9999))
 
-    def test_ping_event(self):
+    def test_ping_event(self) -> None:
         """Testing the ReviewBoardGateway close submitted hook endpoint with
         event=ping
         """
@@ -648,23 +661,34 @@
         self.assertEqual(rsp.status_code, 200)
         self.assertEqual(rsp.content, b'')
 
-    def _test_post_commit_hook(self, tool_name, local_site=None, publish=True,
-                               expected_close_msg='Pushed to master (bbbbbbb)',
-                               **kwargs):
+    def _test_post_commit_hook(
+        self,
+        tool_name: str,
+        local_site: (LocalSite | None) = None,
+        publish: bool = True,
+        expected_close_msg: str = 'Pushed to master (bbbbbbb)',
+        **kwargs,
+    ) -> None:
         """Testing posting to a commit hook.
 
         This will simulate pushing a commit and posting the resulting webhook
         payload from RB Gateway to the handler for the hook.
 
         Args:
-            tool_name (unicode):
+            tool_name (str):
                 The name of the SCM tool to use.
 
             local_site (reviewboard.site.models.LocalSite, optional):
                 The Local Site owning the review request.
 
-            publish (bool):
+            publish (bool, optional):
                 Whether or not to use a published review request.
+
+            expected_close_msg (str, optional):
+                The expected message for the close operation.
+
+            **kwargs (dict):
+                Additional keyword arguments to pass to the commit hook.
         """
         account = self.create_hosting_account(local_site=local_site)
         repository = self.create_repository(tool_name=tool_name,
@@ -691,6 +715,7 @@
             repository_name=repository.name,
             secret=repository.get_or_create_hooks_uuid(),
             **kwargs)
+        assert response is not None
         self.assertEqual(response.status_code, 200)
 
         review_request = ReviewRequest.objects.get(pk=review_request.pk)
@@ -701,36 +726,43 @@
         changedesc = review_request.changedescs.get()
         self.assertEqual(changedesc.text, expected_close_msg)
 
-    def _post_commit_hook_payload(self, post_url, repository_name,
-                                  review_request_url, secret,
-                                  event='push', target_branch='master',
-                                  target_bookmarks=None, target_tags=None):
+    def _post_commit_hook_payload(
+        self,
+        post_url: str,
+        repository_name: str,
+        review_request_url: str,
+        secret: str,
+        event: str = 'push',
+        target_branch: (str | None) = 'master',
+        target_bookmarks: (str | None) = None,
+        target_tags: (str | None) = None,
+    ) -> _MonkeyPatchedWSGIResponse:
         """Post a payload for a hook for testing.
 
         Args:
-            post_url (unicode):
+            post_url (str):
                 The URL to post to.
 
-            repository_name (unicode):
+            repository_name (str):
                 The name of the repository.
 
-            review_request_url (unicode):
+            review_request_url (str):
                 The URL of the review request being represented in the
                 payload.
 
-            secret (unicode):
+            secret (str):
                 The HMAC secret for the message.
 
-            event (unicode, optional):
+            event (str, optional):
                 The webhook event.
 
-            target_branch (unicode, optional):
+            target_branch (str, optional):
                 The target branch to include in the payload.
 
-            target_bookmarks (unicode, optional):
+            target_bookmarks (str, optional):
                 The target Mercurial bookmarks to include in the payload.
 
-            target_tags (unicode, optional):
+            target_tags (str, optional):
                 The target tags to include in the payload.
 
         Results:
diff --git a/reviewboard/hostingsvcs/tests/test_redmine.py b/reviewboard/hostingsvcs/tests/test_redmine.py
index e050d9a1402dd0d8c15e02d1b3352ba0382f85ac..41a20d89d37abc052e491914687b11f6c5bf6cdf 100644
--- a/reviewboard/hostingsvcs/tests/test_redmine.py
+++ b/reviewboard/hostingsvcs/tests/test_redmine.py
@@ -1,20 +1,23 @@
 """Unit tests for the Redmine hosting service."""
 
+from __future__ import annotations
+
+from reviewboard.hostingsvcs.redmine import Redmine
 from reviewboard.hostingsvcs.testing import HostingServiceTestCase
 
 
-class RedmineTests(HostingServiceTestCase):
+class RedmineTests(HostingServiceTestCase[Redmine]):
     """Unit tests for the Redmine hosting service."""
 
     service_name = 'redmine'
     fixtures = ['test_scmtools']
 
-    def test_service_support(self):
+    def test_service_support(self) -> None:
         """Testing Redmine service support capabilities"""
         self.assertTrue(self.service_class.supports_bug_trackers)
         self.assertFalse(self.service_class.supports_repositories)
 
-    def test_get_bug_tracker_field(self):
+    def test_get_bug_tracker_field(self) -> None:
         """Testing Redmine.get_bug_tracker_field"""
         self.assertFalse(
             self.service_class.get_bug_tracker_requires_username())
diff --git a/reviewboard/hostingsvcs/tests/test_sourceforge.py b/reviewboard/hostingsvcs/tests/test_sourceforge.py
index 6740be4e83a32b641b3647ae622c606f1ae5bd8f..08cbaccdb60348f7b64d4da9a8094eb0cb1c25ef 100644
--- a/reviewboard/hostingsvcs/tests/test_sourceforge.py
+++ b/reviewboard/hostingsvcs/tests/test_sourceforge.py
@@ -1,19 +1,22 @@
 """Unit tests for the SourceForge hosting service."""
 
+from __future__ import annotations
+
+from reviewboard.hostingsvcs.sourceforge import SourceForge
 from reviewboard.hostingsvcs.testing import HostingServiceTestCase
 
 
-class SourceForgeTests(HostingServiceTestCase):
+class SourceForgeTests(HostingServiceTestCase[SourceForge]):
     """Unit tests for the SourceForge hosting service."""
 
     service_name = 'sourceforge'
 
-    def test_service_support(self):
+    def test_service_support(self) -> None:
         """Testing SourceForge service support capabilities"""
         self.assertTrue(self.service_class.supports_bug_trackers)
         self.assertTrue(self.service_class.supports_repositories)
 
-    def test_get_repository_fields_with_bazaar(self):
+    def test_get_repository_fields_with_bazaar(self) -> None:
         """Testing SourceForge.get_repository_fields for Bazaar"""
         self.assertEqual(
             self.get_repository_fields(
@@ -28,7 +31,7 @@
                                 'myproj'),
             })
 
-    def test_get_repository_fields_with_cvs(self):
+    def test_get_repository_fields_with_cvs(self) -> None:
         """Testing SourceForge.get_repository_fields for CVS"""
         self.assertEqual(
             self.get_repository_fields(
@@ -43,7 +46,7 @@
                 'mirror_path': 'myproj.cvs.sourceforge.net/cvsroot/myproj',
             })
 
-    def test_get_repository_fields_with_mercurial(self):
+    def test_get_repository_fields_with_mercurial(self) -> None:
         """Testing SourceForge.get_repository_fields for Mercurial"""
         self.assertEqual(
             self.get_repository_fields(
@@ -57,7 +60,7 @@
                 'mirror_path': 'ssh://myproj.hg.sourceforge.net/hgroot/myproj',
             })
 
-    def test_get_repository_fields_with_svn(self):
+    def test_get_repository_fields_with_svn(self) -> None:
         """Testing SourceForge.get_repository_fields for Subversion"""
         self.assertEqual(
             self.get_repository_fields(
diff --git a/reviewboard/hostingsvcs/tests/test_trac.py b/reviewboard/hostingsvcs/tests/test_trac.py
index ec170d8096b52d1b59e822a5562bd17d30beb380..633bbdefabb289c3f53c7dee7643c3e4a5b3a761 100644
--- a/reviewboard/hostingsvcs/tests/test_trac.py
+++ b/reviewboard/hostingsvcs/tests/test_trac.py
@@ -1,20 +1,23 @@
 """Unit tests for the Trac hosting service."""
 
+from __future__ import annotations
+
 from reviewboard.hostingsvcs.testing import HostingServiceTestCase
+from reviewboard.hostingsvcs.trac import Trac
 
 
-class TracTests(HostingServiceTestCase):
+class TracTests(HostingServiceTestCase[Trac]):
     """Unit tests for the Trac hosting service."""
 
     service_name = 'trac'
     fixtures = ['test_scmtools']
 
-    def test_service_support(self):
+    def test_service_support(self) -> None:
         """Testing Trac service support capabilities"""
         self.assertTrue(self.service_class.supports_bug_trackers)
         self.assertFalse(self.service_class.supports_repositories)
 
-    def test_get_bug_tracker_field(self):
+    def test_get_bug_tracker_field(self) -> None:
         """Testing Trac.get_bug_tracker_field"""
         self.assertFalse(
             self.service_class.get_bug_tracker_requires_username())
diff --git a/reviewboard/hostingsvcs/tests/test_unfuddle.py b/reviewboard/hostingsvcs/tests/test_unfuddle.py
index 1498a5ff0ed3524dbe1c99504b400111290f6691..c1eda606c3ee88c22bf499b936084241ea5ac2c8 100644
--- a/reviewboard/hostingsvcs/tests/test_unfuddle.py
+++ b/reviewboard/hostingsvcs/tests/test_unfuddle.py
@@ -1,15 +1,18 @@
 """Unit tests for the Unfuddle hosting service."""
 
+from __future__ import annotations
+
 from urllib.error import HTTPError
 
 from reviewboard.hostingsvcs.errors import RepositoryError
 from reviewboard.hostingsvcs.testing import HostingServiceTestCase
+from reviewboard.hostingsvcs.unfuddle import Unfuddle
 from reviewboard.scmtools.crypto_utils import (decrypt_password,
                                                encrypt_password)
 from reviewboard.scmtools.errors import FileNotFoundError
 
 
-class UnfuddleTests(HostingServiceTestCase):
+class UnfuddleTests(HostingServiceTestCase[Unfuddle]):
     """Unit tests for the Unfuddle hosting service."""
 
     service_name = 'unfuddle'
@@ -26,12 +29,12 @@
         'unfuddle_repo_name': 'myrepo',
     }
 
-    def test_service_support(self):
+    def test_service_support(self) -> None:
         """Testing Unfuddle service support capabilities"""
         self.assertTrue(self.service_class.supports_bug_trackers)
         self.assertTrue(self.service_class.supports_repositories)
 
-    def test_get_repository_fields_with_git(self):
+    def test_get_repository_fields_with_git(self) -> None:
         """Testing Unfuddle.get_repository_fields for Git"""
         self.assertEqual(
             self.get_repository_fields(
@@ -48,7 +51,7 @@
                                 'mydomain_myrepo/'),
             })
 
-    def test_get_repository_fields_with_subversion(self):
+    def test_get_repository_fields_with_subversion(self) -> None:
         """Testing Unfuddle.get_repository_fields for Subversion"""
         self.assertEqual(
             self.get_repository_fields(
@@ -65,7 +68,7 @@
                                 'mydomain_myrepo'),
             })
 
-    def test_authorize(self):
+    def test_authorize(self) -> None:
         """Testing Unfuddle.authorize"""
         hosting_account = self.create_hosting_account(data={})
 
@@ -93,7 +96,7 @@
                          'abc123')
         self.assertTrue(ctx.service.is_authorized())
 
-    def test_check_repository(self):
+    def test_check_repository(self) -> None:
         """Testing Unfuddle.check_repository"""
         payload = self.dump_json([{
             'id': 2,
@@ -114,7 +117,7 @@
                 'Accept': 'application/json',
             })
 
-    def test_check_repository_with_wrong_repo_type(self):
+    def test_check_repository_with_wrong_repo_type(self) -> None:
         """Testing Unfuddle.check_repository with wrong repo type"""
         payload = self.dump_json([{
             'id': 2,
@@ -139,7 +142,7 @@
                 'Accept': 'application/json',
             })
 
-    def test_get_file_with_svn_and_base_commit_id(self):
+    def test_get_file_with_svn_and_base_commit_id(self) -> None:
         """Testing Unfuddle.get_file with Subversion and base commit ID"""
         self._test_get_file(
             tool_name='Subversion',
@@ -147,7 +150,7 @@
             base_commit_id='456',
             expected_revision='456')
 
-    def test_get_file_with_svn_and_revision(self):
+    def test_get_file_with_svn_and_revision(self) -> None:
         """Testing Unfuddle.get_file with Subversion and revision"""
         self._test_get_file(
             tool_name='Subversion',
@@ -155,7 +158,7 @@
             base_commit_id=None,
             expected_revision='123')
 
-    def test_get_file_with_git_and_base_commit_id(self):
+    def test_get_file_with_git_and_base_commit_id(self) -> None:
         """Testing Unfuddle.get_file with Git and revision with base commit ID
         """
         self._test_get_file(
@@ -164,7 +167,7 @@
             base_commit_id='456',
             expected_revision='456')
 
-    def test_get_file_with_git_and_revision(self):
+    def test_get_file_with_git_and_revision(self) -> None:
         """Testing Unfuddle.get_file with Git and revision without base commit
         ID
         """
@@ -175,7 +178,7 @@
             expected_revision=None,
             expected_error=True)
 
-    def test_get_file_exists_with_svn_and_base_commit_id(self):
+    def test_get_file_exists_with_svn_and_base_commit_id(self) -> None:
         """Testing Unfuddle.get_file_exists with Subversion and base commit ID
         """
         self._test_get_file_exists(
@@ -185,7 +188,7 @@
             expected_revision='456',
             expected_found=True)
 
-    def test_get_file_exists_with_svn_and_revision(self):
+    def test_get_file_exists_with_svn_and_revision(self) -> None:
         """Testing Unfuddle.get_file_exists with Subversion and revision"""
         self._test_get_file_exists(
             tool_name='Subversion',
@@ -194,7 +197,7 @@
             expected_revision='123',
             expected_found=True)
 
-    def test_get_file_exists_with_svn_and_revision_not_found(self):
+    def test_get_file_exists_with_svn_and_revision_not_found(self) -> None:
         """Testing Unfuddle.get_file_exists with Subversion and revision not
         found
         """
@@ -205,7 +208,7 @@
             expected_revision='123',
             expected_found=False)
 
-    def test_get_file_exists_with_git_and_base_commit_id(self):
+    def test_get_file_exists_with_git_and_base_commit_id(self) -> None:
         """Testing Unfuddle.get_file_exists with Git and revision with base
         commit ID
         """
@@ -216,7 +219,9 @@
             expected_revision='456',
             expected_found=True)
 
-    def test_get_file_exists_with_git_and_revision_no_base_commit_id(self):
+    def test_get_file_exists_with_git_and_revision_no_base_commit_id(
+        self,
+    ) -> None:
         """Testing Unfuddle.get_file_exists with Git and revision without
         base commit ID
         """
@@ -228,7 +233,7 @@
             expected_found=False,
             expected_error=True)
 
-    def test_get_file_exists_with_git_and_revision_not_found(self):
+    def test_get_file_exists_with_git_and_revision_not_found(self) -> None:
         """Testing Unfuddle.get_file_exists with Git and revision not found"""
         self._test_get_file_exists(
             tool_name='Git',
@@ -237,21 +242,27 @@
             expected_revision='456',
             expected_found=False)
 
-    def _test_get_file(self, tool_name, revision, base_commit_id,
-                       expected_revision, expected_error=False):
+    def _test_get_file(
+        self,
+        tool_name: str,
+        revision: str,
+        base_commit_id: str,
+        expected_revision: str,
+        expected_error: bool = False,
+    ) -> None:
         """Common function for testing file fetching.
 
         Args:
-            tool_name (unicode):
+            tool_name (str):
                 The registered name of the SCMTool.
 
-            revision (unicode):
+            revision (str):
                 The revision to fetch.
 
-            base_commit_id (unicode):
+            base_commit_id (str):
                 The ID the commit is based on.
 
-            expected_revision (unicode):
+            expected_revision (str):
                 The expected revision to find in the URL.
 
             expected_error (bool, optional):
@@ -294,22 +305,28 @@
             self.assertIsInstance(result, bytes)
             self.assertEqual(result, b'My data')
 
-    def _test_get_file_exists(self, tool_name, revision, base_commit_id,
-                              expected_revision, expected_found=True,
-                              expected_error=False):
+    def _test_get_file_exists(
+        self,
+        tool_name: str,
+        revision: str,
+        base_commit_id: str,
+        expected_revision: str,
+        expected_found: bool = True,
+        expected_error: bool = False,
+    ) -> None:
         """Common function for testing file existence checks.
 
         Args:
-            tool_name (unicode):
+            tool_name (str):
                 The registered name of the SCMTool.
 
-            revision (unicode):
+            revision (str):
                 The revision to fetch.
 
-            base_commit_id (unicode):
+            base_commit_id (str):
                 The ID the commit is based on.
 
-            expected_revision (unicode):
+            expected_revision (str):
                 The expected revision to find in the URL.
 
             expected_found (bool, optional):
diff --git a/reviewboard/hostingsvcs/tests/test_version_one.py b/reviewboard/hostingsvcs/tests/test_version_one.py
index 5b8fc11c1d22f9ba6fd87fed1d2130921b9c8d2f..f4ed05bcac969a543d7213f4d624ea7ee26fc0a6 100644
--- a/reviewboard/hostingsvcs/tests/test_version_one.py
+++ b/reviewboard/hostingsvcs/tests/test_version_one.py
@@ -1,20 +1,23 @@
 """Unit tests for the VersionOne hosting service."""
 
+from __future__ import annotations
+
 from reviewboard.hostingsvcs.testing import HostingServiceTestCase
+from reviewboard.hostingsvcs.versionone import VersionOne
 
 
-class VersionOneTests(HostingServiceTestCase):
+class VersionOneTests(HostingServiceTestCase[VersionOne]):
     """Unit tests for the VersionOne hosting service."""
 
     service_name = 'versionone'
     fixtures = ['test_scmtools']
 
-    def test_service_support(self):
+    def test_service_support(self) -> None:
         """Testing VersionOne service support capabilities"""
         self.assertTrue(self.service_class.supports_bug_trackers)
         self.assertFalse(self.service_class.supports_repositories)
 
-    def test_get_bug_tracker_field(self):
+    def test_get_bug_tracker_field(self) -> None:
         """Testing VersionOne.get_bug_tracker_field"""
         self.assertFalse(
             self.service_class.get_bug_tracker_requires_username())
