diff --git a/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json b/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json
index dd37230ed63052850405b84a216d0236b9108371..db52c02689e3a14b87ea68804a0a80d9aac9fd47 100644
--- a/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json
+++ b/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json
@@ -20,5 +20,6 @@
       "name": "drupal/core-dev",
       "version": "9.8.0"
     }
-  ]
+  ],
+  "dev-package-names": []
 }
diff --git a/package_manager/tests/src/Kernel/FixtureUtilityTraitTest.php b/package_manager/tests/src/Kernel/FixtureUtilityTraitTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..7b0e2ddd32fbe9a3f59563ed14e41c07b22eb85b
--- /dev/null
+++ b/package_manager/tests/src/Kernel/FixtureUtilityTraitTest.php
@@ -0,0 +1,179 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait;
+use PHPUnit\Framework\AssertionFailedError;
+
+/**
+ * @coversDefaultClass \Drupal\Tests\package_manager\Traits\FixtureUtilityTrait
+ *
+ * @group package_manager
+ */
+class FixtureUtilityTraitTest extends PackageManagerKernelTestBase {
+
+  use FixtureUtilityTrait;
+
+  /**
+   * The root directory of the virtual project.
+   *
+   * @var string
+   */
+  private string $dir;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+
+    $this->addPackage($this->dir, ['name' => 'my/package']);
+    $this->addPackage($this->dir, [
+      'name' => 'my/dev-package',
+      'version' => '2.1.0',
+      'dev_requirement' => TRUE,
+    ]);
+  }
+
+  /**
+   * @covers ::addPackage
+   */
+  public function testAddPackage(): void {
+    // Packages cannot be added without a name.
+    try {
+      $this->addPackage($this->dir, ['type' => 'unknown']);
+      $this->fail('Adding an anonymous package should raise an error.');
+    }
+    catch (AssertionFailedError $e) {
+      $this->assertSame("Failed asserting that an array has the key 'name'.", $e->getMessage());
+    }
+
+    // We should not be able to add an existing package.
+    try {
+      $this->addPackage($this->dir, ['name' => 'my/package']);
+      $this->fail('Trying to add an existing package should raise an error.');
+    }
+    catch (AssertionFailedError $e) {
+      $this->assertStringContainsString("Expected package 'my/package' to not be installed, but it was.", $e->getMessage());
+    }
+
+    $expected_packages = [
+      'my/package' => [
+        'name' => 'my/package',
+      ],
+      'my/dev-package' => [
+        'name' => 'my/dev-package',
+        'version' => '2.1.0',
+        'dev_requirement' => TRUE,
+      ],
+    ];
+    [$installed_json, $installed_php] = $this->getData();
+    $installed_json['packages'] = array_intersect_key($installed_json['packages'], $expected_packages);
+    $this->assertSame($expected_packages, $installed_json['packages']);
+    $this->assertContains('my/dev-package', $installed_json['dev-package-names']);
+    $this->assertNotContains('my/package', $installed_json['dev-package-names']);
+    $this->assertSame($expected_packages, $installed_php);
+  }
+
+  /**
+   * @covers ::modifyPackage
+   */
+  public function testModifyPackage(): void {
+    // We should not be able to modify a non-existent package.
+    try {
+      $this->modifyPackage($this->dir, 'junk/drawer', ['type' => 'library']);
+      $this->fail('Modifying a non-existent package should raise an error.');
+    }
+    catch (AssertionFailedError $e) {
+      $this->assertStringContainsString("Expected package 'junk/drawer' to be installed, but it wasn't.", $e->getMessage());
+    }
+
+    // Add a key to an existing package.
+    $this->modifyPackage($this->dir, 'my/package', ['type' => 'metapackage']);
+    // Change a key in an existing package.
+    $this->modifyPackage($this->dir, 'my/dev-package', ['version' => '3.2.1']);
+    // Move an existing package to dev requirements.
+    $this->addPackage($this->dir, [
+      'name' => 'my/other-package',
+      'type' => 'library',
+    ]);
+    $this->modifyPackage($this->dir, 'my/other-package', ['dev_requirement' => TRUE]);
+
+    $expected_packages = [
+      'my/package' => [
+        'name' => 'my/package',
+        'type' => 'metapackage',
+      ],
+      'my/dev-package' => [
+        'name' => 'my/dev-package',
+        'version' => '3.2.1',
+        'dev_requirement' => TRUE,
+      ],
+      'my/other-package' => [
+        'name' => 'my/other-package',
+        'type' => 'library',
+        'dev_requirement' => TRUE,
+      ],
+    ];
+
+    [$installed_json, $installed_php] = $this->getData();
+    $installed_json['packages'] = array_intersect_key($installed_json['packages'], $expected_packages);
+    $this->assertSame($expected_packages, $installed_json['packages']);
+    $this->assertContains('my/dev-package', $installed_json['dev-package-names']);
+    $this->assertContains('my/other-package', $installed_json['dev-package-names']);
+    $this->assertNotContains('my/package', $installed_json['dev-package-names']);
+    $this->assertSame($expected_packages, $installed_php);
+  }
+
+  /**
+   * @covers ::removePackage
+   */
+  public function testRemovePackage(): void {
+    // We should not be able to remove a package that's not installed.
+    try {
+      $this->removePackage($this->dir, 'junk/drawer');
+      $this->fail('Removing a non-existent package should raise an error.');
+    }
+    catch (AssertionFailedError $e) {
+      $this->assertStringContainsString("Expected package 'junk/drawer' to be installed, but it wasn't.", $e->getMessage());
+    }
+
+    $this->removePackage($this->dir, 'my/package');
+    $this->removePackage($this->dir, 'my/dev-package');
+
+    foreach (['json', 'php'] as $extension) {
+      $contents = file_get_contents("$this->dir/vendor/composer/installed.$extension");
+      $this->assertStringNotContainsString('my/package', $contents);
+      $this->assertStringNotContainsString('my/dev-package', $contents);
+    }
+  }
+
+  /**
+   * Returns the data from installed.php and installed.json.
+   *
+   * @return array[]
+   *   An array of two arrays. The first array will be the contents of
+   *   installed.json, with the `packages` array keyed by package name. The
+   *   second array will be the `versions` array from installed.php.
+   */
+  private function getData(): array {
+    $installed_json = file_get_contents("$this->dir/vendor/composer/installed.json");
+    $installed_json = json_decode($installed_json, TRUE, 512, JSON_THROW_ON_ERROR);
+
+    $keyed_packages = [];
+    foreach ($installed_json['packages'] as $package) {
+      $keyed_packages[$package['name']] = $package;
+    }
+    $installed_json['packages'] = $keyed_packages;
+
+    $installed_php = require "$this->dir/vendor/composer/installed.php";
+    return [
+      $installed_json,
+      $installed_php['versions'],
+    ];
+  }
+
+}
diff --git a/package_manager/tests/src/Traits/FixtureUtilityTrait.php b/package_manager/tests/src/Traits/FixtureUtilityTrait.php
index e7b66acb6b948c51cdd7ebcb5036f1e07f04747b..1ca27372acaf305b1e130486a13d141b6d66a500 100644
--- a/package_manager/tests/src/Traits/FixtureUtilityTrait.php
+++ b/package_manager/tests/src/Traits/FixtureUtilityTrait.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\Tests\package_manager\Traits;
 
