diff --git a/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.info.yml b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.info.yml
index 81a15e3ea75a4d46e75392c73d1ffe786b46b377..354140b9a514670a370bf1f012e0e753f608f0bb 100644
--- a/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.info.yml
+++ b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.info.yml
@@ -4,3 +4,4 @@ type: module
 package: Testing
 dependencies:
   - automatic_updates:automatic_updates_extensions
+  - automatic_updates:package_manager_test_api
diff --git a/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.routing.yml b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.routing.yml
index c74bb67edc0dcb48ec5c5a7d628669e08a4c95ca..0c8f41c72c6752549ec9407a94fa7eaa62933714 100644
--- a/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.routing.yml
+++ b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.routing.yml
@@ -4,3 +4,9 @@ automatic_updates_extensions_test_api:
     _controller: 'Drupal\automatic_updates_extensions_test_api\ApiController::run'
   requirements:
     _access: 'TRUE'
+automatic_updates_extensions_test_api.finish:
+  path: '/automatic-updates-extensions-test-api/finish/{id}'
+  defaults:
+    _controller: 'Drupal\automatic_updates_extensions_test_api\ApiController::finish'
+  requirements:
+    _access: 'TRUE'
diff --git a/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/src/ApiController.php b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/src/ApiController.php
index 0a58fedca8e80f1af872ab9f04f256a4d4d8d29e..1a8b520068e8e900b76f812d0795daefea10d443 100644
--- a/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/src/ApiController.php
+++ b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/src/ApiController.php
@@ -2,45 +2,19 @@
 
 namespace Drupal\automatic_updates_extensions_test_api;
 
-use Drupal\automatic_updates_extensions\ExtensionUpdater;
-use Drupal\Core\Controller\ControllerBase;
-use Drupal\package_manager\PathLocator;
 use Symfony\Component\DependencyInjection\ContainerInterface;
-use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\HttpFoundation\Request;
+use Drupal\package_manager_test_api\ApiController as PackageManagerApiController;
 
 /**
  * Provides API endpoints to interact with a staging area in functional tests.
  */
