diff --git a/tests/src/Build/CoreUpdateTest.php b/tests/src/Build/CoreUpdateTest.php
index 662090dc8b14b2f68a3c1b9d21fc08cc898be75d..5a3ca412c5f1db77fb5ce9363e61954bc8bc50dc 100644
--- a/tests/src/Build/CoreUpdateTest.php
+++ b/tests/src/Build/CoreUpdateTest.php
@@ -2,7 +2,7 @@
 
 namespace Drupal\Tests\automatic_updates\Build;
 
-use Drupal\Component\Serialization\Json;
+use Drupal\Composer\Composer;
 
 /**
  * Tests an end-to-end update of Drupal core.
@@ -14,153 +14,43 @@ class CoreUpdateTest extends UpdateTestBase {
   /**
    * {@inheritdoc}
    */
-  protected function createTestSite(string $template): void {
-    $dir = $this->getWorkspaceDirectory();
-
-    // Build the test site and alter its copy of core so that it thinks it's
-    // running Drupal 9.8.0, which will never actually exist in the real world.
-    // Then, prepare a secondary copy of the core code base, masquerading as
-    // Drupal 9.8.1, which will be the version of core we update to. These two
-    // versions are referenced in the fake release metadata in our fake release
-    // metadata (see fixtures/release-history/drupal.0.0.xml).
-    parent::createTestSite($template);
-    $this->setCoreVersion($this->getWebRoot() . '/core', '9.8.0');
-    $this->alterPackage($dir, $this->getConfigurationForUpdate('9.8.1'));
-
-    // Install Drupal and ensure it's using the fake release metadata to fetch
-    // information about available updates.
-    $this->installQuickStart('minimal');
-    $this->setReleaseMetadata(['drupal' => '9.8.1-security']);
-    $this->formLogin($this->adminUsername, $this->adminPassword);
-    $this->installModules([
-      'automatic_updates',
-      'automatic_updates_test',
-      'update_test',
-    ]);
-
-    // If using the drupal/recommended-project template, we don't expect there
-    // to be an .htaccess file at the project root. One would normally be
-    // generated by Composer when Package Manager or other code creates a
-    // ComposerUtility object in the active directory, except that Package
-    // Manager takes specific steps to prevent that. So, here we're just
-    // confirming that, in fact, Composer's .htaccess protection was disabled.
-    // We don't do this for the drupal/legacy-project template because its
-    // project root, which is also the document root, SHOULD contain a .htaccess
-    // generated by Drupal core.
-    // We do this check because this test uses PHP's built-in web server, which
-    // ignores .htaccess files and everything in them, so a Composer-generated
-    // .htaccess file won't cause this test to fail.
-    if ($template === 'drupal/recommended-project') {
-      $this->assertFileDoesNotExist($dir . '/.htaccess');
-    }
+  public function copyCodebase(\Iterator $iterator = NULL, $working_dir = NULL) {
+    parent::copyCodebase($iterator, $working_dir);
 
-    // Ensure that Drupal thinks we are running 9.8.0, then refresh information
-    // about available updates.
-    $this->assertCoreVersion('9.8.0');
-    $this->checkForUpdates();
-    // Ensure that an update to 9.8.1 is available.
-    $this->visit('/admin/modules/automatic-update');
-    $this->getMink()->assertSession()->pageTextContains('9.8.1');
+    // Ensure that we will install Drupal 9.8.0 (a fake version that should
+    // never exist in real life) initially.
+    $this->setUpstreamCoreVersion('9.8.0');
   }
 
   /**
    * {@inheritdoc}
    */
-  protected function tearDown(): void {
-    if ($this->destroyBuild) {
-      $this->deleteCopiedPackages();
-    }
-    parent::tearDown();
-  }
-
-  /**
-   * Modifies a Drupal core code base to set its version.
-   *
-   * @param string $dir
-   *   The directory of the Drupal core code base.
-   * @param string $version
-   *   The version number to set.
-   */
-  private function setCoreVersion(string $dir, string $version): void {
-    $this->alterPackage($dir, ['version' => $version]);
-
-    $drupal_php = "$dir/lib/Drupal.php";
-    $this->assertIsWritable($drupal_php);
-    $code = file_get_contents($drupal_php);
-    $code = preg_replace("/const VERSION = '([0-9]+\.?){3}(-dev)?';/", "const VERSION = '$version';", $code);
-    file_put_contents($drupal_php, $code);
+  public function getCodebaseFinder() {
+    // Don't copy .git directories and such, since that just slows things down.
+    // We can use ::setUpstreamCoreVersion() to explicitly set the versions of
+    // core packages required by the test site.
+    return parent::getCodebaseFinder()->ignoreVCS(TRUE);
   }
 
   /**
-   * Returns composer.json changes that are needed to update core.
-   *
-   * This will clone the following packages into temporary directories:
-   * - drupal/core
-   * - drupal/core-recommended
-   * - drupal/core-project-message
-   * - drupal/core-composer-scaffold
-   * The cloned packages will be assigned the given version number, and the test
-   * site's composer.json will use the clones as path repositories.
-   *
-   * @param string $version
-   *   The version of core we will be updating to.
-   *
-   * @return array
-   *   The changes to merge into the test site's composer.json.
+   * {@inheritdoc}
    */
-  protected function getConfigurationForUpdate(string $version): array {
-    $repositories = [];
-
-    // Create a fake version of core with the given version number, and change
-    // its README so that we can actually be certain that we update to this
-    // fake version.
-    $dir = $this->copyPackage($this->getWebRoot() . '/core');
-    $this->setCoreVersion($dir, $version);
-    file_put_contents("$dir/README.txt", "Placeholder for Drupal core $version.");
-    $repositories['drupal/core'] = $this->createPathRepository($dir);
-
-    $drupal_root = $this->getDrupalRoot();
-
-    // Create a fake version of drupal/core-recommended which itself requires
-    // the fake version of core we just created.
-    $dir = $this->copyPackage("$drupal_root/composer/Metapackage/CoreRecommended");
-    $this->alterPackage($dir, [
-      'require' => [
-        'drupal/core' => $version,
-      ],
-      'version' => $version,
-    ]);
-    $repositories['drupal/core-recommended'] = $this->createPathRepository($dir);
-
-    // Create fake target versions of core plugins and metapackages.
-    $packages = [
-      'drupal/core-dev' => "$drupal_root/composer/Metapackage/DevDependencies",
-      'drupal/core-project-message' => "$drupal_root/composer/Plugin/ProjectMessage",
-      'drupal/core-composer-scaffold' => "$drupal_root/composer/Plugin/Scaffold",
-      'drupal/core-vendor-hardening' => "$drupal_root/composer/Plugin/VendorHardening",
-    ];
-    foreach ($packages as $name => $dir) {
-      $dir = $this->copyPackage($dir);
-      $this->alterPackage($dir, ['version' => $version]);
-      $repositories[$name] = $this->createPathRepository($dir);
-    }
-
-    return [
-      'repositories' => $repositories,
-    ];
-  }
+  protected function createTestProject(string $template): void {
+    parent::createTestProject($template);
+
+    // Prepare an "upstream" version of core, 9.8.1, to which we will update.
+    // This version, along with 9.8.0 (which was installed initially), is
+    // referenced in our fake release metadata (see
+    // fixtures/release-history/drupal.0.0.xml).
+    $this->setUpstreamCoreVersion('9.8.1');
+    $this->setReleaseMetadata(['drupal' => '9.8.1-security']);
 
-  /**
-   * Data provider for end-to-end update tests.
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerTemplate(): array {
-    return [
-      ['drupal/recommended-project'],
-      ['drupal/legacy-project'],
-    ];
+    // Ensure that Drupal thinks we are running 9.8.0, then refresh information
+    // about available updates and ensure that an update to 9.8.1 is available.
+    $this->assertCoreVersion('9.8.0');
+    $this->checkForUpdates();
+    $this->visit('/admin/modules/automatic-update');
+    $this->getMink()->assertSession()->pageTextContains('9.8.1');
   }
 
   /**
@@ -172,14 +62,14 @@ class CoreUpdateTest extends UpdateTestBase {
    * @dataProvider providerTemplate
    */
   public function testApi(string $template): void {
-    $this->createTestSite($template);
+    $this->createTestProject($template);
 
     $mink = $this->getMink();
     $assert_session = $mink->assertSession();
 
     // Ensure that the update is prevented if the web root and/or vendor
     // directories are not writable.
-    $this->assertReadOnlyFileSystemError($template, '/automatic-update-test/update/9.8.1');
+    $this->assertReadOnlyFileSystemError('/automatic-update-test/update/9.8.1');
 
     $mink->getSession()->reload();
     $assert_session->pageTextContains('9.8.1');
@@ -194,7 +84,7 @@ class CoreUpdateTest extends UpdateTestBase {
    * @dataProvider providerTemplate
    */
   public function testUi(string $template): void {
-    $this->createTestSite($template);
+    $this->createTestProject($template);
 
     $mink = $this->getMink();
     $session = $mink->getSession();
@@ -207,7 +97,7 @@ class CoreUpdateTest extends UpdateTestBase {
 
     // Ensure that the update is prevented if the web root and/or vendor
     // directories are not writable.
-    $this->assertReadOnlyFileSystemError($template, parse_url($session->getCurrentUrl(), PHP_URL_PATH));
+    $this->assertReadOnlyFileSystemError(parse_url($session->getCurrentUrl(), PHP_URL_PATH));
     $session->reload();
 
     $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.');
@@ -218,7 +108,7 @@ class CoreUpdateTest extends UpdateTestBase {
     $this->waitForBatchJob();
     $assert_session->pageTextContains('Update complete!');
     $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.');
-    $this->assertUpdateSuccessful();
+    $this->assertUpdateSuccessful('9.8.1');
   }
 
   /**
@@ -230,43 +120,35 @@ class CoreUpdateTest extends UpdateTestBase {
    * @dataProvider providerTemplate
    */
   public function testCron(string $template): void {
-    $this->createTestSite($template);
+    $this->createTestProject($template);
 
     $this->visit('/admin/reports/status');
     $this->getMink()->getSession()->getPage()->clickLink('Run cron');
-    $this->assertUpdateSuccessful();
+    $this->assertUpdateSuccessful('9.8.1');
   }
 
   /**
    * Asserts that the update is prevented if the filesystem isn't writable.
    *
-   * @param string $template
-   *   The project template used to build the test site. See ::createTestSite()
-   *   for the possible values.
-   * @param string $url
+   * @param string $error_url
    *   A URL where we can see the error message which is raised when parts of
    *   the file system are not writable. This URL will be visited twice: once
    *   for the web root, and once for the vendor directory.
    */
-  private function assertReadOnlyFileSystemError(string $template, string $url): void {
+  private function assertReadOnlyFileSystemError(string $error_url): void {
     $directories = [
       'Drupal' => rtrim($this->getWebRoot(), './'),
     ];
 
     // The location of the vendor directory depends on which project template
-    // was used to build the test site.
-    if ($template === 'drupal/recommended-project') {
-      $directories['vendor'] = $this->getWorkspaceDirectory() . '/vendor';
-    }
-    elseif ($template === 'drupal/legacy-project') {
-      $directories['vendor'] = $directories['Drupal'] . '/vendor';
-    }
+    // was used to build the test site, so just ask Composer where it is.
+    $directories['vendor'] = $this->runComposer('composer config --absolute vendor-dir', 'project');
 
     $assert_session = $this->getMink()->assertSession();
     foreach ($directories as $type => $path) {
       chmod($path, 0555);
       $this->assertDirectoryIsNotWritable($path);
-      $this->visit($url);
+      $this->visit($error_url);
       $assert_session->pageTextContains("The $type directory \"$path\" is not writable.");
       chmod($path, 0755);
       $this->assertDirectoryIsWritable($path);
@@ -274,43 +156,97 @@ class CoreUpdateTest extends UpdateTestBase {
   }
 
   /**
-   * Asserts that Drupal core was successfully updated.
+   * Sets the version of Drupal core to which the test site will be updated.
+   *
+   * @param string $version
+   *   The Drupal core version to set.
+   */
+  private function setUpstreamCoreVersion(string $version): void {
+    $workspace_dir = $this->getWorkspaceDirectory();
+
+    // Loop through core's metapackages and plugins, and alter them as needed.
+    $packages = str_replace("$workspace_dir/", NULL, $this->getCorePackages());
+    foreach ($packages as $path) {
+      // Assign the new upstream version.
+      $this->runComposer("composer config version $version", $path);
+
+      // If this package requires Drupal core (e.g., drupal/core-recommended),
+      // make it require the new upstream version.
+      $info = $this->runComposer('composer info --self --format json', $path, TRUE);
+      if (isset($info['requires']['drupal/core'])) {
+        $this->runComposer("composer require --no-update drupal/core:$version", $path);
+      }
+    }
+
+    // Change the \Drupal::VERSION constant and put placeholder text in the
+    // README so we can ensure that we really updated to the correct version.
+    // @see ::assertUpdateSuccessful()
+    Composer::setDrupalVersion($workspace_dir, $version);
+    file_put_contents("$workspace_dir/core/README.txt", "Placeholder for Drupal core $version.");
+  }
+
+  /**
+   * Asserts that a specific version of Drupal core is running.
+   *
+   * Assumes that a user with permission to view the status report is logged in.
+   *
+   * @param string $expected_version
+   *   The version of core that should be running.
+   */
+  protected function assertCoreVersion(string $expected_version): void {
+    $this->visit('/admin/reports/status');
+    $item = $this->getMink()
+      ->assertSession()
+      ->elementExists('css', 'h3:contains("Drupal Version")')
+      ->getParent()
+      ->getText();
+    $this->assertStringContainsString($expected_version, $item);
+  }
+
+  /**
+   * Asserts that Drupal core was updated successfully.
+   *
+   * Assumes that a user with appropriate permissions is logged in.
+   *
+   * @param string $expected_version
+   *   The expected active version of Drupal core.
    */
-  private function assertUpdateSuccessful(): void {
+  private function assertUpdateSuccessful(string $expected_version): void {
     // The update form should not have any available updates.
     // @todo Figure out why this assertion fails when the batch processor
     //   redirects directly to the update form, instead of update.status, when
     //   updating via the UI.
     $this->visit('/admin/modules/automatic-update');
     $this->getMink()->assertSession()->pageTextContains('No update available');
-    // The status page should report that we're running Drupal 9.8.1.
-    $this->assertCoreVersion('9.8.1');
-    // The fake placeholder text from ::getConfigurationForUpdate() should be
-    // present in the README.
+
+    // The status page should report that we're running the expected version and
+    // the README should contain the placeholder text written by
+    // ::setUpstreamCoreVersion().
+    $this->assertCoreVersion($expected_version);
     $placeholder = file_get_contents($this->getWebRoot() . '/core/README.txt');
-    $this->assertSame('Placeholder for Drupal core 9.8.1.', $placeholder);
+    $this->assertSame("Placeholder for Drupal core $expected_version.", $placeholder);
+
+    $info = $this->runComposer('composer info --self --format json', 'project', TRUE);
 
-    $composer = file_get_contents($this->getWorkspaceDirectory() . '/composer.json');
-    $composer = Json::decode($composer);
     // The production dependencies should have been updated.
-    $this->assertSame('9.8.1', $composer['require']['drupal/core-recommended']);
-    $this->assertSame('9.8.1', $composer['require']['drupal/core-composer-scaffold']);
-    $this->assertSame('9.8.1', $composer['require']['drupal/core-project-message']);
+    $this->assertSame($expected_version, $info['requires']['drupal/core-recommended']);
+    $this->assertSame($expected_version, $info['requires']['drupal/core-composer-scaffold']);
+    $this->assertSame($expected_version, $info['requires']['drupal/core-project-message']);
     // The core-vendor-hardening plugin is only used by the legacy project
     // template.
-    if ($composer['name'] === 'drupal/legacy-project') {
-      $this->assertSame('9.8.1', $composer['require']['drupal/core-vendor-hardening']);
+    if ($info['name'] === 'drupal/legacy-project') {
+      $this->assertSame($expected_version, $info['requires']['drupal/core-vendor-hardening']);
     }
     // The production dependencies should not be listed as dev dependencies.
-    $this->assertArrayNotHasKey('drupal/core-recommended', $composer['require-dev']);
-    $this->assertArrayNotHasKey('drupal/core-composer-scaffold', $composer['require-dev']);
-    $this->assertArrayNotHasKey('drupal/core-project-message', $composer['require-dev']);
-    $this->assertArrayNotHasKey('drupal/core-vendor-hardening', $composer['require-dev']);
+    $this->assertArrayNotHasKey('drupal/core-recommended', $info['devRequires']);
+    $this->assertArrayNotHasKey('drupal/core-composer-scaffold', $info['devRequires']);
+    $this->assertArrayNotHasKey('drupal/core-project-message', $info['devRequires']);
+    $this->assertArrayNotHasKey('drupal/core-vendor-hardening', $info['devRequires']);
 
     // The drupal/core-dev metapackage should not be a production dependency...
-    $this->assertArrayNotHasKey('drupal/core-dev', $composer['require']);
+    $this->assertArrayNotHasKey('drupal/core-dev', $info['requires']);
     // ...but it should have been updated in the dev dependencies.
-    $this->assertSame('9.8.1', $composer['require-dev']['drupal/core-dev']);
+    $this->assertSame($expected_version, $info['devRequires']['drupal/core-dev']);
   }
 
 }
diff --git a/tests/src/Build/TemplateProjectSiteTestBase.php b/tests/src/Build/TemplateProjectSiteTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..a05c5cd98de022ca892d4ce905fadeb861af6471
--- /dev/null
+++ b/tests/src/Build/TemplateProjectSiteTestBase.php
@@ -0,0 +1,285 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Build;
+
+use Drupal\BuildTests\QuickStart\QuickStartTestBase;
+use Drupal\Composer\Composer;
+
+/**
+ * Base class for tests which create a test site from a core project template.
+ */
+abstract class TemplateProjectSiteTestBase extends QuickStartTestBase {
+
+  /**
+   * The web root of the test site, relative to the workspace directory.
+   *
+   * @var string
+   */
+  private $webRoot;
+
+  /**
+   * Data provider for tests which use all of the core project templates.
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerTemplate(): array {
+    return [
+      ['RecommendedProject'],
+      ['LegacyProject'],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function copyCodebase(\Iterator $iterator = NULL, $working_dir = NULL) {
+    parent::copyCodebase($iterator, $working_dir);
+
+    // In certain situations, like Drupal CI, automatic_updates might be
+    // required into the code base by Composer. This may cause it to be added to
+    // the drupal/core-recommended metapackage, which can prevent the test site
+    // from being built correctly, among other deleterious effects. To prevent
+    // such shenanigans, always remove drupal/automatic_updates from
+    // drupal/core-recommended.
+    $this->runComposer('composer remove --no-update drupal/automatic_updates', 'composer/Metapackage/CoreRecommended');
+  }
+
+  /**
+   * Returns the full path to the test site's document root.
+   *
+   * @return string
+   *   The full path of the test site's document root.
+   */
+  protected function getWebRoot(): string {
+    return $this->getWorkspaceDirectory() . '/' . $this->webRoot;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function instantiateServer($port, $working_dir = NULL) {
+    return parent::instantiateServer($port, $working_dir ?: $this->webRoot);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function installQuickStart($profile, $working_dir = NULL) {
+    parent::installQuickStart($profile, $working_dir ?: $this->webRoot);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function visit($request_uri = '/', $working_dir = NULL) {
+    return parent::visit($request_uri, $working_dir ?: $this->webRoot);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function formLogin($username, $password, $working_dir = NULL) {
+    parent::formLogin($username, $password, $working_dir ?: $this->webRoot);
+  }
+
+  /**
+   * Returns the paths of all core Composer packages.
+   *
+   * @return string[]
+   *   The paths of the core Composer packages, keyed by parent directory name.
+   */
+  protected function getCorePackages(): array {
+    $workspace_dir = $this->getWorkspaceDirectory();
+
+    $packages = [
+      'core' => "$workspace_dir/core",
+    ];
+    foreach (['Metapackage', 'Plugin'] as $type) {
+      foreach (Composer::composerSubprojectPaths($workspace_dir, $type) as $package) {
+        $path = $package->getPath();
+        $name = basename($path);
+        $packages[$name] = $path;
+      }
+    }
+    return $packages;
+  }
+
+  /**
+   * Creates a test project from a given template and installs Drupal.
+   *
+   * @param string $template
+   *   The template to use. Can be 'RecommendedProject' or 'LegacyProject'.
+   */
+  protected function createTestProject(string $template): void {
+    // Create a copy of core (including its Composer plugins, templates, and
+    // metapackages) which we can modify.
+    $this->copyCodebase();
+
+    $workspace_dir = $this->getWorkspaceDirectory();
+    $template_dir = "composer/Template/$template";
+
+    // Remove the packages.drupal.org entry (and any other custom repository)
+    // from the template's repositories section. We have no reliable way of
+    // knowing the repositories' names in advance, so we get that information
+    // from `composer config`, and use `composer config --unset` to actually
+    // modify the template, to ensure it's done correctly.
+    $repositories = $this->runComposer('composer config repo', $template_dir, TRUE);
+
+    foreach (array_keys($repositories) as $name) {
+      $this->runComposer("composer config --unset repo.$name", $template_dir);
+    }
+
+    // Add all core plugins and metapackages as path repositories. To disable
+    // symlinking, we need to pass the JSON representations of the repositories
+    // to `composer config`.
+    foreach ($this->getCorePackages() as $name => $path) {
+      $repository = [
+        'type' => 'path',
+        'url' => $path,
+        'options' => [
+          'symlink' => FALSE,
+        ],
+      ];
+      $repository = json_encode($repository, JSON_UNESCAPED_SLASHES);
+      $this->runComposer("composer config repo.$name '$repository'", $template_dir);
+    }
+
+    // Add a local Composer repository with all third-party dependencies.
+    $vendor = "$workspace_dir/vendor.json";
+    file_put_contents($vendor, json_encode($this->createVendorRepository(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+    $this->runComposer("composer config repo.vendor composer file://$vendor", $template_dir);
+
+    // Disable Packagist entirely so that we don't test the Internet.
+    $this->runComposer('composer config repo.packagist.org false', $template_dir);
+
+    // Create the test project, defining its repository as part of the
+    // `composer create-project` command.
+    $repository = [
+      'type' => 'path',
+      'url' => $template_dir,
+    ];
+    $command = sprintf(
+      "COMPOSER_MIRROR_PATH_REPOS=1 composer create-project %s project --stability dev --repository '%s'",
+      $this->runComposer('composer config name', $template_dir),
+      json_encode($repository, JSON_UNESCAPED_SLASHES)
+    );
+    // Because we set the COMPOSER_MIRROR_PATH_REPOS=1 environment variable when
+    // creating the project, none of the dependencies should be symlinked.
+    $this->assertStringNotContainsString('Symlinking', $this->runComposer($command));
+
+    // If using the drupal/recommended-project template, we don't expect there
+    // to be an .htaccess file at the project root. One would normally be
+    // generated by Composer when Package Manager or other code creates a
+    // ComposerUtility object in the active directory, except that Package
+    // Manager takes specific steps to prevent that. So, here we're just
+    // confirming that, in fact, Composer's .htaccess protection was disabled.
+    // We don't do this for the drupal/legacy-project template because its
+    // project root, which is also the document root, SHOULD contain a .htaccess
+    // generated by Drupal core.
+    // We do this check because this test uses PHP's built-in web server, which
+    // ignores .htaccess files and everything in them, so a Composer-generated
+    // .htaccess file won't cause this test to fail.
+    if ($template === 'RecommendedProject') {
+      $this->assertFileDoesNotExist("$workspace_dir/project/.htaccess");
+    }
+
+    // Now that we know the project was created successfully, we can set the
+    // web root with confidence.
+    $this->webRoot = 'project/' . $this->runComposer('composer config extra.drupal-scaffold.locations.web-root', 'project');
+  }
+
+  /**
+   * Creates a Composer repository for all installed third-party dependencies.
+   *
+   * @return array
+   *   The data that should be written to the repository file.
+   */
+  protected function createVendorRepository(): array {
+    $packages = [];
+    $drupal_root = $this->getDrupalRoot();
+
+    foreach ($this->getPackagesFromLockFile() as $package) {
+      $name = $package['name'];
+      $path = "$drupal_root/vendor/$name";
+
+      // We are building a set of path repositories to projects in the vendor
+      // directory, so we will skip any project that does not exist in vendor.
+      // Also skip the projects that are symlinked in vendor. These are in our
+      // metapackage and will be represented as path repositories in the test
+      // project's composer.json.
+      if (is_dir($path) && !is_link($path)) {
+        unset(
+          // Force the package to be installed from our 'dist' information.
+          $package['source'],
+          // Don't notify anybody that we're installing this package.
+          $package['notification-url'],
+          // Since Drupal 9 requires PHP 7.3 or later, these polyfills won't be
+          // installed, so we should make sure that they're not required by
+          // anything.
+          $package['require']['symfony/polyfill-php72'],
+          $package['require']['symfony/polyfill-php73']
+        );
+        // Disabling symlinks in the transport options doesn't seem to have an
+        // effect, so we use the COMPOSER_MIRROR_PATH_REPOS environment variable
+        // to force mirroring in ::createTestProject().
+        $package['dist'] = [
+          'type' => 'path',
+          'url' => $path,
+        ];
+        $version = $package['version'];
+        $packages[$name][$version] = $package;
+      }
+    }
+    return ['packages' => $packages];
+  }
+
+  /**
+   * Returns all package information from the lock file.
+   *
+   * @return array[]
+   *   All package data from the lock file.
+   */
+  private function getPackagesFromLockFile(): array {
+    $lock = $this->getDrupalRoot() . '/composer.lock';
+    $this->assertFileExists($lock);
+
+    $lock = file_get_contents($lock);
+    $lock = json_decode($lock, TRUE, JSON_THROW_ON_ERROR);
+
+    $lock += [
+      'packages' => [],
+      'packages-dev' => [],
+    ];
+    return array_merge($lock['packages'], $lock['packages-dev']);
+  }
+
+  /**
+   * Runs a Composer command and returns its output.
+   *
+   * Always asserts that the command was executed successfully.
+   *
+   * @param string $command
+   *   The command to execute, including the `composer` invocation.
+   * @param string $working_dir
+   *   (optional) A working directory relative to the workspace, within which to
+   *   execute the command. Defaults to the workspace directory.
+   * @param bool $json
+   *   (optional) Whether to parse the command's output as JSON before returning
+   *   it. Defaults to FALSE.
+   *
+   * @return mixed|string|null
+   *   The command's output, optionally parsed as JSON.
+   */
+  protected function runComposer(string $command, string $working_dir = NULL, bool $json = FALSE) {
+    $output = $this->executeCommand($command, $working_dir)->getOutput();
+    $this->assertCommandSuccessful();
+
+    $output = trim($output);
+    if ($json) {
+      $output = json_decode($output, TRUE, JSON_THROW_ON_ERROR);
+    }
+    return $output;
+  }
+
+}
diff --git a/tests/src/Build/UpdateTestBase.php b/tests/src/Build/UpdateTestBase.php
index 367e0f824153e08e0fa4362e7e7acd652c142771..907acead16dba817590e0163eedf31016f789994 100644
--- a/tests/src/Build/UpdateTestBase.php
+++ b/tests/src/Build/UpdateTestBase.php
@@ -2,22 +2,12 @@
 
 namespace Drupal\Tests\automatic_updates\Build;
 
-use Drupal\BuildTests\QuickStart\QuickStartTestBase;
-use Drupal\Component\Serialization\Json;
 use Drupal\Component\Utility\Html;
-use Drupal\Tests\automatic_updates\Traits\LocalPackagesTrait;
-use Drupal\Tests\automatic_updates\Traits\SettingsTrait;
 
 /**
  * Base class for tests that perform in-place updates.
  */
-abstract class UpdateTestBase extends QuickStartTestBase {
-
-  use LocalPackagesTrait {
-    getPackagePath as traitGetPackagePath;
-    copyPackage as traitCopyPackage;
-  }
-  use SettingsTrait;
+abstract class UpdateTestBase extends TemplateProjectSiteTestBase {
 
   /**
    * A secondary server instance, to serve XML metadata about available updates.
@@ -26,15 +16,6 @@ abstract class UpdateTestBase extends QuickStartTestBase {
    */
   private $metadataServer;
 
-  /**
-   * The test site's document root, relative to the workspace directory.
-   *
-   * @var string
-   *
-   * @see ::createTestSite()
-   */
-  private $webRoot;
-
   /**
    * {@inheritdoc}
    */
@@ -48,36 +29,52 @@ abstract class UpdateTestBase extends QuickStartTestBase {
   /**
    * {@inheritdoc}
    */
-  protected function copyPackage(string $source_dir, string $destination_dir = NULL): string {
-    return $this->traitCopyPackage($source_dir, $destination_dir ?: $this->getWorkspaceDirectory());
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function getPackagePath(array $package): string {
-    if ($package['name'] === 'drupal/core') {
-      return 'core';
-    }
+  protected function createTestProject(string $template): void {
+    parent::createTestProject($template);
 
-    [$vendor, $name] = explode('/', $package['name']);
+    // Install Automatic Updates into the test project and ensure it wasn't
+    // symlinked.
+    $dir = 'project';
+    $this->runComposer('composer config repo.automatic_updates path ' . __DIR__ . '/../../..', $dir);
+    $this->assertStringNotContainsString('Symlinking', $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require "drupal/automatic_updates:@dev"', $dir));
 
-    // Assume any contributed module is in modules/contrib/$name.
-    if ($vendor === 'drupal' && $package['type'] === 'drupal-module') {
-      return implode(DIRECTORY_SEPARATOR, ['modules', 'contrib', $name]);
-    }
-
-    return $this->traitGetPackagePath($package);
+    // Install Drupal. Always allow test modules to be installed in the UI and,
+    // for easier debugging, always display errors in their dubious glory.
+    $this->installQuickStart('minimal');
+    $php = <<<END
+\$settings['extension_discovery_scan_tests'] = TRUE;
+\$config['system.logging']['error_level'] = 'verbose';
+END;
+    $this->writeSettings($php);
+
+    // Install Automatic Updates and other modules needed for testing.
+    $this->formLogin($this->adminUsername, $this->adminPassword);
+    $this->installModules([
+      'automatic_updates',
+      'automatic_updates_test',
+      'update_test',
+    ]);
   }
 
   /**
-   * Returns the full path to the test site's document root.
+   * Appends PHP code to the test site's settings.php.
    *
-   * @return string
-   *   The full path of the test site's document root.
-   */
-  protected function getWebRoot(): string {
-    return $this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $this->webRoot;
+   * @param string $php
+   *   The PHP code to append to the test site's settings.php.
+   */
+  protected function writeSettings(string $php): void {
+    // Ensure settings are writable, since this is the only way we can set
+    // configuration values that aren't accessible in the UI.
+    $file = $this->getWebRoot() . '/sites/default/settings.php';
+    $this->assertFileExists($file);
+    chmod(dirname($file), 0744);
+    chmod($file, 0744);
+    $this->assertFileIsWritable($file);
+
+    $stream = fopen($file, 'a');
+    $this->assertIsResource($stream);
+    $this->assertIsInt(fwrite($stream, $php));
+    $this->assertTrue(fclose($stream));
   }
 
   /**
@@ -100,158 +97,12 @@ END;
     // about available updates.
     if (empty($this->metadataServer)) {
       $port = $this->findAvailablePort();
-      $this->metadataServer = $this->instantiateServer($port, $this->webRoot);
+      $this->metadataServer = $this->instantiateServer($port);
       $code .= <<<END
 \$config['update.settings']['fetch']['url'] = 'http://localhost:$port/automatic-update-test';
 END;
     }
-    $this->addSettings($code, $this->getWebRoot());
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function visit($request_uri = '/', $working_dir = NULL) {
-    return parent::visit($request_uri, $working_dir ?: $this->webRoot);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function formLogin($username, $password, $working_dir = NULL) {
-    parent::formLogin($username, $password, $working_dir ?: $this->webRoot);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function installQuickStart($profile, $working_dir = NULL) {
-    parent::installQuickStart($profile, $working_dir ?: $this->webRoot);
-
-    // Always allow test modules to be installed in the UI and, for easier
-    // debugging, always display errors in their dubious glory.
-    $php = <<<END
-\$settings['extension_discovery_scan_tests'] = TRUE;
-\$config['system.logging']['error_level'] = 'verbose';
-END;
-    $this->addSettings($php, $this->getWebRoot());
-  }
-
-  /**
-   * Uses our already-installed dependencies to build a test site to update.
-   *
-   * @param string $template
-   *   The template project from which to build the test site. Can be
-   *   'drupal/recommended-project' or 'drupal/legacy-project'.
-   */
-  protected function createTestSite(string $template): void {
-    // Create the test site using one of the core project templates, but don't
-    // install dependencies just yet.
-    $template_dir = implode(DIRECTORY_SEPARATOR, [
-      $this->getDrupalRoot(),
-      'composer',
-      'Template',
-    ]);
-    $recommended_template = $this->createPathRepository($template_dir . DIRECTORY_SEPARATOR . 'RecommendedProject');
-    $legacy_template = $this->createPathRepository($template_dir . DIRECTORY_SEPARATOR . 'LegacyProject');
-
-    $dir = $this->getWorkspaceDirectory();
-    $command = sprintf(
-      "composer create-project %s %s --no-install --stability dev --repository '%s' --repository '%s'",
-      $template,
-      $dir,
-      Json::encode($recommended_template),
-      Json::encode($legacy_template)
-    );
-    $this->executeCommand($command);
-    $this->assertCommandSuccessful();
-
-    $composer = $dir . DIRECTORY_SEPARATOR . 'composer.json';
-    $data = $this->readJson($composer);
-
-    // Allow the test to configure the test site as necessary.
-    $data = $this->getInitialConfiguration($data);
-
-    // We need to know the path of the web root, relative to the project root,
-    // in order to install Drupal or visit the test site at all. Luckily, both
-    // template projects define this because the scaffold plugin needs to know
-    // it as well.
-    // @see ::visit()
-    // @see ::formLogin()
-    // @see ::installQuickStart()
-    $this->webRoot = $data['extra']['drupal-scaffold']['locations']['web-root'];
-
-    // Update the test site's composer.json.
-    $this->writeJson($composer, $data);
-    // Install dependencies, including dev.
-    $this->executeCommand('composer install');
-    $this->assertCommandSuccessful();
-  }
-
-  /**
-   * Returns the initial data to write to the test site's composer.json.
-   *
-   * This configuration will be used to build the pre-update test site.
-   *
-   * @param array $data
-   *   The current contents of the test site's composer.json.
-   *
-   * @return array
-   *   The data that should be written to the test site's composer.json.
-   */
-  protected function getInitialConfiguration(array $data): array {
-    $drupal_root = $this->getDrupalRoot();
-    $core_composer_dir = $drupal_root . DIRECTORY_SEPARATOR . 'composer';
-    $repositories = [];
-
-    // Add all the metapackages that are provided by Drupal core.
-    $metapackage_dir = $core_composer_dir . DIRECTORY_SEPARATOR . 'Metapackage';
-    $repositories['drupal/core-recommended'] = $this->createPathRepository($metapackage_dir . DIRECTORY_SEPARATOR . 'CoreRecommended');
-    $repositories['drupal/core-dev'] = $this->createPathRepository($metapackage_dir . DIRECTORY_SEPARATOR . 'DevDependencies');
-
-    // Add all the Composer plugins that are provided by Drupal core.
-    $plugin_dir = $core_composer_dir . DIRECTORY_SEPARATOR . 'Plugin';
-    $repositories['drupal/core-project-message'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'ProjectMessage');
-    $repositories['drupal/core-composer-scaffold'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'Scaffold');
-    $repositories['drupal/core-vendor-hardening'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'VendorHardening');
-
-    $repositories = array_merge($repositories, $this->getLocalPackageRepositories($drupal_root));
-    // To ensure the test runs entirely offline, don't allow Composer to contact
-    // Packagist.
-    $repositories['packagist.org'] = FALSE;
-
-    $repositories['drupal/automatic_updates'] = $this->createPathRepository(__DIR__ . '/../../..');
-    // Use whatever the current branch of automatic_updates is.
-    $data['require']['drupal/automatic_updates'] = '*';
-
-    $data['repositories'] = $repositories;
-
-    // Since Drupal 9 requires PHP 7.3 or later, these packages are probably
-    // not installed, which can cause trouble during dependency resolution.
-    // The drupal/drupal package (defined with a composer.json that is part
-    // of core's repository) replaces these, so we need to emulate that here.
-    $data['replace']['symfony/polyfill-php72'] = '*';
-    $data['replace']['symfony/polyfill-php73'] = '*';
-
-    return $data;
-  }
-
-  /**
-   * Asserts that a specific version of Drupal core is running.
-   *
-   * Assumes that a user with permission to view the status report is logged in.
-   *
-   * @param string $expected_version
-   *   The version of core that should be running.
-   */
-  protected function assertCoreVersion(string $expected_version): void {
-    $this->visit('/admin/reports/status');
-    $item = $this->getMink()
-      ->assertSession()
-      ->elementExists('css', 'h3:contains("Drupal Version")')
-      ->getParent()
-      ->getText();
-    $this->assertStringContainsString($expected_version, $item);
+    $this->writeSettings($code);
   }
 
   /**
diff --git a/tests/src/Traits/JsonTrait.php b/tests/src/Traits/JsonTrait.php
deleted file mode 100644
index 2c216306b2cc6b3a65e047a4b2ac6aa472612436..0000000000000000000000000000000000000000
--- a/tests/src/Traits/JsonTrait.php
+++ /dev/null
@@ -1,41 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Traits;
-
-use Drupal\Component\Serialization\Json;
-use PHPUnit\Framework\Assert;
-
-/**
- * Provides assertive methods to read and write JSON data in files.
- */
-trait JsonTrait {
-
-  /**
-   * Reads JSON data from a file and returns it as an array.
-   *
-   * @param string $path
-   *   The path of the file to read.
-   *
-   * @return mixed[]
-   *   The parsed data in the file.
-   */
-  protected function readJson(string $path): array {
-    Assert::assertIsReadable($path);
-    $data = file_get_contents($path);
-    return Json::decode($data);
-  }
-
-  /**
-   * Writes an array of data to a file as JSON.
-   *
-   * @param string $path
-   *   The path of the file to write.
-   * @param array $data
-   *   The data to be written.
-   */
-  protected function writeJson(string $path, array $data): void {
-    Assert::assertIsWritable(file_exists($path) ? $path : dirname($path));
-    file_put_contents($path, Json::encode($data));
-  }
-
-}
diff --git a/tests/src/Traits/LocalPackagesTrait.php b/tests/src/Traits/LocalPackagesTrait.php
deleted file mode 100644
index a0c178fb9db2eaa55c97b40ec3a27ab8e49fe8c5..0000000000000000000000000000000000000000
--- a/tests/src/Traits/LocalPackagesTrait.php
+++ /dev/null
@@ -1,163 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Traits;
-
-use Drupal\Component\FileSystem\FileSystem;
-use Drupal\Component\Utility\NestedArray;
-use PHPUnit\Framework\Assert;
-use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem;
-
-/**
- * Provides methods for interacting with installed Composer packages.
- */
-trait LocalPackagesTrait {
-
-  use JsonTrait;
-
-  /**
-   * The paths of temporary copies of packages.
-   *
-   * @see ::copyPackage()
-   * @see ::deleteCopiedPackages()
-   *
-   * @var string[]
-   */
-  private $copiedPackages = [];
-
-  /**
-   * Returns the path of an installed package, relative to composer.json.
-   *
-   * @param array $package
-   *   The package information, as read from the lock file.
-   *
-   * @return string
-   *   The path of the installed package, relative to composer.json.
-   */
-  protected function getPackagePath(array $package): string {
-    return 'vendor' . DIRECTORY_SEPARATOR . $package['name'];
-  }
-
-  /**
-   * Deletes all copied packages.
-   *
-   * @see ::copyPackage()
-   */
-  protected function deleteCopiedPackages(): void {
-    (new SymfonyFilesystem())->remove($this->copiedPackages);
-  }
-
-  /**
-   * Copies a package's entire directory to another location.
-   *
-   * The copies' paths will be stored so that they can be easily deleted by
-   * ::deleteCopiedPackages().
-   *
-   * @param string $source_dir
-   *   The path of the package directory to copy.
-   * @param string|null $destination_dir
-   *   (optional) The directory to which the package should be copied. Will be
-   *   suffixed with a random string to ensure uniqueness. If not given, the
-   *   system temporary directory will be used.
-   *
-   * @return string
-   *   The path of the temporary copy.
-   *
-   * @see ::deleteCopiedPackages()
-   */
-  protected function copyPackage(string $source_dir, string $destination_dir = NULL): string {
-    Assert::assertDirectoryExists($source_dir);
-
-    if (empty($destination_dir)) {
-      $destination_dir = FileSystem::getOsTemporaryDirectory();
-      Assert::assertNotEmpty($destination_dir);
-      $destination_dir .= DIRECTORY_SEPARATOR;
-    }
-    $destination_dir = uniqid($destination_dir);
-    Assert::assertDirectoryDoesNotExist($destination_dir);
-
-    (new SymfonyFilesystem())->mirror($source_dir, $destination_dir);
-    array_push($this->copiedPackages, $destination_dir);
-
-    return $destination_dir;
-  }
-
-  /**
-   * Generates local path repositories for a set of installed packages.
-   *
-   * @param string $dir
-   *   The directory which contains composer.lock.
-   *
-   * @return mixed[][]
-   *   The local path repositories' configuration, for inclusion in a
-   *   composer.json file.
-   */
-  protected function getLocalPackageRepositories(string $dir): array {
-    $repositories = [];
-
-    foreach ($this->getPackagesFromLockFile($dir) as $package) {
-      // Ensure the package directory is writable, since we'll need to make a
-      // few changes to it.
-      $path = $dir . DIRECTORY_SEPARATOR . $this->getPackagePath($package);
-      Assert::assertIsWritable($path);
-      $composer = $path . DIRECTORY_SEPARATOR . 'composer.json';
-
-      // Overwrite the composer.json with the fully resolved package information
-      // from the lock file.
-      // @todo Back up composer.json before overwriting it?
-      $this->writeJson($composer, $package);
-
-      $name = $package['name'];
-      $repositories[$name] = $this->createPathRepository($path);
-    }
-    return $repositories;
-  }
-
-  /**
-   * Defines a local path repository for a given path.
-   *
-   * @param string $path
-   *   The path of the repository.
-   *
-   * @return array
-   *   The local path repository definition.
-   */
-  protected function createPathRepository(string $path): array {
-    return [
-      'type' => 'path',
-      'url' => $path,
-      'options' => [
-        'symlink' => FALSE,
-      ],
-    ];
-  }
-
-  /**
-   * Alters a package's composer.json file.
-   *
-   * @param string $package_dir
-   *   The package directory.
-   * @param array $changes
-   *   The changes to merge into composer.json.
-   */
-  protected function alterPackage(string $package_dir, array $changes): void {
-    $composer = $package_dir . DIRECTORY_SEPARATOR . 'composer.json';
-    $data = $this->readJson($composer);
-    $data = NestedArray::mergeDeep($data, $changes);
-    $this->writeJson($composer, $data);
-  }
-
-  /**
-   * Reads all package information from a composer.lock file.
-   *
-   * @param string $dir
-   *   The directory which contains the lock file.
-   *
-   * @return mixed[][]
-   *   All package information (including dev packages) from the lock file.
-   */
-  private function getPackagesFromLockFile(string $dir): array {
-    $lock = $this->readJson($dir . DIRECTORY_SEPARATOR . 'composer.lock');
-    return array_merge($lock['packages'], $lock['packages-dev']);
-  }
-
-}
diff --git a/tests/src/Traits/SettingsTrait.php b/tests/src/Traits/SettingsTrait.php
deleted file mode 100644
index b9ae2948990b758c4fda838b72f7f3af4ff3b215..0000000000000000000000000000000000000000
--- a/tests/src/Traits/SettingsTrait.php
+++ /dev/null
@@ -1,57 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Traits;
-
-use PHPUnit\Framework\Assert;
-
-/**
- * Provides methods for manipulating site settings.
- */
-trait SettingsTrait {
-
-  /**
-   * Appends some PHP code to settings.php.
-   *
-   * @param string $php
-   *   The PHP code to append to settings.php.
-   * @param string $drupal_root
-   *   The path of the Drupal root.
-   * @param string $site
-   *   (optional) The name of the site whose settings.php should be amended.
-   *   Defaults to 'default'.
-   */
-  protected function addSettings(string $php, string $drupal_root, string $site = 'default'): void {
-    $settings = $this->makeSettingsWritable($drupal_root, $site);
-    $settings = fopen($settings, 'a');
-    Assert::assertIsResource($settings);
-    Assert::assertIsInt(fwrite($settings, $php));
-    Assert::assertTrue(fclose($settings));
-  }
-
-  /**
-   * Ensures that settings.php is writable.
-   *
-   * @param string $drupal_root
-   *   The path of the Drupal root.
-   * @param string $site
-   *   (optional) The name of the site whose settings should be made writable.
-   *   Defaults to 'default'.
-   *
-   * @return string
-   *   The path to settings.php for the specified site.
-   */
-  private function makeSettingsWritable(string $drupal_root, string $site = 'default'): string {
-    $settings = implode(DIRECTORY_SEPARATOR, [
-      $drupal_root,
-      'sites',
-      $site,
-      'settings.php',
-    ]);
-    chmod(dirname($settings), 0744);
-    chmod($settings, 0744);
-    Assert::assertIsWritable($settings);
-
-    return $settings;
-  }
-
-}