+use Drupal\Component\Utility\NestedArray;
 use Symfony\Component\Filesystem\Filesystem;
 use Symfony\Component\Finder\Iterator\RecursiveDirectoryIterator;
 
@@ -76,4 +77,126 @@ trait FixtureUtilityTrait {
     }
   }
 
+  /**
+   * Adds a package.
+   *
+   * @param string $dir
+   *   The root Composer-managed directory (e.g., the project root or staging
+   *   area).
+   * @param array $package
+   *   The package info that should be added to installed.json and
+   *   installed.php. Must include a `name` key.
+   */
+  protected function addPackage(string $dir, array $package): void {
+    $this->assertArrayHasKey('name', $package);
+    $this->setPackage($dir, $package['name'], $package, FALSE);
+  }
+
+  /**
+   * Modifies a package's installed info.
+   *
+   * @param string $dir
+   *   The root Composer-managed directory (e.g., the project root or staging
+   *   area).
+   * @param string $name
+   *   The name of the package to modify.
+   * @param array $package
+   *   The package info that should be updated in installed.json and
+   *   installed.php.
+   */
+  protected function modifyPackage(string $dir, string $name, array $package): void {
+    $this->setPackage($dir, $name, $package, TRUE);
+  }
+
+  /**
+   * Removes a package.
+   *
+   * @param string $dir
+   *   The root Composer-managed directory (e.g., the project root or staging
+   *   area).
+   * @param string $name
+   *   The name of the package to remove.
+   */
+  protected function removePackage(string $dir, string $name): void {
+    $this->setPackage($dir, $name, NULL, TRUE);
+  }
+
+  /**
+   * Changes a package's installation information in a particular directory.
+   *
+   * This function is internal and should not be called directly. Use
+   * ::addPackage(), ::modifyPackage(), and ::removePackage() instead.
+   *
+   * @param string $dir
+   *   The root Composer-managed directory (e.g., the project root or staging
+   *   area).
+   * @param string $name
+   *   The name of the package to add, update, or remove.
+   * @param array|null $package
+   *   The package information to be set in installed.json and installed.php, or
+   *   NULL to remove it. Will be merged into the existing information if the
+   *   package is already installed.
+   * @param bool $should_exist
+   *   Whether or not the package is expected to already be installed.
+   */
+  private function setPackage(string $dir, string $name, ?array $package, bool $should_exist): void {
+    $file = $dir . '/vendor/composer/installed.json';
+    $this->assertFileIsWritable($file);
+
+    $data = file_get_contents($file);
+    $data = json_decode($data, TRUE, 512, JSON_THROW_ON_ERROR);
+
+    // If the package is already installed, find its numerical index.
+    $position = NULL;
+    for ($i = 0; $i < count($data['packages']); $i++) {
+      if ($data['packages'][$i]['name'] === $name) {
+        $position = $i;
+        break;
+      }
+    }
+    // Ensure that we actually expect to find the package already installed (or
+    // not).
+    $message = $should_exist
+      ? "Expected package '$name' to be installed, but it wasn't."
+      : "Expected package '$name' to not be installed, but it was.";
+    $this->assertSame($should_exist, isset($position), $message);
+
+    if (isset($position)) {
+      // If we're going to be updating the package data, merge the incoming data
+      // into what we already have.
+      if ($package) {
+        $package = NestedArray::mergeDeep($data['packages'][$position], $package);
+      }
+
+      // Remove the existing package; the array will be re-keyed by
+      // array_splice().
+      array_splice($data['packages'], $position, 1);
+      $data['dev-package-names'] = array_diff($data['dev-package-names'], [$name]);
+      $data['dev-package-names'] = array_values($data['dev-package-names']);
+    }
+    // Add the package back to the list, if we have data for it.
+    if ($package) {
+      $package['name'] = $name;
+      $data['packages'][] = $package;
+
+      if (!empty($package['dev_requirement'])) {
+        $data['dev-package-names'][] = $name;
+      }
+    }
+    file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+
+    $file = $dir . '/vendor/composer/installed.php';
+    $this->assertFileIsWritable($file);
+
+    $data = require $file;
+    if ($package) {
+      $data['versions'][$name] = $package;
+    }
+    else {
+      unset($data['versions'][$name]);
+    }
+    $data = var_export($data, TRUE);
+    file_put_contents($file, "<?php\nreturn $data;");
+  }
+
 }
