diff --git a/core/lib/Drupal/Core/Test/FunctionalTestSetupTrait.php b/core/lib/Drupal/Core/Test/FunctionalTestSetupTrait.php
index 03990d8e46..82685a9320 100644
--- a/core/lib/Drupal/Core/Test/FunctionalTestSetupTrait.php
+++ b/core/lib/Drupal/Core/Test/FunctionalTestSetupTrait.php
@@ -669,11 +669,11 @@ protected function prepareEnvironment() {
    *   An array of available database driver installer objects.
    */
   protected function getDatabaseTypes() {
-    if ($this->originalContainer) {
+    if (isset($this->originalContainer) && $this->originalContainer) {
       \Drupal::setContainer($this->originalContainer);
     }
     $database_types = drupal_get_database_types();
-    if ($this->originalContainer) {
+    if (isset($this->originalContainer) && $this->originalContainer) {
       \Drupal::unsetContainer();
     }
     return $database_types;
diff --git a/core/lib/Drupal/Core/Test/TestDatabase.php b/core/lib/Drupal/Core/Test/TestDatabase.php
index 9bfa25d77a..42a90c0fd6 100644
--- a/core/lib/Drupal/Core/Test/TestDatabase.php
+++ b/core/lib/Drupal/Core/Test/TestDatabase.php
@@ -62,13 +62,17 @@ public static function getConnection() {
    *
    * @param string|null $db_prefix
    *   If not provided a new test lock is generated.
+   * @param bool $create_lock
+   *   (optional) Whether or not to create a lock file. Defaults to FALSE. If
+   *   the environment variable RUN_TESTS_CONCURRENCY is greater than 1 it will
+   *   be overridden to TRUE regardless of its initial value.
    *
    * @throws \InvalidArgumentException
    *   Thrown when $db_prefix does not match the regular expression.
    */
-  public function __construct($db_prefix = NULL) {
+  public function __construct($db_prefix = NULL, $create_lock = FALSE) {
     if ($db_prefix === NULL) {
-      $this->lockId = $this->getTestLock();
+      $this->lockId = $this->getTestLock($create_lock);
       $this->databasePrefix = 'test' . $this->lockId;
     }
     else {
@@ -107,31 +111,48 @@ public function getDatabasePrefix() {
   /**
    * Generates a unique lock ID for the test method.
    *
+   * @param bool $create_lock
+   *   (optional) Whether or not to create a lock file. Defaults to FALSE.
+   *
    * @return int
    *   The unique lock ID for the test method.
    */
-  protected function getTestLock() {
+  protected function getTestLock($create_lock = FALSE) {
+    // If we're only running with a concurrency of greater than 1 there is a
+    // risk the random number generated clashing. Therefore we need to create
+    // a lock.
+    if (getenv('RUN_TESTS_CONCURRENCY') > 1) {
+      $create_lock = TRUE;
+    }
+
     // Ensure that the generated lock ID is not in use, which may happen when
     // tests are run concurrently.
     do {
       $lock_id = mt_rand(10000000, 99999999);
-      // If we're only running with a concurrency of 1 there's no need to create
-      // a test lock file as there is no chance of the random number generated
-      // clashing.
-      if (getenv('RUN_TESTS_CONCURRENCY') > 1 && @symlink(__FILE__, $this->getLockFile($lock_id)) === FALSE) {
+      if ($create_lock && @symlink(__FILE__, $this->getLockFile($lock_id)) === FALSE) {
         $lock_id = NULL;
       }
     } while ($lock_id === NULL);
     return $lock_id;
   }
 
+  /**
+   * Releases a lock.
+   *
+   * @return bool
+   *   TRUE if successful, FALSE if not.
+   */
+  public function releaseLock() {
+    return unlink($this->getLockFile($this->lockId));
+  }
+
   /**
    * Releases all test locks.
    *
    * This should only be called once all the test fixtures have been cleaned up.
    */
   public static function releaseAllTestLocks() {
-    $tmp = file_directory_os_temp();
+    $tmp = FileSystem::getOsTemporaryDirectory();
     $dir = dir($tmp);
     while (($entry = $dir->read()) !== FALSE) {
       if ($entry === '.' || $entry === '..') {
diff --git a/core/scripts/test-site.php b/core/scripts/test-site.php
new file mode 100644
index 0000000000..d3fb826e82
--- /dev/null
+++ b/core/scripts/test-site.php
@@ -0,0 +1,22 @@
+#!/usr/bin/env php
+<?php
+
+/**
+ * @file
+ * A command line application to install drupal for tests.
+ */
+
+use Drupal\TestSite\TestSiteApplication;
+
+if (PHP_SAPI !== 'cli') {
+  return;
+}
+
+// Use the PHPUnit bootstrap to prime an autoloader that works for test classes.
+// Note we have to disable the SYMFONY_DEPRECATIONS_HELPER to ensure deprecation
+// notices are not triggered.
+putenv('SYMFONY_DEPRECATIONS_HELPER=disabled');
+require_once __DIR__ . '/../tests/bootstrap.php';
+
+$app = new TestSiteApplication('test-site', '0.1.0');
+$app->run();
diff --git a/core/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php b/core/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php
new file mode 100644
index 0000000000..e61d26402a
--- /dev/null
+++ b/core/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php
@@ -0,0 +1,231 @@
+<?php
+
+namespace Drupal\TestSite\Commands;
+
+use Drupal\Core\Database\Database;
+use Drupal\Core\Test\FunctionalTestSetupTrait;
+use Drupal\Core\Test\TestDatabase;
+use Drupal\Core\Test\TestSetupTrait;
+use Drupal\TestSite\TestSetupInterface;
+use Drupal\Tests\RandomGeneratorTrait;
+use Drupal\Tests\SessionTestTrait;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+/**
+ * Command to create a test Drupal site.
+ *
+ * @internal
+ */
+class TestSiteInstallCommand extends Command {
+
+  use FunctionalTestSetupTrait {
+    installParameters as protected installParametersTrait;
+  }
+  use RandomGeneratorTrait;
+  use SessionTestTrait;
+  use TestSetupTrait {
+    changeDatabasePrefix as protected changeDatabasePrefixTrait;
+  }
+
+  /**
+   * The install profile to use.
+   *
+   * @var string
+   */
+  protected $profile = 'testing';
+
+  /**
+   * Time limit in seconds for the test.
+   *
+   * @var int
+   */
+  protected $timeLimit = 500;
+
+  /**
+   * The database prefix of this test run.
+   *
+   * @var string
+   */
+  protected $databasePrefix;
+
+  /**
+   * The language to install the site in.
+   *
+   * @var string
+   */
+  protected $langcode = 'en';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function configure() {
+    $this->setName('install')
+      ->setDescription('Creates a test Drupal site')
+      ->setHelp('The details to connect to the test site created will be displayed upon success. It will contain the database prefix and the user agent.')
+      ->addOption('setup_class', NULL, InputOption::VALUE_OPTIONAL, 'A PHP class to setup configuration used by the test, for example, \Drupal\TestSite\TestSiteInstallTestScript')
+      ->addOption('db_url', NULL, InputOption::VALUE_OPTIONAL, 'URL for database or SIMPLETEST_DB', getenv('SIMPLETEST_DB'))
+      ->addOption('base_url', NULL, InputOption::VALUE_OPTIONAL, 'Base URL for site under test or SIMPLETEST_BASE_URL', getenv('SIMPLETEST_BASE_URL'))
+      ->addOption('install_profile', NULL, InputOption::VALUE_OPTIONAL, 'Install profile to install the site in. Defaults to testing', 'testing')
+      ->addOption('langcode', NULL, InputOption::VALUE_OPTIONAL, 'The language to install the site in. Defaults to en', 'en')
+      ->addOption('json', NULL, InputOption::VALUE_NONE, 'Output test site connection details in JSON')
+      ->addUsage('--setup_class "\Drupal\TestSite\TestSiteInstallTestScript" --json')
+      ->addUsage('--install_profile demo_umami --langcode fr')
+      ->addUsage('--base_url "http://example.com" --db_url "mysql://username:password@localhost/databasename#table_prefix"');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function execute(InputInterface $input, OutputInterface $output) {
+    // Validate the setup class prior to installing a database to avoid creating
+    // unnecessary sites.
+    $this->validateSetupClass($input->getOption('setup_class'));
+    // Ensure we can install a site in the sites/simpletest directory.
+    $this->ensureDirectory();
+
+    $db_url = $input->getOption('db_url');
+    $base_url = $input->getOption('base_url');
+    putenv("SIMPLETEST_DB=$db_url");
+    putenv("SIMPLETEST_BASE_URL=$base_url");
+
+    // Manage site fixture.
+    $this->setup($input->getOption('install_profile'), $input->getOption('setup_class'), $input->getOption('langcode'));
+
+    $user_agent = drupal_generate_test_ua($this->databasePrefix);
+    if ($input->getOption('json')) {
+      $output->writeln(json_encode([
+        'db_prefix' => $this->databasePrefix,
+        'user_agent' => $user_agent,
+      ]));
+    }
+    else {
+      $output->writeln('<info>Successfully installed a test site</info>');
+      $io = new SymfonyStyle($input, $output);
+      $io->table([], [
+        ['Database prefix', $this->databasePrefix],
+        ['User agent', $user_agent],
+      ]);
+    }
+  }
+
+  /**
+   * Validates the setup class.
+   *
+   * @param string|null $class
+   *   The setup class to validate.
+   *
+   * @throws \InvalidArgumentException
+   *   Thrown if the class does not exist or does not implement
+   *   \Drupal\TestSite\TestSetupInterface.
+   */
+  protected function validateSetupClass($class) {
+    if ($class === NULL) {
+      return;
+    }
+    if (!class_exists($class)) {
+      throw new \InvalidArgumentException("There was a problem loading $class");
+    }
+
+    if (!is_subclass_of($class, TestSetupInterface::class)) {
+      throw new \InvalidArgumentException('You need to define a class implementing \Drupal\TestSite\TestSetupInterface');
+    }
+  }
+
+  /**
+   * Ensures that the sites/simpletest directory exists and is writable.
+   */
+  protected function ensureDirectory() {
+    $root = dirname(dirname(dirname(dirname(dirname(__DIR__)))));
+    if (!is_writable($root . '/sites/simpletest')) {
+      if (!@mkdir($root . '/sites/simpletest')) {
+        throw new \RuntimeException($root . '/sites/simpletest must exist and be writable to install a test site');
+      }
+    }
+  }
+
+  /**
+   * Creates a test drupal installation.
+   *
+   * @param string $profile
+   *   (optional) The installation profile to use.
+   * @param string $setup_class
+   *   (optional) Setup class. A PHP class to setup configuration used by the
+   *   test.
+   * @param string $langcode
+   *   (optional) The language to install the site in.
+   */
+  public function setup($profile = 'testing', $setup_class = NULL, $langcode = 'en') {
+    $this->profile = $profile;
+    $this->langcode = $langcode;
+    $this->setupBaseUrl();
+    $this->prepareEnvironment();
+    $this->installDrupal();
+
+    if ($setup_class) {
+      $this->executeSetupClass($setup_class);
+    }
+  }
+
+  /**
+   * Installs Drupal into the test site.
+   */
+  protected function installDrupal() {
+    $this->initUserSession();
+    $this->prepareSettings();
+    $this->doInstall();
+    $this->initSettings();
+    $container = $this->initKernel(\Drupal::request());
+    $this->initConfig($container);
+    $this->installModulesFromClassProperty($container);
+    $this->rebuildAll();
+  }
+
+  /**
+   * Uses the setup file to configure Drupal.
+   *
+   * @param string $class
+   *   The full qualified class name, which should setup Drupal for tests. For
+   *   example this class could create content types and fields or install
+   *   modules. The class needs to implement TestSetupInterface.
+   *
+   * @see \Drupal\TestSite\TestSetupInterface
+   */
+  protected function executeSetupClass($class) {
+    /** @var \Drupal\TestSite\TestSetupInterface $instance */
+    $instance = new $class();
+    $instance->setup();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function installParameters() {
+    $parameters = $this->installParametersTrait();
+    $parameters['parameters']['langcode'] = $this->langcode;
+    return $parameters;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function changeDatabasePrefix() {
+    // Ensure that we use the database from SIMPLETEST_DB environment variable.
+    Database::removeConnection('default');
+    $this->changeDatabasePrefixTrait();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function prepareDatabasePrefix() {
+    // Override this method so that we can force a lock to be created.
+    $test_db = new TestDatabase(NULL, TRUE);
+    $this->siteDirectory = $test_db->getTestSitePath();
+    $this->databasePrefix = $test_db->getDatabasePrefix();
+  }
+
+}
diff --git a/core/tests/Drupal/TestSite/Commands/TestSiteReleaseLocksCommand.php b/core/tests/Drupal/TestSite/Commands/TestSiteReleaseLocksCommand.php
new file mode 100644
index 0000000000..4be43788f7
--- /dev/null
+++ b/core/tests/Drupal/TestSite/Commands/TestSiteReleaseLocksCommand.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\TestSite\Commands;
+
+use Drupal\Core\Test\TestDatabase;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Command to release all test site database prefix locks.
+ *
+ * @internal
+ */
+class TestSiteReleaseLocksCommand extends Command {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function configure() {
+    $this->setName('release-locks')
+      ->setDescription('Releases all test site locks')
+      ->setHelp('The locks ensure test site database prefixes are not reused.');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function execute(InputInterface $input, OutputInterface $output) {
+    TestDatabase::releaseAllTestLocks();
+    $output->writeln('<info>Successfully released all the test database locks</info>');
+  }
+
+}
diff --git a/core/tests/Drupal/TestSite/Commands/TestSiteTearDownCommand.php b/core/tests/Drupal/TestSite/Commands/TestSiteTearDownCommand.php
new file mode 100644
index 0000000000..6dce9539f0
--- /dev/null
+++ b/core/tests/Drupal/TestSite/Commands/TestSiteTearDownCommand.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace Drupal\TestSite\Commands;
+
+use Drupal\Core\Database\Database;
+use Drupal\Core\Test\TestDatabase;
+use Drupal\Tests\BrowserTestBase;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+/**
+ * Command to tear down a test Drupal site.
+ *
+ * @internal
+ */
+class TestSiteTearDownCommand extends Command {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function configure() {
+    $this->setName('tear-down')
+      ->setDescription('Removes a test site added by the install command')
+      ->setHelp('All the database tables and files will be removed.')
+      ->addArgument('db_prefix', InputArgument::REQUIRED, 'The database prefix for the test site')
+      ->addOption('db_url', NULL, InputOption::VALUE_OPTIONAL, 'URL for database or SIMPLETEST_DB', getenv('SIMPLETEST_DB'))
+      ->addOption('keep_lock', NULL, InputOption::VALUE_NONE, 'Keeps the database prefix lock. Useful for ensuring test isolation when running concurrent tests.')
+      ->addUsage('test12345678')
+      ->addUsage('test12345678 --db_url "mysql://username:password@localhost/databasename#table_prefix"')
+      ->addUsage('test12345678 --keep_lock');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function execute(InputInterface $input, OutputInterface $output) {
+    $db_prefix = $input->getArgument('db_prefix');
+    // Validate the db_prefix argument.
+    try {
+      $test_database = new TestDatabase($db_prefix);
+    }
+    catch (\InvalidArgumentException $e) {
+      $io = new SymfonyStyle($input, $output);
+      $io->getErrorStyle()->error("Invalid database prefix: $db_prefix\n\nValid database prefixes match the regular expression '/test(\d+)$/'. For example, 'test12345678'.");
+      // Display the synopsis of the command like Composer does.
+      $output->writeln(sprintf('<info>%s</info>', sprintf($this->getSynopsis(), $this->getName())), OutputInterface::VERBOSITY_QUIET);
+      return 1;
+    }
+
+    $db_url = $input->getOption('db_url');
+    putenv("SIMPLETEST_DB=$db_url");
+
+    // Handle the cleanup of the test site.
+    $this->tearDown($test_database, $db_url);
+
+    // Release the test database prefix lock.
+    if (!$input->getOption('keep_lock')) {
+      $test_database->releaseLock();
+    }
+
+    $output->writeln("<info>Successfully uninstalled $db_prefix test site</info>");
+  }
+
+  /**
+   * Removes a given instance by deleting all the database tables and files.
+   *
+   * @param \Drupal\Core\Test\TestDatabase $test_database
+   *   The test database object.
+   * @param string $db_url
+   *   The database URL.
+   *
+   * @see \Drupal\Tests\BrowserTestBase::cleanupEnvironment()
+   */
+  protected function tearDown(TestDatabase $test_database, $db_url) {
+    // Connect to the test database.
+    $root = dirname(dirname(dirname(dirname(dirname(__DIR__)))));
+    $database = Database::convertDbUrlToConnectionInfo($db_url, $root);
+    $database['prefix'] = ['default' => $test_database->getDatabasePrefix()];
+    Database::addConnectionInfo(__CLASS__, 'default', $database);
+
+    // Remove all the tables.
+    $schema = Database::getConnection('default', __CLASS__)->schema();
+    $tables = $schema->findTables('%');
+    array_walk($tables, [$schema, 'dropTable']);
+
+    // Delete test site directory.
+    $this->fileUnmanagedDeleteRecursive($root . DIRECTORY_SEPARATOR . $test_database->getTestSitePath(), [BrowserTestBase::class, 'filePreDeleteCallback']);
+  }
+
+  /**
+   * Deletes all files and directories in the specified filepath recursively.
+   *
+   * Note this version has no dependencies on Drupal core to ensure that the
+   * test site can be torn down even if something in the test site is broken.
+   *
+   * @param $path
+   *   A string containing either an URI or a file or directory path.
+   * @param callable $callback
+   *   (optional) Callback function to run on each file prior to deleting it and
+   *   on each directory prior to traversing it. For example, can be used to
+   *   modify permissions.
+   *
+   * @return bool
+   *   TRUE for success or if path does not exist, FALSE in the event of an
+   *   error.
+   *
+   * @see file_unmanaged_delete_recursive()
+   */
+  protected function fileUnmanagedDeleteRecursive($path, $callback = NULL) {
+    if (isset($callback)) {
+      call_user_func($callback, $path);
+    }
+    if (is_dir($path)) {
+      $dir = dir($path);
+      while (($entry = $dir->read()) !== FALSE) {
+        if ($entry == '.' || $entry == '..') {
+          continue;
+        }
+        $entry_path = $path . '/' . $entry;
+        $this->fileUnmanagedDeleteRecursive($entry_path, $callback);
+      }
+      $dir->close();
+
+      return rmdir($path);
+    }
+    return unlink($path);
+  }
+
+}
diff --git a/core/tests/Drupal/TestSite/TestSetupInterface.php b/core/tests/Drupal/TestSite/TestSetupInterface.php
new file mode 100644
index 0000000000..985f26778d
--- /dev/null
+++ b/core/tests/Drupal/TestSite/TestSetupInterface.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Drupal\TestSite;
+
+/**
+ * Allows you to setup an environment as part of a test site install.
+ *
+ * @see \Drupal\TestSite\Commands\TestSiteInstallCommand
+ */
+interface TestSetupInterface {
+
+  /**
+   * Run the code to setup the test environment.
+   *
+   * You have access to any API provided by any installed module. For example,
+   * to install modules use:
+   * @code
+   * \Drupal::service('module_installer')->install(['my_module'])
+   * @endcode
+   *
+   * Check out TestSiteInstallTestScript for an example.
+   *
+   * @see \Drupal\TestSite\TestSiteInstallTestScript
+   */
+  public function setup();
+
+}
diff --git a/core/tests/Drupal/TestSite/TestSiteApplication.php b/core/tests/Drupal/TestSite/TestSiteApplication.php
new file mode 100644
index 0000000000..eba29d8d3f
--- /dev/null
+++ b/core/tests/Drupal/TestSite/TestSiteApplication.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Drupal\TestSite;
+
+use Drupal\TestSite\Commands\TestSiteInstallCommand;
+use Drupal\TestSite\Commands\TestSiteReleaseLocksCommand;
+use Drupal\TestSite\Commands\TestSiteTearDownCommand;
+use Symfony\Component\Console\Application;
+
+/**
+ * Application wrapper for test site commands.
+ *
+ * In order to see what commands are available and how to use them run
+ * "php core/scripts/test-site.php" from command line and use the help system.
+ *
+ * @internal
+ */
+class TestSiteApplication extends Application {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getDefaultCommands() {
+    $default_commands = parent::getDefaultCommands();
+    $default_commands[] = new TestSiteInstallCommand();
+    $default_commands[] = new TestSiteTearDownCommand();
+    $default_commands[] = new TestSiteReleaseLocksCommand();
+    return $default_commands;
+  }
+
+}
diff --git a/core/tests/Drupal/TestSite/TestSiteInstallTestScript.php b/core/tests/Drupal/TestSite/TestSiteInstallTestScript.php
new file mode 100644
index 0000000000..1f6f9644fc
--- /dev/null
+++ b/core/tests/Drupal/TestSite/TestSiteInstallTestScript.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\TestSite;
+
+/**
+ * Setup file used by TestSiteApplicationTest.
+ *
+ * @see \Drupal\Tests\Scripts\TestSiteApplicationTest
+ */
+class TestSiteInstallTestScript implements TestSetupInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setup() {
+    \Drupal::service('module_installer')->install(['test_page_test']);
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Scripts/TestSiteApplicationTest.php b/core/tests/Drupal/Tests/Scripts/TestSiteApplicationTest.php
new file mode 100644
index 0000000000..9136f61cf4
--- /dev/null
+++ b/core/tests/Drupal/Tests/Scripts/TestSiteApplicationTest.php
@@ -0,0 +1,267 @@
+<?php
+
+namespace Drupal\Tests\Scripts;
+
+use Drupal\Component\FileSystem\FileSystem;
+use Drupal\Core\Database\Database;
+use Drupal\Core\Test\TestDatabase;
+use Drupal\TestSite\TestSiteInstallTestScript;
+use Drupal\Tests\UnitTestCase;
+use GuzzleHttp\Client;
+use GuzzleHttp\Psr7\Request;
+use Symfony\Component\Process\PhpExecutableFinder;
+use Symfony\Component\Process\Process;
+
+/**
+ * Tests core/scripts/test-site.php.
+ *
+ * @group Setup
+ *
+ * This test uses the Drupal\Core\Database\Database class which has a static.
+ * Therefore run in an separate process to avoid side effects.
+ *
+ * @runTestsInSeparateProcesses
+ * @preserveGlobalState disabled
+ *
+ * @see \Drupal\TestSite\TestSiteApplication
+ * @see \Drupal\TestSite\Commands\TestSiteInstallCommand
+ * @see \Drupal\TestSite\Commands\TestSiteTearDownCommand
+ */
+class TestSiteApplicationTest extends UnitTestCase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->root = dirname(dirname(substr(__DIR__, 0, -strlen(__NAMESPACE__))));
+  }
+
+  /**
+   * @coversNothing
+   */
+  public function testInstallWithNonExistingClass() {
+    $php_binary_finder = new PhpExecutableFinder();
+    $php_binary_path = $php_binary_finder->find();
+
+    // Create a connection to the DB configured in SIMPLETEST_DB.
+    $connection = Database::getConnection('default', $this->addTestDatabase(''));
+    $table_count = count($connection->schema()->findTables('%'));
+
+    $command_line = $php_binary_path . ' core/scripts/test-site.php install --setup_class "this-class-does-not-exist" --db_url "' . getenv('SIMPLETEST_DB') . '"';
+    $process = new Process($command_line, $this->root);
+    $process->run();
+
+    $this->assertContains('There was a problem loading this-class-does-not-exist', $process->getErrorOutput());
+    $this->assertSame(1, $process->getExitCode());
+    $this->assertCount($table_count, $connection->schema()->findTables('%'), 'No additional tables created in the database');
+  }
+
+  /**
+   * @coversNothing
+   */
+  public function testInstallWithNonSetupClass() {
+    $php_binary_finder = new PhpExecutableFinder();
+    $php_binary_path = $php_binary_finder->find();
+
+    // Create a connection to the DB configured in SIMPLETEST_DB.
+    $connection = Database::getConnection('default', $this->addTestDatabase(''));
+    $table_count = count($connection->schema()->findTables('%'));
+
+    $command_line = $php_binary_path . ' core/scripts/test-site.php install --setup_class "' . static::class . '" --db_url "' . getenv('SIMPLETEST_DB') . '"';
+    $process = new Process($command_line, $this->root);
+    $process->run();
+
+    $this->assertContains('You need to define a class implementing \Drupal\TestSite\TestSetupInterface', $process->getErrorOutput());
+    $this->assertSame(1, $process->getExitCode());
+    $this->assertCount($table_count, $connection->schema()->findTables('%'), 'No additional tables created in the database');
+  }
+
+  /**
+   * @coversNothing
+   */
+  public function testInstallScript() {
+    $simpletest_path = $this->root . DIRECTORY_SEPARATOR . 'sites' . DIRECTORY_SEPARATOR . 'simpletest';
+    if (!is_writable($simpletest_path)) {
+      $this->markTestSkipped("Requires the directory $simpletest_path to exist and be writable");
+    }
+    $php_binary_finder = new PhpExecutableFinder();
+    $php_binary_path = $php_binary_finder->find();
+
+    // Install a site using the JSON output.
+    $command_line = $php_binary_path . ' core/scripts/test-site.php install --json --setup_class "' . TestSiteInstallTestScript::class . '" --db_url "' . getenv('SIMPLETEST_DB') . '"';
+    $process = new Process($command_line, $this->root);
+    // Set the timeout to a value that allows debugging.
+    $process->setTimeout(500);
+    $process->run();
+
+    $this->assertSame(0, $process->getExitCode());
+    $result = json_decode($process->getOutput(), TRUE);
+    $db_prefix = $result['db_prefix'];
+    $this->assertStringStartsWith('simpletest' . substr($db_prefix, 4) . ':', $result['user_agent']);
+
+    $http_client = new Client();
+    $request = (new Request('GET', getenv('SIMPLETEST_BASE_URL') . '/test-page'))
+      ->withHeader('User-Agent', trim($result['user_agent']));
+
+    $response = $http_client->send($request);
+    // Ensure the test_page_test module got installed.
+    $this->assertContains('Test page | Drupal', (string) $response->getBody());
+
+    // Ensure that there are files and database tables for tear down command to
+    // clean up.
+    $key = $this->addTestDatabase($db_prefix);
+    $this->assertGreaterThan(0, count(Database::getConnection('default', $key)->schema()->findTables('%')));
+    $test_database = new TestDatabase($db_prefix);
+    $test_file = $this->root . DIRECTORY_SEPARATOR . $test_database->getTestSitePath() . DIRECTORY_SEPARATOR . '.htkey';
+    $this->assertFileExists($test_file);
+
+    // Ensure the lock file exists.
+    $this->assertFileExists($this->getTestLockFile($db_prefix));
+
+    // Install another site so we can ensure tear down only removes one site at
+    // a time. Use the regular output.
+    $command_line = $php_binary_path . ' core/scripts/test-site.php install --setup_class "' . TestSiteInstallTestScript::class . '" --db_url "' . getenv('SIMPLETEST_DB') . '"';
+    $process = new Process($command_line, $this->root);
+    // Set the timeout to a value that allows debugging.
+    $process->setTimeout(500);
+    $process->run();
+    $this->assertContains('Successfully installed a test site', $process->getOutput());
+    $this->assertSame(0, $process->getExitCode());
+    $regex = '/Database prefix\s+([^\s]*)/';
+    $this->assertRegExp($regex, $process->getOutput());
+    preg_match('/Database prefix\s+([^\s]*)/', $process->getOutput(), $matches);
+    $other_db_prefix = $matches[1];
+    $other_key = $this->addTestDatabase($other_db_prefix);
+    $this->assertGreaterThan(0, count(Database::getConnection('default', $other_key)->schema()->findTables('%')));
+
+    // Ensure the lock file exists for the new install.
+    $this->assertFileExists($this->getTestLockFile($other_db_prefix));
+
+    // Now test the tear down process as well.
+    $command_line = $php_binary_path . ' core/scripts/test-site.php tear-down ' . $db_prefix . ' --keep_lock --db_url "' . getenv('SIMPLETEST_DB') . '"';
+    $process = new Process($command_line, $this->root);
+    // Set the timeout to a value that allows debugging.
+    $process->setTimeout(500);
+    $process->run();
+    $this->assertSame(0, $process->getExitCode());
+    $this->assertContains("Successfully uninstalled $db_prefix test site", $process->getOutput());
+
+    // Ensure that all the tables and files for this DB prefix are gone.
+    $this->assertCount(0, Database::getConnection('default', $key)->schema()->findTables('%'));
+    $this->assertFileNotExists($test_file);
+
+    // Ensure the other site's tables and files still exist.
+    $this->assertGreaterThan(0, count(Database::getConnection('default', $other_key)->schema()->findTables('%')));
+    $test_database = new TestDatabase($other_db_prefix);
+    $test_file = $this->root . DIRECTORY_SEPARATOR . $test_database->getTestSitePath() . DIRECTORY_SEPARATOR . '.htkey';
+    $this->assertFileExists($test_file);
+
+    // Tear down the other site installed. Tear down should work if the test
+    // site is broken. Prove this by removing its settings.php.
+    $test_site_settings = $this->root . DIRECTORY_SEPARATOR . $test_database->getTestSitePath() . DIRECTORY_SEPARATOR . 'settings.php';
+    $this->assertTrue(unlink($test_site_settings));
+    $command_line = $php_binary_path . ' core/scripts/test-site.php tear-down ' . $other_db_prefix . ' --db_url "' . getenv('SIMPLETEST_DB') . '"';
+    $process = new Process($command_line, $this->root);
+    // Set the timeout to a value that allows debugging.
+    $process->setTimeout(500);
+    $process->run();
+    $this->assertSame(0, $process->getExitCode());
+    $this->assertContains("Successfully uninstalled $other_db_prefix test site", $process->getOutput());
+
+    // Ensure that all the tables and files for this DB prefix are gone.
+    $this->assertCount(0, Database::getConnection('default', $other_key)->schema()->findTables('%'));
+    $this->assertFileNotExists($test_file);
+
+    // The lock for the first site should still exist but the second site is
+    // clean up during tear down.
+    $this->assertFileExists($this->getTestLockFile($db_prefix));
+    $this->assertFileNotExists($this->getTestLockFile($other_db_prefix));
+  }
+
+  /**
+   * @coversNothing
+   */
+  public function testInstallInDifferentLanguage() {
+    $simpletest_path = $this->root . DIRECTORY_SEPARATOR . 'sites' . DIRECTORY_SEPARATOR . 'simpletest';
+    if (!is_writable($simpletest_path)) {
+      $this->markTestSkipped("Requires the directory $simpletest_path to exist and be writable");
+    }
+    $php_binary_finder = new PhpExecutableFinder();
+    $php_binary_path = $php_binary_finder->find();
+
+    $command_line = $php_binary_path . ' core/scripts/test-site.php install --json --langcode fr --setup_class "' . TestSiteInstallTestScript::class . '" --db_url "' . getenv('SIMPLETEST_DB') . '"';
+    $process = new Process($command_line, $this->root);
+    $process->setTimeout(500);
+    $process->run();
+    $this->assertEquals(0, $process->getExitCode());
+
+    $result = json_decode($process->getOutput(), TRUE);
+    $db_prefix = $result['db_prefix'];
+    $http_client = new Client();
+    $request = (new Request('GET', getenv('SIMPLETEST_BASE_URL') . '/test-page'))
+      ->withHeader('User-Agent', trim($result['user_agent']));
+
+    $response = $http_client->send($request);
+    // Ensure the test_page_test module got installed.
+    $this->assertContains('Test page | Drupal', (string) $response->getBody());
+    $this->assertContains('lang="fr"', (string) $response->getBody());
+
+    // Now test the tear down process as well.
+    $command_line = $php_binary_path . ' core/scripts/test-site.php tear-down ' . $db_prefix . ' --db_url "' . getenv('SIMPLETEST_DB') . '"';
+    $process = new Process($command_line, $this->root);
+    $process->setTimeout(500);
+    $process->run();
+    $this->assertSame(0, $process->getExitCode());
+
+    // Ensure that all the tables for this DB prefix are gone.
+    $this->assertCount(0, Database::getConnection('default', $this->addTestDatabase($db_prefix))->schema()->findTables('%'));
+  }
+
+  /**
+   * @coversNothing
+   */
+  public function testTearDownDbPrefixValidation() {
+    $php_binary_finder = new PhpExecutableFinder();
+    $php_binary_path = $php_binary_finder->find();
+
+    $command_line = $php_binary_path . ' core/scripts/test-site.php tear-down not-a-valid-prefix';
+    $process = new Process($command_line, $this->root);
+    $process->setTimeout(500);
+    $process->run();
+    $this->assertSame(1, $process->getExitCode());
+    $this->assertContains('Invalid database prefix: not-a-valid-prefix', $process->getErrorOutput());
+  }
+
+  /**
+   * Adds the installed test site to the database connection info.
+   *
+   * @param string $db_prefix
+   *   The prefix of the installed test site.
+   *
+   * @return string
+   *   The database key of the added connection.
+   */
+  protected function addTestDatabase($db_prefix) {
+    $database = Database::convertDbUrlToConnectionInfo(getenv('SIMPLETEST_DB'), $this->root);
+    $database['prefix'] = ['default' => $db_prefix];
+    $target = __CLASS__ . $db_prefix;
+    Database::addConnectionInfo($target, 'default', $database);
+    return $target;
+  }
+
+  /**
+   * Gets the lock file path.
+   *
+   * @param string $db_prefix
+   *   The prefix of the installed test site.
+   *
+   * @return string
+   *   The lock file path.
+   */
+  protected function getTestLockFile($db_prefix) {
+    $lock_id = str_replace('test', '', $db_prefix);
+    return FileSystem::getOsTemporaryDirectory() . '/test_' . $lock_id;
+  }
+
+}
diff --git a/core/tests/bootstrap.php b/core/tests/bootstrap.php
index 7eb6ecb70c..a2963d45f3 100644
--- a/core/tests/bootstrap.php
+++ b/core/tests/bootstrap.php
@@ -129,6 +129,7 @@ function drupal_phpunit_populate_class_loader() {
 
   // Start with classes in known locations.
   $loader->add('Drupal\\Tests', __DIR__);
+  $loader->add('Drupal\\TestSite', __DIR__);
   $loader->add('Drupal\\KernelTests', __DIR__);
   $loader->add('Drupal\\FunctionalTests', __DIR__);
   $loader->add('Drupal\\FunctionalJavascriptTests', __DIR__);
@@ -195,5 +196,5 @@ class_alias('\PHPUnit\Framework\MockObject\Matcher\InvokedRecorder', '\PHPUnit_F
   class_alias('\PHPUnit\Framework\SkippedTestError', '\PHPUnit_Framework_SkippedTestError');
   class_alias('\PHPUnit\Framework\TestCase', '\PHPUnit_Framework_TestCase');
   class_alias('\PHPUnit\Util\Test', '\PHPUnit_Util_Test');
-  class_alias('\PHPUnit\Util\XML', '\PHPUnit_Util_XML');
+  class_alias('\PHPUnit\Util\Xml', '\PHPUnit_Util_XML');
 }