-class ApiController extends ControllerBase {
-
-
-  /**
-   * The extension updater.
-   *
-   * @var \Drupal\automatic_updates_extensions\ExtensionUpdater
-   */
-  private $extensionUpdater;
+class ApiController extends PackageManagerApiController {
 
   /**
-   * The path locator service.
-   *
-   * @var \Drupal\package_manager\PathLocator
-   */
-  private $pathLocator;
-
-  /**
-   * Constructs an ApiController object.
-   *
-   * @param \Drupal\automatic_updates_extensions\ExtensionUpdater $extension_updater
-   *   The updater.
-   * @param \Drupal\package_manager\PathLocator $path_locator
-   *   The path locator service.
+   * {@inheritdoc}
    */
-  public function __construct(ExtensionUpdater $extension_updater, PathLocator $path_locator) {
-    $this->extensionUpdater = $extension_updater;
-    $this->pathLocator = $path_locator;
-  }
+  protected $finishedRoute = 'automatic_updates_extensions_test_api.finish';
 
   /**
    * {@inheritdoc}
@@ -53,36 +27,13 @@ class ApiController extends ControllerBase {
   }
 
   /**
-   * Runs a complete stage life cycle.
-   *
-   * Creates a staging area, requires packages into it, applies changes to the
-   * active directory, and destroys the stage.
-   *
-   * @param \Symfony\Component\HttpFoundation\Request $request
-   *   The request. The runtime and dev dependencies are expected to be in
-   *   either the query string or request body, under the 'runtime' and 'dev'
-   *   keys, respectively. There may also be a 'files_to_return' key, which
-   *   contains an array of file paths, relative to the project root, whose
-   *   contents should be returned in the response.
-   *
-   * @return \Symfony\Component\HttpFoundation\JsonResponse
-   *   A JSON response containing an associative array of the contents of the
-   *   files listed in the 'files_to_return' request key. The array will be
-   *   keyed by path, relative to the project root.
+   * {@inheritdoc}
    */
-  public function run(Request $request): JsonResponse {
-    $this->extensionUpdater->begin($request->get('projects', []));
-    $this->extensionUpdater->stage();
-    $this->extensionUpdater->apply();
-    $this->extensionUpdater->postApply();
-    $this->extensionUpdater->destroy();
-
-    $dir = $this->pathLocator->getProjectRoot();
-    $file_contents = [];
-    foreach ($request->get('files_to_return', []) as $path) {
-      $file_contents[$path] = file_get_contents($dir . '/' . $path);
-    }
-    return new JsonResponse($file_contents);
+  protected function createAndApplyStage(Request $request): string {
+    $id = $this->stage->begin($request->get('projects', []));
+    $this->stage->stage();
+    $this->stage->apply();
+    return $id;
   }
 
 }
diff --git a/package_manager/tests/modules/package_manager_test_api/src/ApiController.php b/package_manager/tests/modules/package_manager_test_api/src/ApiController.php
index 35269cc57eeef1255d34be8241c0cde539407ec9..9904d9f3a089c73c827324424e7421b1a62ff4a1 100644
--- a/package_manager/tests/modules/package_manager_test_api/src/ApiController.php
+++ b/package_manager/tests/modules/package_manager_test_api/src/ApiController.php
@@ -16,12 +16,19 @@ use Symfony\Component\HttpFoundation\Request;
  */
 class ApiController extends ControllerBase {
 
+  /**
+   * The route to redirect to after the stage has been applied.
+   *
+   * @var string
+   */
+  protected $finishedRoute = 'package_manager_test_api.finish';
+
   /**
    * The stage.
    *
    * @var \Drupal\package_manager\Stage
    */
-  private $stage;
+  protected $stage;
 
   /**
    * The path locator service.
@@ -83,14 +90,8 @@ class ApiController extends ControllerBase {
    * @see ::finish()
    */
   public function run(Request $request): RedirectResponse {
-    $id = $this->stage->create();
-    $this->stage->require(
-      $request->get('runtime', []),
-      $request->get('dev', [])
-    );
-    $this->stage->apply();
-
-    $redirect_url = Url::fromRoute('package_manager_test_api.finish')
+    $id = $this->createAndApplyStage($request);
+    $redirect_url = Url::fromRoute($this->finishedRoute)
       ->setRouteParameter('id', $id)
       ->setOption('query', [
         'files_to_return' => $request->get('files_to_return', []),
@@ -128,4 +129,29 @@ class ApiController extends ControllerBase {
     return new JsonResponse($file_contents);
   }
 
+  /**
+   * Creates a stage, requires packages into it, and applies the changes.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request. The runtime and dev dependencies are expected to be in
+   *   either the query string or request body, under the 'runtime' and 'dev'
+   *   keys, respectively. There may also be a 'files_to_return' key, which
+   *   contains an array of file paths, relative to the project root, whose
+   *   contents should be returned in the response.
+   *
+   * @return string
+   *   Unique ID for the stage, which can be used to claim the stage before
+   *   performing other operations on it. Calling code should store this ID for
+   *   as long as the stage needs to exist.
+   */
+  protected function createAndApplyStage(Request $request) : string {
+    $id = $this->stage->create();
+    $this->stage->require(
+      $request->get('runtime', []),
+      $request->get('dev', [])
+    );
+    $this->stage->apply();
+    return $id;
+  }
+
 }
diff --git a/package_manager/tests/src/Build/TemplateProjectTestBase.php b/package_manager/tests/src/Build/TemplateProjectTestBase.php
index 172a7de6055fa4055dc437bf0baa0e58be3a39a9..836ef51b8be2d669dda29733d705b503d8802cde 100644
--- a/package_manager/tests/src/Build/TemplateProjectTestBase.php
+++ b/package_manager/tests/src/Build/TemplateProjectTestBase.php
@@ -164,7 +164,7 @@ END;
    * @param array $xml_map
    *   The update XML map, as used by update_test.settings.
    *
-   * @see \Drupal\automatic_updates_test\TestController::metadata()
+   * @see \Drupal\package_manager_test_release_history\TestController::metadata()
    */
   protected function setReleaseMetadata(array $xml_map): void {
     foreach ($xml_map as $metadata_file) {
diff --git a/tests/modules/automatic_updates_test/automatic_updates_test.routing.yml b/tests/modules/automatic_updates_test/automatic_updates_test.routing.yml
deleted file mode 100644
index c7bc51971b7fb458f771371f6c2e54dbf93110d5..0000000000000000000000000000000000000000
--- a/tests/modules/automatic_updates_test/automatic_updates_test.routing.yml
+++ /dev/null
@@ -1,8 +0,0 @@
-automatic_updates_test.update:
-  path: '/automatic-update-test/update/{to_version}'
-  defaults:
-    _controller: '\Drupal\automatic_updates_test\TestController::update'
-  requirements:
-    _access: 'TRUE'
-  options:
-    _maintenance_access: TRUE
diff --git a/tests/modules/automatic_updates_test/src/TestController.php b/tests/modules/automatic_updates_test/src/TestController.php
deleted file mode 100644
index 192aceb520439444b3aaded513afe9c381b98d3b..0000000000000000000000000000000000000000
--- a/tests/modules/automatic_updates_test/src/TestController.php
+++ /dev/null
@@ -1,62 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates_test;
-
-use Drupal\automatic_updates\Exception\UpdateException;
-use Drupal\Core\Controller\ControllerBase;
-use Drupal\Core\Render\HtmlResponse;
-use Symfony\Component\HttpFoundation\Response;
-
-class TestController extends ControllerBase {
-
-  /**
-   * Performs an in-place update to a given version of Drupal core.
-   *
-   * This executes the update immediately, in one request, without using the
-   * batch system or cron as wrappers.
-   *
-   * @param string $to_version
-   *   The version of core to update to.
-   *
-   * @return \Symfony\Component\HttpFoundation\Response
-   *   The response object.
-   */
-  public function update(string $to_version): Response {
-
-    /** @var \Drupal\automatic_updates\Updater $updater */
-    $updater = \Drupal::service('automatic_updates.updater');
-    try {
-      $updater->begin(['drupal' => $to_version]);
-      $updater->stage();
-      $updater->apply();
-      $updater->postApply();
-      $updater->destroy();
-
-      // The code base has been updated, but as far as the PHP runtime is
-      // concerned, \Drupal::VERSION refers to the old version, until the next
-      // request. So check if the updated version is in Drupal.php and return
-      // a clear indication of whether it's there or not.
-      $drupal_php = file_get_contents(\Drupal::root() . '/core/lib/Drupal.php');
-      if (str_contains($drupal_php, "const VERSION = '$to_version';")) {
-        $content = "$to_version found in Drupal.php";
-      }
-      else {
-        $content = "$to_version not found in Drupal.php";
-      }
-      $status = 200;
-    }
-    catch (UpdateException $e) {
-      $messages = [];
-      foreach ($e->getResults() as $result) {
-        if ($summary = $result->getSummary()) {
-          $messages[] = $summary;
-        }
-        $messages = array_merge($messages, $result->getMessages());
-      }
-      $content = implode('<br />', $messages);
-      $status = 500;
-    }
-    return new HtmlResponse($content, $status);
-  }
-
-}
diff --git a/tests/modules/automatic_updates_test_api/automatic_updates_test_api.info.yml b/tests/modules/automatic_updates_test_api/automatic_updates_test_api.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9b0871e7c166dc1792f262ebe06807a2f27785f1
--- /dev/null
+++ b/tests/modules/automatic_updates_test_api/automatic_updates_test_api.info.yml
@@ -0,0 +1,6 @@
+name: 'Automatic Updates Test API'
+description: 'Provides API endpoints for doing stage operations in functional tests.'
+type: module
+package: Testing
+dependencies:
+  - automatic_updates:package_manager_test_api
diff --git a/tests/modules/automatic_updates_test_api/automatic_updates_test_api.routing.yml b/tests/modules/automatic_updates_test_api/automatic_updates_test_api.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..821c91b431953ba82f05765d2f9891034d6a5a51
--- /dev/null
+++ b/tests/modules/automatic_updates_test_api/automatic_updates_test_api.routing.yml
@@ -0,0 +1,14 @@
+automatic_updates_test_api.update:
+  path: '/automatic-updates-test-api'
+  defaults:
+    _controller: 'Drupal\automatic_updates_test_api\ApiController::run'
+  requirements:
+    _access: 'TRUE'
+  options:
+    _maintenance_access: TRUE
+automatic_updates_test_api.finish:
+  path: '/automatic-updates-test-api/finish/{id}'
+  defaults:
+    _controller: 'Drupal\automatic_updates_test_api\ApiController::finish'
+  requirements:
+    _access: 'TRUE'
diff --git a/tests/modules/automatic_updates_test_api/src/ApiController.php b/tests/modules/automatic_updates_test_api/src/ApiController.php
new file mode 100644
index 0000000000000000000000000000000000000000..855accd4d410681e577f829c4646c0b52ac15e53
--- /dev/null
+++ b/tests/modules/automatic_updates_test_api/src/ApiController.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\automatic_updates_test_api;
+
+use Drupal\package_manager_test_api\ApiController as PackageManagerApiController;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\Request;
+
+class ApiController extends PackageManagerApiController {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $finishedRoute = 'automatic_updates_test_api.finish';
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('automatic_updates.updater'),
+      $container->get('package_manager.path_locator')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createAndApplyStage(Request $request): string {
+    $id = $this->stage->begin($request->get('projects', []));
+    $this->stage->stage();
+    $this->stage->apply();
+    return $id;
+  }
+
+}
diff --git a/tests/src/Build/CoreUpdateTest.php b/tests/src/Build/CoreUpdateTest.php
index 55f1ebcfbc300865518aec16fc6d8b9416986672..3af3e281f1ca2ac49a721eca1884e07abd5f54d6 100644
--- a/tests/src/Build/CoreUpdateTest.php
+++ b/tests/src/Build/CoreUpdateTest.php
@@ -63,20 +63,28 @@ class CoreUpdateTest extends UpdateTestBase {
    */
   public function testApi(): void {
     $this->createTestProject('RecommendedProject');
-
-    $mink = $this->getMink();
-    $assert_session = $mink->assertSession();
-
+    $query = http_build_query([
+      'projects' => [
+        'drupal' => '9.8.1',
+      ],
+      'files_to_return' => [
+        'web/core/lib/Drupal.php',
+      ],
+    ]);
     // Ensure that the update is prevented if the web root and/or vendor
     // directories are not writable.
-    $this->assertReadOnlyFileSystemError('/automatic-update-test/update/9.8.1');
+    $this->assertReadOnlyFileSystemError("/automatic-updates-test-api?$query");
 
+    $mink = $this->getMink();
     $session = $mink->getSession();
     $session->reload();
-    $this->assertSame('9.8.1 found in Drupal.php', trim($session->getPage()->getContent()));
+    $mink->assertSession()->statusCodeEquals(200);
+    $file_contents = $session->getPage()->getContent();
+    $file_contents = json_decode($file_contents, TRUE, 512, JSON_THROW_ON_ERROR);
+    $this->assertStringContainsString("const VERSION = '9.8.1';", $file_contents['web/core/lib/Drupal.php']);
     // Even though the response is what we expect, assert the status code as
     // well, to be extra-certain that there was no kind of server-side error.
-    $assert_session->statusCodeEquals(200);
+
     $this->assertUpdateSuccessful('9.8.1');
   }
 
diff --git a/tests/src/Build/UpdateTestBase.php b/tests/src/Build/UpdateTestBase.php
index effacbb73f885abf6ec3c2fa2211a382796a317e..a8156a49d9c06cca20ded599d0bbb580ffc07f24 100644
--- a/tests/src/Build/UpdateTestBase.php
+++ b/tests/src/Build/UpdateTestBase.php
@@ -19,7 +19,7 @@ abstract class UpdateTestBase extends TemplateProjectTestBase {
     // Install Automatic Updates, and other modules needed for testing.
     $this->installModules([
       'automatic_updates',
-      'automatic_updates_test',
+      'automatic_updates_test_api',
       'automatic_updates_test_cron',
     ]);
   }