diff --git a/tests/fixtures/StagedProjectsValidatorTest/README.md b/tests/fixtures/StagedProjectsValidatorTest/README.md
deleted file mode 100644
index b29def63dd8293e7831b00c2dccca4007279ec6d..0000000000000000000000000000000000000000
--- a/tests/fixtures/StagedProjectsValidatorTest/README.md
+++ /dev/null
@@ -1,30 +0,0 @@
-# `StagedProjectsValidatorTest` Fixtures
-
-### new_project_added
-Simulates a scenario where, while updating Drupal core in a site with no non-core dependencies, a new contrib module and
-a new custom module are unexpectedly installed (as runtime and dev dependencies, respectively). Additionally, two new
-non-Drupal packages are installed (again, one as a runtime dependency, the other dev).
-
-**Expectation**: The validator should complain about the new modules; the new non-Drupal packages are ignored.
-
-### no_errors
-Simulates a scenario where, while updating Drupal core in a site with two unpinned contrib dependencies (one runtime and
-one dev), no Drupal packages are updated, but two non-Drupal libraries are removed (again, one a runtime dependency, the
-other dev), two are updated (same arrangement), and two are added (ditto).
-
-**Expectation**: The validator to raise no errors; changes to non-Drupal packages are ignored.
-
-### project_removed
-Simulates a scenario where, while updating Drupal core in a site with no non-core dependencies, an installed contrib
-theme and an installed custom theme are unexpectedly removed (from runtime and dev dependencies, respectively).
-Additionally, two installed non-Drupal packages are removed (again, one from a runtime dependency, the other dev). The 
-existing contrib dependencies' installed versions are unchanged.
-
-**Expectation**: The validator should complain about the removed themes; the removed non-Drupal packages are ignored.
-
-### version_changed
-Simulates a scenario where, while updating Drupal core in a site with two unpinned contrib dependencies (one runtime and
-one dev), the contrib modules are unexpectedly updated, as are two installed non-Drupal packages (again, one a runtime
-dependency, the other dev).
-
-**Expectation**: The validator should complain about the updated modules; the updated non-Drupal packages are ignored.
diff --git a/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/vendor/composer/installed.json b/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/vendor/composer/installed.json
deleted file mode 100644
index acd9250ec7417f1acd9c5b5f2e63b970bc6f5d1e..0000000000000000000000000000000000000000
--- a/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/vendor/composer/installed.json
+++ /dev/null
@@ -1,57 +0,0 @@
-{
-  "_readme": [
-    "This file simulates a list of packages installed in a virtual staging area.",
-    "It will be compared against active.installed.json.",
-    "See \\Drupal\\Tests\\automatic_updates\\Kernel\\StatusCheck\\StagedProjectsValidatorTest::testErrors()"
-  ],
-  "packages": [
-    {
-      "name": "drupal/core",
-      "version": "9.8.1",
-      "type": "drupal-core",
-      "extra": {
-        "drupal-scaffold": {
-          "file-mapping": {}
-        }
-      }
-    },
-    {
-      "name": "drupal/test_module",
-      "version": "1.3.0",
-      "type": "drupal-module"
-    },
-    {
-      "name": "drupal/test_module2",
-      "version": "1.3.1",
-      "type": "drupal-module"
-    },
-    {
-      "name": "other/new_project",
-      "description": "This is newly added project but there should be no error because it is not a drupal project",
-      "version": "1.3.1",
-      "type": "library"
-    },
-    {
-      "name": "drupal/dev-test_module",
-      "version": "1.3.0",
-      "type": "drupal-module"
-    },
-    {
-      "name": "drupal/dev-test_module2",
-      "version": "1.3.1",
-      "type": "drupal-custom-module"
-    },
-    {
-      "name": "other/dev-new_project",
-      "description": "This is newly added project but there should be no error because it is not a drupal project",
-      "version": "1.3.1",
-      "type": "library"
-    }
-  ],
-  "dev": true,
-  "dev-package-names": [
-    "drupal/dev-test_module",
-    "drupal/dev-test_module2",
-    "other/dev-new_project"
-  ]
-}
diff --git a/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/vendor/composer/installed.php b/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/vendor/composer/installed.php
deleted file mode 100644
index 8871c50abbdf000b8512f6056dfd601dbc0d3bc7..0000000000000000000000000000000000000000
--- a/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/vendor/composer/installed.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-
-/**
- * @file
- */
-
-$projects_dir = __DIR__ . '/../../modules';
-return [
-  'versions' => [
-    'drupal/test_module' => [
-      'type' => 'drupal-module',
-      'install_path' => $projects_dir . '/test_module',
-    ],
-    'drupal/dev-test_module' => [
-      'type' => 'drupal-module',
-      'install_path' => $projects_dir . '/dev-test_module',
-    ],
-    'drupal/test_module2' => [
-      'type' => 'drupal-module',
-      'install_path' => $projects_dir . '/test_module2',
-    ],
-    'drupal/dev-test_module2' => [
-      'type' => 'drupal-module',
-      'install_path' => $projects_dir . '/dev-test_module2',
-    ],
-    'other/new_project' => [
-      'type' => 'library',
-      'install_path' => __DIR__ . '/../../new_project',
-    ],
-    'other/dev-new_project' => [
-      'type' => 'library',
-      'install_path' => __DIR__ . '/../../dev-new_project',
-    ],
-  ],
-];
diff --git a/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/vendor/composer/installed.json b/tests/fixtures/StagedProjectsValidatorTest/new_project_added/vendor/composer/installed.json
similarity index 100%
rename from tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/vendor/composer/installed.json
rename to tests/fixtures/StagedProjectsValidatorTest/new_project_added/vendor/composer/installed.json
diff --git a/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/vendor/composer/installed.php b/tests/fixtures/StagedProjectsValidatorTest/new_project_added/vendor/composer/installed.php
similarity index 56%
rename from tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/vendor/composer/installed.php
rename to tests/fixtures/StagedProjectsValidatorTest/new_project_added/vendor/composer/installed.php
index 249cef05ef6c642431cdb14d90f3906ec4abb8d2..0111e9a997ed25cb6f8a8738a90d5b2d80111a44 100644
--- a/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/vendor/composer/installed.php
+++ b/tests/fixtures/StagedProjectsValidatorTest/new_project_added/vendor/composer/installed.php
@@ -4,16 +4,15 @@
  * @file
  */
 
-$projects_dir = __DIR__ . '/../../modules';
 return [
   'versions' => [
     'drupal/test_module' => [
       'type' => 'drupal-module',
-      'install_path' => $projects_dir . '/test_module',
+      'install_path' => '../../modules/test_module',
     ],
     'drupal/dev-test_module' => [
       'type' => 'drupal-module',
-      'install_path' => $projects_dir . '/dev-test_module',
+      'install_path' => '../../modules/dev-test_module',
     ],
   ],
 ];
diff --git a/tests/fixtures/StagedProjectsValidatorTest/no_errors/staged/vendor/composer/installed.json b/tests/fixtures/StagedProjectsValidatorTest/no_errors/staged/vendor/composer/installed.json
deleted file mode 100644
index 73ab701d96ca313a200680b99203ae68fbfde5d2..0000000000000000000000000000000000000000
--- a/tests/fixtures/StagedProjectsValidatorTest/no_errors/staged/vendor/composer/installed.json
+++ /dev/null
@@ -1,59 +0,0 @@
-{
-  "_readme": [
-    "This file simulates a list of packages installed in a virtual staging area.",
-    "It will be compared against active.installed.json.",
-    "See \\Drupal\\Tests\\automatic_updates\\Kernel\\StatusCheck\\StagedProjectsValidatorTest::testErrors()"
-  ],
-  "packages": [
-    {
-      "name": "drupal/core",
-      "version": "9.8.1",
-      "type": "drupal-core",
-      "extra": {
-        "drupal-scaffold": {
-          "file-mapping": {}
-        }
-      }
-    },
-    {
-      "name": "drupal/test_module",
-      "version": "1.3.0",
-      "type": "drupal-module"
-    },
-    {
-      "name": "other/new_project",
-      "description": "This is newly added project but there should be no error because it is not a drupal project",
-      "version": "1.3.1",
-      "type": "library"
-    },
-    {
-      "name": "other/changed",
-      "description": "This project version is changed but there should be no error because it is not a Drupal project.",
-      "version": "1.3.2",
-      "type": "library"
-    },
-    {
-      "name": "drupal/dev-test_module",
-      "version": "1.3.0",
-      "type": "drupal-module"
-    },
-    {
-      "name": "other/dev-new_project",
-      "description": "This is newly added project but there should be no error because it is not a drupal project",
-      "version": "1.3.1",
-      "type": "library"
-    },
-    {
-      "name": "other/dev-changed",
-      "description": "This project version is changed but there should be no error because it is not a Drupal project.",
-      "version": "1.3.2",
-      "type": "library"
-    }
-  ],
-  "dev": true,
-  "dev-package-names": [
-    "drupal/dev-test_module",
-    "other/dev-new_project",
-    "other/dev-changed"
-  ]
-}
diff --git a/tests/fixtures/StagedProjectsValidatorTest/no_errors/staged/vendor/composer/installed.php b/tests/fixtures/StagedProjectsValidatorTest/no_errors/staged/vendor/composer/installed.php
deleted file mode 100644
index 706c6fd4632f0d344dae40097c4aae3fade04d70..0000000000000000000000000000000000000000
--- a/tests/fixtures/StagedProjectsValidatorTest/no_errors/staged/vendor/composer/installed.php
+++ /dev/null
@@ -1,20 +0,0 @@
-<?php
-
-/**
- * @file
- * Simulates that 2 packages are installed in virtual staging area.
- */
-
-$projects_dir = __DIR__ . '/../../';
-return [
-  'versions' => [
-    'other/new_project' => [
-      'type' => 'library',
-      'install_path' => $projects_dir . '/other/new_project',
-    ],
-    'other/dev-new_project' => [
-      'type' => 'library',
-      'install_path' => $projects_dir . '/other/dev-new_project',
-    ],
-  ],
-];
diff --git a/tests/fixtures/StagedProjectsValidatorTest/no_errors/active/vendor/composer/installed.json b/tests/fixtures/StagedProjectsValidatorTest/no_errors/vendor/composer/installed.json
similarity index 100%
rename from tests/fixtures/StagedProjectsValidatorTest/no_errors/active/vendor/composer/installed.json
rename to tests/fixtures/StagedProjectsValidatorTest/no_errors/vendor/composer/installed.json
diff --git a/tests/fixtures/StagedProjectsValidatorTest/project_removed/staged/vendor/composer/installed.json b/tests/fixtures/StagedProjectsValidatorTest/project_removed/staged/vendor/composer/installed.json
deleted file mode 100644
index d981720fe56b12bfb67cb950baaa87081384ad5f..0000000000000000000000000000000000000000
--- a/tests/fixtures/StagedProjectsValidatorTest/project_removed/staged/vendor/composer/installed.json
+++ /dev/null
@@ -1,33 +0,0 @@
-{
-  "_readme": [
-    "This file simulates a list of packages installed in a virtual staging area.",
-    "It will be compared against active.installed.json.",
-    "See \\Drupal\\Tests\\automatic_updates\\Kernel\\StatusCheck\\StagedProjectsValidatorTest::testErrors()"
-  ],
-  "packages": [
-    {
-      "name": "drupal/core",
-      "version": "9.8.1",
-      "type": "drupal-core",
-      "extra": {
-        "drupal-scaffold": {
-          "file-mapping": {}
-        }
-      }
-    },
-    {
-      "name": "drupal/test_module2",
-      "version": "1.3.1",
-      "type": "drupal-module"
-    },
-    {
-      "name": "drupal/dev-test_module2",
-      "version": "1.3.1",
-      "type": "drupal-module"
-    }
-  ],
-  "dev": true,
-  "dev-package-names": [
-    "drupal/dev-test_module2"
-  ]
-}
diff --git a/tests/fixtures/StagedProjectsValidatorTest/project_removed/active/vendor/composer/installed.json b/tests/fixtures/StagedProjectsValidatorTest/project_removed/vendor/composer/installed.json
similarity index 100%
rename from tests/fixtures/StagedProjectsValidatorTest/project_removed/active/vendor/composer/installed.json
rename to tests/fixtures/StagedProjectsValidatorTest/project_removed/vendor/composer/installed.json
diff --git a/tests/fixtures/StagedProjectsValidatorTest/version_changed/staged/vendor/composer/installed.json b/tests/fixtures/StagedProjectsValidatorTest/version_changed/staged/vendor/composer/installed.json
deleted file mode 100644
index 574c593b4245a11881a682ff8a84828c8e6f2772..0000000000000000000000000000000000000000
--- a/tests/fixtures/StagedProjectsValidatorTest/version_changed/staged/vendor/composer/installed.json
+++ /dev/null
@@ -1,46 +0,0 @@
-{
-  "_readme": [
-    "This file simulates a list of packages installed in a virtual staging area.",
-    "It will be compared against active.installed.json.",
-    "See \\Drupal\\Tests\\automatic_updates\\Kernel\\StatusCheck\\StagedProjectsValidatorTest::testErrors()"
-  ],
-  "packages": [
-    {
-      "name": "drupal/core",
-      "version": "9.8.1",
-      "type": "drupal-core",
-      "extra": {
-        "drupal-scaffold": {
-          "file-mapping": {}
-        }
-      }
-    },
-    {
-      "name": "drupal/test_module",
-      "version": "1.3.1",
-      "type": "drupal-module"
-    },
-    {
-      "name": "other/changed",
-      "description": "This project version is changed but there should be no error because it is not a Drupal project.",
-      "version": "1.3.2",
-      "type": "library"
-    },
-    {
-      "name": "drupal/dev-test_module",
-      "version": "1.3.1",
-      "type": "drupal-module"
-    },
-    {
-      "name": "other/dev-changed",
-      "description": "This project version is changed but there should be no error because it is not a Drupal project.",
-      "version": "1.3.2",
-      "type": "library"
-    }
-  ],
-  "dev": true,
-  "dev-package-names": [
-    "drupal/dev-test_module",
-    "other/dev-changed"
-  ]
-}
diff --git a/tests/fixtures/StagedProjectsValidatorTest/version_changed/active/vendor/composer/installed.json b/tests/fixtures/StagedProjectsValidatorTest/version_changed/vendor/composer/installed.json
similarity index 100%
rename from tests/fixtures/StagedProjectsValidatorTest/version_changed/active/vendor/composer/installed.json
rename to tests/fixtures/StagedProjectsValidatorTest/version_changed/vendor/composer/installed.json
diff --git a/tests/src/Kernel/StatusCheck/StagedProjectsValidatorTest.php b/tests/src/Kernel/StatusCheck/StagedProjectsValidatorTest.php
index 778f5f3282acab6b6af9c477880811f92509edab..13d7f2b6a99df7acd0bac6b5099be4c01061b07a 100644
--- a/tests/src/Kernel/StatusCheck/StagedProjectsValidatorTest.php
+++ b/tests/src/Kernel/StatusCheck/StagedProjectsValidatorTest.php
@@ -6,6 +6,7 @@ use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\Exception\StageValidationException;
 use Drupal\package_manager\ValidationResult;
 use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
+use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait;
 
 /**
  * @covers \Drupal\automatic_updates\Validator\StagedProjectsValidator
@@ -14,6 +15,8 @@ use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
  */
 class StagedProjectsValidatorTest extends AutomaticUpdatesKernelTestBase {
 
+  use FixtureUtilityTrait;
+
   /**
    * {@inheritdoc}
    */
@@ -29,28 +32,6 @@ class StagedProjectsValidatorTest extends AutomaticUpdatesKernelTestBase {
     parent::setUp();
   }
 
-  /**
-   * Asserts a set of validation results when staged changes are applied.
-   *
-   * @param \Drupal\package_manager\ValidationResult[] $expected_results
-   *   The expected validation results.
-   */
-  private function validate(array $expected_results): void {
-    /** @var \Drupal\automatic_updates\Updater $updater */
-    $updater = $this->container->get('automatic_updates.updater');
-    $updater->begin(['drupal' => '9.8.1']);
-    $updater->stage();
-
-    try {
-      $updater->apply();
-      // If no exception occurs, ensure we weren't expecting any errors.
-      $this->assertEmpty($expected_results);
-    }
-    catch (StageValidationException $e) {
-      $this->assertValidationResultsEqual($expected_results, $e->getResults());
-    }
-  }
-
   /**
    * Tests that exceptions are turned into validation errors.
    */
@@ -72,75 +53,188 @@ class StagedProjectsValidatorTest extends AutomaticUpdatesKernelTestBase {
     $this->container->get('event_dispatcher')
       ->addListener(PreApplyEvent::class, $listener, PHP_INT_MAX);
 
-    $this->validate([
-      ValidationResult::createError(["Composer could not find the config file: $composer_json\n"]),
+    /** @var \Drupal\automatic_updates\Updater $updater */
+    $updater = $this->container->get('automatic_updates.updater');
+    $updater->begin(['drupal' => '9.8.1']);
+    $updater->stage();
+
+    $error = ValidationResult::createError(["Composer could not find the config file: $composer_json\n"]);
+    try {
+      $updater->apply();
+      $this->fail('Expected an error, but none was raised.');
+    }
+    catch (StageValidationException $e) {
+      $this->assertValidationResultsEqual([$error], $e->getResults());
+    }
+  }
+
+  /**
+   * Tests that an error is raised if Drupal extensions are unexpectedly added.
+   */
+  public function testProjectsAdded(): void {
+    $this->copyFixtureFolderToActiveDirectory(__DIR__ . '/../../../fixtures/StagedProjectsValidatorTest/new_project_added');
+
+    $updater = $this->container->get('automatic_updates.updater');
+    $updater->begin(['drupal' => '9.8.1']);
+    $updater->stage();
+
+    $stage_dir = $updater->getStageDirectory();
+    $this->addPackage($stage_dir, [
+      'name' => 'drupal/test_module2',
+      'version' => '1.3.1',
+      'type' => 'drupal-module',
+      'install_path' => '../../modules/test_module2',
+    ]);
+    $this->addPackage($stage_dir, [
+      'name' => 'drupal/dev-test_module2',
+      'version' => '1.3.1',
+      'type' => 'drupal-custom-module',
+      'dev_requirement' => TRUE,
+      'install_path' => '../../modules/dev-test_module2',
+    ]);
+    // The validator shouldn't complain about these packages being added or
+    // removed, since it only cares about Drupal modules and themes.
+    $this->addPackage($stage_dir, [
+      'name' => 'other/new_project',
+      'version' => '1.3.1',
+      'type' => 'library',
+      'install_path' => '../other/new_project',
     ]);
+    $this->addPackage($stage_dir, [
+      'name' => 'other/dev-new_project',
+      'version' => '1.3.1',
+      'type' => 'library',
+      'dev_requirement' => TRUE,
+      'install_path' => '../other/dev-new_project',
+    ]);
+    $this->removePackage($stage_dir, 'other/removed');
+    $this->removePackage($stage_dir, 'other/dev-removed');
+
+    $messages = [
+      "module 'drupal/test_module2' installed.",
+      "custom module 'drupal/dev-test_module2' installed.",
+    ];
+    $error = ValidationResult::createError($messages, t('The update cannot proceed because the following Drupal projects were installed during the update.'));
+    try {
+      $updater->apply();
+      $this->fail('Expected an error, but none was raised.');
+    }
+    catch (StageValidationException $e) {
+      $this->assertValidationResultsEqual([$error], $e->getResults());
+    }
   }
 
   /**
-   * Tests validation errors, or lack thereof.
-   *
-   * @param string $root_fixture_directory
-   *   A directory containing to fixtures sub-directories, 'active' and
-   *   'staged'.
-   * @param string|null $expected_summary
-   *   The expected error summary, or NULL if no errors are expected.
-   * @param string[] $expected_messages
-   *   The expected error messages, if any.
-   *
-   * @dataProvider providerErrors
+   * Tests that errors are raised if Drupal extensions are unexpectedly removed.
    */
-  public function testErrors(string $root_fixture_directory, ?string $expected_summary, array $expected_messages): void {
-    $this->copyFixtureFolderToActiveDirectory("$root_fixture_directory/active");
-    $this->copyFixtureFolderToStageDirectoryOnApply("$root_fixture_directory/staged");
-
-    $expected_results = [];
-    if ($expected_messages) {
-      // @codingStandardsIgnoreLine
-      $expected_results[] = ValidationResult::createError($expected_messages, t($expected_summary));
+  public function testProjectsRemoved(): void {
+    $this->copyFixtureFolderToActiveDirectory(__DIR__ . '/../../../fixtures/StagedProjectsValidatorTest/project_removed');
+
+    $updater = $this->container->get('automatic_updates.updater');
+    $updater->begin(['drupal' => '9.8.1']);
+    $updater->stage();
+
+    $stage_dir = $updater->getStageDirectory();
+    $this->removePackage($stage_dir, 'drupal/test_theme');
+    $this->removePackage($stage_dir, 'drupal/dev-test_theme');
+    // The validator shouldn't complain about these packages being removed,
+    // since it only cares about Drupal modules and themes.
+    $this->removePackage($stage_dir, 'other/removed');
+    $this->removePackage($stage_dir, 'other/dev-removed');
+
+    $messages = [
+      "theme 'drupal/test_theme' removed.",
+      "custom theme 'drupal/dev-test_theme' removed.",
+    ];
+    $error = ValidationResult::createError($messages, t('The update cannot proceed because the following Drupal projects were removed during the update.'));
+    try {
+      $updater->apply();
+      $this->fail('Expected an error, but none was raised.');
+    }
+    catch (StageValidationException $e) {
+      $this->assertValidationResultsEqual([$error], $e->getResults());
     }
-    $this->validate($expected_results);
   }
 
   /**
-   * Data provider for testErrors().
-   *
-   * @return \string[][]
-   *   The test cases.
+   * Tests that errors are raised if Drupal extensions are unexpectedly updated.
    */
-  public function providerErrors(): array {
-    $fixtures_folder = __DIR__ . '/../../../fixtures/StagedProjectsValidatorTest';
-    return [
-      'new_project_added' => [
-        "$fixtures_folder/new_project_added",
-        'The update cannot proceed because the following Drupal projects were installed during the update.',
-        [
-          "module 'drupal/test_module2' installed.",
-          "custom module 'drupal/dev-test_module2' installed.",
-        ],
-      ],
-      'project_removed' => [
-        "$fixtures_folder/project_removed",
-        'The update cannot proceed because the following Drupal projects were removed during the update.',
-        [
-          "theme 'drupal/test_theme' removed.",
-          "custom theme 'drupal/dev-test_theme' removed.",
-        ],
-      ],
-      'version_changed' => [
-        "$fixtures_folder/version_changed",
-        'The update cannot proceed because the following Drupal projects were unexpectedly updated. Only Drupal Core updates are currently supported.',
-        [
-          "module 'drupal/test_module' from 1.3.0 to 1.3.1.",
-          "module 'drupal/dev-test_module' from 1.3.0 to 1.3.1.",
-        ],
-      ],
-      'no_errors' => [
-        "$fixtures_folder/no_errors",
-        NULL,
-        [],
-      ],
+  public function testVersionsChanged(): void {
+    $this->copyFixtureFolderToActiveDirectory(__DIR__ . '/../../../fixtures/StagedProjectsValidatorTest/version_changed');
+
+    $updater = $this->container->get('automatic_updates.updater');
+    $updater->begin(['drupal' => '9.8.1']);
+    $updater->stage();
+
+    $stage_dir = $updater->getStageDirectory();
+    $this->modifyPackage($stage_dir, 'drupal/test_module', [
+      'version' => '1.3.1',
+    ]);
+    $this->modifyPackage($stage_dir, 'drupal/dev-test_module', [
+      'version' => '1.3.1',
+    ]);
+    // The validator shouldn't complain about these packages being updated,
+    // because it only cares about Drupal modules and themes.
+    $this->modifyPackage($stage_dir, 'other/changed', [
+      'version' => '1.3.2',
+    ]);
+    $this->modifyPackage($stage_dir, 'other/dev-changed', [
+      'version' => '1.3.2',
+    ]);
+
+    $messages = [
+      "module 'drupal/test_module' from 1.3.0 to 1.3.1.",
+      "module 'drupal/dev-test_module' from 1.3.0 to 1.3.1.",
     ];
+    $error = ValidationResult::createError($messages, t('The update cannot proceed because the following Drupal projects were unexpectedly updated. Only Drupal Core updates are currently supported.'));
+    try {
+      $updater->apply();
+      $this->fail('Expected an error, but none was raised.');
+    }
+    catch (StageValidationException $e) {
+      $this->assertValidationResultsEqual([$error], $e->getResults());
+    }
+  }
+
+  /**
+   * Tests that no errors occur if only core and its dependencies are updated.
+   */
+  public function testNoErrors(): void {
+    $this->copyFixtureFolderToActiveDirectory(__DIR__ . '/../../../fixtures/StagedProjectsValidatorTest/no_errors');
+
+    $updater = $this->container->get('automatic_updates.updater');
+    $updater->begin(['drupal' => '9.8.1']);
+    $updater->stage();
+
+    $stage_dir = $updater->getStageDirectory();
+    $this->modifyPackage($stage_dir, 'drupal/core', [
+      'version' => '9.8.1',
+    ]);
+    // The validator shouldn't care what happens to these packages, since it
+    // only concerns itself with Drupal modules and themes.
+    $this->addPackage($stage_dir, [
+      'name' => 'other/new_project',
+      'version' => '1.3.1',
+      'type' => 'library',
+      'install_path' => '../other/new_project',
+    ]);
+    $this->addPackage($stage_dir, [
+      'name' => 'other/dev-new_project',
+      'version' => '1.3.1',
+      'type' => 'library',
+      'dev_requirement' => TRUE,
+      'install_path' => '../other/dev-new_project',
+    ]);
+    $this->modifyPackage($stage_dir, 'other/changed', [
+      'version' => '1.3.2',
+    ]);
+    $this->modifyPackage($stage_dir, 'other/dev-changed', [
+      'version' => '1.3.2',
+    ]);
+    $this->removePackage($stage_dir, 'other/removed');
+    $this->removePackage($stage_dir, 'other/dev-removed');
+
+    $updater->apply();
   }
 
 }