Verified Commit f7114354 authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3075608 by mondrake, Mile23, longwave: Introduce TestRun objects and...

Issue #3075608 by mondrake, Mile23, longwave: Introduce TestRun objects and refactor TestDatabase to be testable
parent 237373bc
Loading
Loading
Loading
Loading
+24 −80
Original line number Diff line number Diff line
@@ -8,75 +8,38 @@

/**
 * Helper class for cleaning test environments.
 */
class EnvironmentCleaner implements EnvironmentCleanerInterface {

  /**
   * Path to Drupal root directory.
   *
   * @var string
   */
  protected $root;

  /**
   * Connection to the database being used for tests.
   *
   * @var \Drupal\Core\Database\Connection
   */
  protected $testDatabase;

  /**
   * Connection to the database where test results are stored.
   *
   * This could be the same as $testDatabase, or it could be different.
   * run-tests.sh allows you to specify a different results database with the
   * --sqlite parameter.
   *
   * @var \Drupal\Core\Database\Connection
   */
  protected $resultsDatabase;

  /**
   * The file system service.
   *
   * @var \Drupal\Core\File\FileSystemInterface
   */
  protected $fileSystem;

  /**
   * Console output.
 *
   * @var \Symfony\Component\Console\Output\OutputInterface
 * @internal
 */
  protected $output;
class EnvironmentCleaner implements EnvironmentCleanerInterface {

  /**
   * Construct an environment cleaner.
   * Constructs a test environment cleaner.
   *
   * @param string $root
   *   The path to the root of the Drupal installation.
   * @param \Drupal\Core\Database\Connection $test_database
   * @param \Drupal\Core\Database\Connection $testDatabase
   *   Connection to the database against which tests were run.
   * @param \Drupal\Core\Database\Connection $results_database
   *   Connection to the database where test results were stored. This could be
   *   the same as $test_database, or it could be different.
   * @param \Drupal\Core\Test\TestRunResultsStorageInterface $testRunResultsStorage
   *   The test run results storage.
   * @param \Symfony\Component\Console\Output\OutputInterface $output
   *   A symfony console output object.
   * @param \Drupal\Core\File\FileSystemInterface $file_system
   *   The file_system service.
   *   A Symfony console output object.
   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
   *   Drupal's file_system service.
   */
  public function __construct($root, Connection $test_database, Connection $results_database, OutputInterface $output, FileSystemInterface $file_system) {
    $this->root = $root;
    $this->testDatabase = $test_database;
    $this->resultsDatabase = $results_database;
    $this->output = $output;
    $this->fileSystem = $file_system;
  public function __construct(
    protected string $root,
    protected Connection $testDatabase,
    protected TestRunResultsStorageInterface $testRunResultsStorage,
    protected OutputInterface $output,
    protected FileSystemInterface $fileSystem
  ) {
  }

  /**
   * {@inheritdoc}
   */
  public function cleanEnvironment($clear_results = TRUE, $clear_temp_directories = TRUE, $clear_database = TRUE) {
  public function cleanEnvironment(bool $clear_results = TRUE, bool $clear_temp_directories = TRUE, bool $clear_database = TRUE): void {
    $count = 0;
    if ($clear_database) {
      $this->doCleanDatabase();
@@ -85,7 +48,7 @@ public function cleanEnvironment($clear_results = TRUE, $clear_temp_directories
      $this->doCleanTemporaryDirectories();
    }
    if ($clear_results) {
      $count = $this->cleanResultsTable();
      $count = $this->cleanResults();
      $this->output->write('Test results removed: ' . $count);
    }
    else {
@@ -96,7 +59,7 @@ public function cleanEnvironment($clear_results = TRUE, $clear_temp_directories
  /**
   * {@inheritdoc}
   */
  public function cleanDatabase() {
  public function cleanDatabase(): void {
    $count = $this->doCleanDatabase();
    if ($count > 0) {
      $this->output->write('Leftover tables removed: ' . $count);
@@ -112,7 +75,7 @@ public function cleanDatabase() {
   * @return int
   *   The number of tables that were removed.
   */
  protected function doCleanDatabase() {
  protected function doCleanDatabase(): int {
    /** @var \Drupal\Core\Database\Schema $schema */
    $schema = $this->testDatabase->schema();
    $tables = $schema->findTables('test%');
@@ -131,7 +94,7 @@ protected function doCleanDatabase() {
  /**
   * {@inheritdoc}
   */
  public function cleanTemporaryDirectories() {
  public function cleanTemporaryDirectories(): void {
    $count = $this->doCleanTemporaryDirectories();
    if ($count > 0) {
      $this->output->write('Temporary directories removed: ' . $count);
@@ -147,7 +110,7 @@ public function cleanTemporaryDirectories() {
   * @return int
   *   The count of temporary directories removed.
   */
  protected function doCleanTemporaryDirectories() {
  protected function doCleanTemporaryDirectories(): int {
    $count = 0;
    $simpletest_dir = $this->root . '/sites/simpletest';
    if (is_dir($simpletest_dir)) {
@@ -168,27 +131,8 @@ protected function doCleanTemporaryDirectories() {
  /**
   * {@inheritdoc}
   */
  public function cleanResultsTable($test_id = NULL) {
    $count = 0;
    if ($test_id) {
      $count = $this->resultsDatabase->query('SELECT COUNT([test_id]) FROM {simpletest_test_id} WHERE [test_id] = :test_id', [':test_id' => $test_id])->fetchField();

      $this->resultsDatabase->delete('simpletest')
        ->condition('test_id', $test_id)
        ->execute();
      $this->resultsDatabase->delete('simpletest_test_id')
        ->condition('test_id', $test_id)
        ->execute();
    }
    else {
      $count = $this->resultsDatabase->query('SELECT COUNT([test_id]) FROM {simpletest_test_id}')->fetchField();

      // Clear test results.
      $this->resultsDatabase->delete('simpletest')->execute();
      $this->resultsDatabase->delete('simpletest_test_id')->execute();
    }

    return $count;
  public function cleanResults(TestRun $test_run = NULL): int {
    return $test_run ? $test_run->removeResults() : $this->testRunResultsStorage->cleanUp();
  }

}
+10 −11
Original line number Diff line number Diff line
@@ -8,11 +8,9 @@
 * This interface is marked internal. It does not imply an API.
 *
 * @todo Formalize this interface in
 *   https://www.drupal.org/project/drupal/issues/3075490 and
 *   https://www.drupal.org/project/drupal/issues/3075608
 *   https://www.drupal.org/project/drupal/issues/3075490
 *
 * @see https://www.drupal.org/project/drupal/issues/3075490
 * @see https://www.drupal.org/project/drupal/issues/3075608
 *
 * @internal
 */
@@ -25,33 +23,34 @@ interface EnvironmentCleanerInterface {
   * under test.
   *
   * @param bool $clear_results
   *   (optional) Whether to clear the test results database. Defaults to TRUE.
   *   (optional) Whether to clear the test results storage. Defaults to TRUE.
   * @param bool $clear_temp_directories
   *   (optional) Whether to clear the test site directories. Defaults to TRUE.
   * @param bool $clear_database
   *   (optional) Whether to clean up the fixture database. Defaults to TRUE.
   */
  public function cleanEnvironment($clear_results = TRUE, $clear_temp_directories = TRUE, $clear_database = TRUE);
  public function cleanEnvironment(bool $clear_results = TRUE, bool $clear_temp_directories = TRUE, bool $clear_database = TRUE): void;

  /**
   * Remove database entries left over in the fixture database.
   */
  public function cleanDatabase();
  public function cleanDatabase(): void;

  /**
   * Finds all leftover fixture site directories and removes them.
   */
  public function cleanTemporaryDirectories();
  public function cleanTemporaryDirectories(): void;

  /**
   * Clears test result tables from the results database.
   * Clears test results from the results storage.
   *
   * @param $test_id
   *   Test ID to remove results for, or NULL to remove all results.
   * @param \Drupal\Core\Test\TestRun $test_run
   *   The test run object to remove results for, or NULL to remove all
   *   results.
   *
   * @return int
   *   The number of results that were removed.
   */
  public function cleanResultsTable($test_id = NULL);
  public function cleanResults(TestRun $test_run = NULL): int;

}
+43 −40
Original line number Diff line number Diff line
@@ -13,55 +13,42 @@
 * This class runs PHPUnit-based tests and converts their JUnit results to a
 * format that can be stored in the {simpletest} database schema.
 *
 * This class is @internal and not considered to be API.
 * This class is internal and not considered to be API.
 *
 * @code
 * $runner = PhpUnitTestRunner::create(\Drupal::getContainer());
 * $results = $runner->runTests($test_id, $test_list['phpunit']);
 * $results = $runner->execute($test_run, $test_list['phpunit']);
 * @endcode
 *
 * @internal
 */
class PhpUnitTestRunner implements ContainerInjectionInterface {

  /**
   * Path to the working directory.
   *
   * JUnit log files will be stored in this directory.
   * Constructs a test runner.
   *
   * @var string
   */
  protected $workingDirectory;

  /**
   * @param string $appRoot
   *   Path to the application root.
   *
   * @var string
   * @param string $workingDirectory
   *   Path to the working directory. JUnit log files will be stored in this
   *   directory.
   */
  protected $appRoot;
  public function __construct(
    protected string $appRoot,
    protected string $workingDirectory
  ) {
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
  public static function create(ContainerInterface $container): static {
    return new static(
      (string) $container->getParameter('app.root'),
      (string) $container->get('file_system')->realpath('public://simpletest')
    );
  }

  /**
   * Constructs a test runner.
   *
   * @param string $app_root
   *   Path to the application root.
   * @param string $working_directory
   *   Path to the working directory. JUnit log files will be stored in this
   *   directory.
   */
  public function __construct($app_root, $working_directory) {
    $this->appRoot = $app_root;
    $this->workingDirectory = $working_directory;
  }

  /**
   * Returns the path to use for PHPUnit's --log-junit option.
   *
@@ -73,7 +60,7 @@ public function __construct($app_root, $working_directory) {
   *
   * @internal
   */
  public function xmlLogFilePath($test_id) {
  public function xmlLogFilePath(int $test_id): string {
    return $this->workingDirectory . '/phpunit-' . $test_id . '.xml';
  }

@@ -85,7 +72,7 @@ public function xmlLogFilePath($test_id) {
   *
   * @internal
   */
  public function phpUnitCommand() {
  public function phpUnitCommand(): string {
    // Load the actual autoloader being used and determine its filename using
    // reflection. We can determine the vendor directory based on that filename.
    $autoloader = require $this->appRoot . '/autoload.php';
@@ -124,7 +111,7 @@ public function phpUnitCommand() {
   *
   * @internal
   */
  public function runCommand(array $unescaped_test_classnames, $phpunit_file, &$status = NULL, &$output = NULL) {
  public function runCommand(array $unescaped_test_classnames, string $phpunit_file, int &$status = NULL, array &$output = NULL): string {
    global $base_url;
    // Setup an environment variable containing the database connection so that
    // functional tests can connect to the database.
@@ -184,8 +171,8 @@ public function runCommand(array $unescaped_test_classnames, $phpunit_file, &$st
  /**
   * Executes PHPUnit tests and returns the results of the run.
   *
   * @param int $test_id
   *   The current test ID.
   * @param \Drupal\Core\Test\TestRun $test_run
   *   The test run object.
   * @param string[] $unescaped_test_classnames
   *   An array of test class names, including full namespaces, to be passed as
   *   a regular expression to PHPUnit's --filter option.
@@ -199,18 +186,18 @@ public function runCommand(array $unescaped_test_classnames, $phpunit_file, &$st
   *
   * @internal
   */
  public function runTests($test_id, array $unescaped_test_classnames, &$status = NULL) {
    $phpunit_file = $this->xmlLogFilePath($test_id);
  public function execute(TestRun $test_run, array $unescaped_test_classnames, int &$status = NULL): array {
    $phpunit_file = $this->xmlLogFilePath($test_run->id());
    // Store output from our test run.
    $output = [];
    $this->runCommand($unescaped_test_classnames, $phpunit_file, $status, $output);

    if ($status == TestStatus::PASS) {
      return JUnitConverter::xmlToRows($test_id, $phpunit_file);
      return JUnitConverter::xmlToRows($test_run->id(), $phpunit_file);
    }
    return [
      [
        'test_id' => $test_id,
        'test_id' => $test_run->id(),
        'test_class' => implode(",", $unescaped_test_classnames),
        'status' => TestStatus::label($status),
        'message' => 'PHPUnit Test failed to complete; Error: ' . implode("\n", $output),
@@ -222,19 +209,35 @@ public function runTests($test_id, array $unescaped_test_classnames, &$status =
    ];
  }

  /**
   * Logs the parsed PHPUnit results into the test run.
   *
   * @param \Drupal\Core\Test\TestRun $test_run
   *   The test run object.
   * @param array[] $phpunit_results
   *   An array of test results, as returned from
   *   \Drupal\Core\Test\JUnitConverter::xmlToRows(). Can be the return value of
   *   PhpUnitTestRunner::execute().
   */
  public function processPhpUnitResults(TestRun $test_run, array $phpunit_results): void {
    foreach ($phpunit_results as $result) {
      $test_run->insertLogEntry($result);
    }
  }

  /**
   * Tallies test results per test class.
   *
   * @param string[][] $results
   *   Array of results in the {simpletest} schema. Can be the return value of
   *   PhpUnitTestRunner::runTests().
   *   PhpUnitTestRunner::execute().
   *
   * @return int[][]
   *   Array of status tallies, keyed by test class name and status type.
   *
   * @internal
   */
  public function summarizeResults(array $results) {
  public function summarizeResults(array $results): array {
    $summaries = [];
    foreach ($results as $result) {
      if (!isset($summaries[$result['test_class']])) {
+272 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Core\Test;

use Drupal\Core\Database\Database;
use Drupal\Core\Database\Connection;
use Drupal\Core\Database\ConnectionNotDefinedException;

/**
 * Implements a test run results storage compatible with legacy Simpletest.
 *
 * @internal
 */
class SimpletestTestRunResultsStorage implements TestRunResultsStorageInterface {

  /**
   * SimpletestTestRunResultsStorage constructor.
   *
   * @param \Drupal\Core\Database\Connection $connection
   *   The database connection to use for inserting assertions.
   */
  public function __construct(
    protected Connection $connection
  ) {
  }

  /**
   * Returns the database connection to use for inserting assertions.
   *
   * @return \Drupal\Core\Database\Connection
   *   The database connection to use for inserting assertions.
   */
  public static function getConnection(): Connection {
    // Check whether there is a test runner connection.
    // @see run-tests.sh
    // @todo Convert Simpletest UI runner to create + use this connection, too.
    try {
      $connection = Database::getConnection('default', 'test-runner');
    }
    catch (ConnectionNotDefinedException $e) {
      // Check whether there is a backup of the original default connection.
      // @see FunctionalTestSetupTrait::prepareEnvironment()
      try {
        $connection = Database::getConnection('default', 'simpletest_original_default');
      }
      catch (ConnectionNotDefinedException $e) {
        // If FunctionalTestSetupTrait::prepareEnvironment() failed, the
        // test-specific database connection does not exist yet/anymore, so
        // fall back to the default of the (UI) test runner.
        $connection = Database::getConnection('default', 'default');
      }
    }
    return $connection;
  }

  /**
   * {@inheritdoc}
   */
  public function createNew(): int|string {
    return $this->connection->insert('simpletest_test_id')
      ->useDefaults(['test_id'])
      ->execute();
  }

  /**
   * {@inheritdoc}
   */
  public function setDatabasePrefix(TestRun $test_run, string $database_prefix): void {
    $affected_rows = $this->connection->update('simpletest_test_id')
      ->fields(['last_prefix' => $database_prefix])
      ->condition('test_id', $test_run->id())
      ->execute();
    if (!$affected_rows) {
      throw new \RuntimeException('Failed to set up database prefix.');
    }
  }

  /**
   * {@inheritdoc}
   */
  public function insertLogEntry(TestRun $test_run, array $entry): bool {
    $entry['test_id'] = $test_run->id();
    $entry = array_merge([
      'function' => 'Unknown',
      'line' => 0,
      'file' => 'Unknown',
    ], $entry);

    return (bool) $this->connection->insert('simpletest')
      ->fields($entry)
      ->execute();
  }

  /**
   * {@inheritdoc}
   */
  public function removeResults(TestRun $test_run): int {
    $this->connection->startTransaction('delete_test_run');
    $this->connection->delete('simpletest')
      ->condition('test_id', $test_run->id())
      ->execute();
    $count = $this->connection->delete('simpletest_test_id')
      ->condition('test_id', $test_run->id())
      ->execute();
    return $count;
  }

  /**
   * {@inheritdoc}
   */
  public function getLogEntriesByTestClass(TestRun $test_run): array {
    return $this->connection->select('simpletest')
      ->fields('simpletest')
      ->condition('test_id', $test_run->id())
      ->orderBy('test_class')
      ->orderBy('message_id')
      ->execute()
      ->fetchAll();
  }

  /**
   * {@inheritdoc}
   */
  public function getCurrentTestRunState(TestRun $test_run): array {
    // Define a subquery to identify the latest 'message_id' given the
    // $test_id.
    $max_message_id_subquery = $this->connection
      ->select('simpletest', 'sub')
      ->condition('test_id', $test_run->id());
    $max_message_id_subquery->addExpression('MAX([message_id])', 'max_message_id');

    // Run a select query to return 'last_prefix' from {simpletest_test_id} and
    // 'test_class' from {simpletest}.
    $select = $this->connection->select($max_message_id_subquery, 'st_sub');
    $select->join('simpletest', 'st', '[st].[message_id] = [st_sub].[max_message_id]');
    $select->join('simpletest_test_id', 'sttid', '[st].[test_id] = [sttid].[test_id]');
    $select->addField('sttid', 'last_prefix', 'db_prefix');
    $select->addField('st', 'test_class');

    return $select->execute()->fetchAssoc();
  }

  /**
   * {@inheritdoc}
   */
  public function buildTestingResultsEnvironment(bool $keep_results): void {
    $schema = $this->connection->schema();
    foreach (static::testingResultsSchema() as $name => $table_spec) {
      $table_exists = $schema->tableExists($name);
      if (!$keep_results && $table_exists) {
        $this->connection->truncate($name)->execute();
      }
      if (!$table_exists) {
        $schema->createTable($name, $table_spec);
      }
    }
  }

  /**
   * {@inheritdoc}
   */
  public function validateTestingResultsEnvironment(): bool {
    $schema = $this->connection->schema();
    return $schema->tableExists('simpletest') && $schema->tableExists('simpletest_test_id');
  }

  /**
   * {@inheritdoc}
   */
  public function cleanUp(): int {
    // Clear test results.
    $this->connection->startTransaction('delete_simpletest');
    $this->connection->delete('simpletest')->execute();
    $count = $this->connection->delete('simpletest_test_id')->execute();
    return $count;
  }

  /**
   * Defines the database schema for run-tests.sh and simpletest module.
   *
   * @return array
   *   Array suitable for use in a hook_schema() implementation.
   */
  public static function testingResultsSchema(): array {
    $schema['simpletest'] = [
      'description' => 'Stores simpletest messages',
      'fields' => [
        'message_id' => [
          'type' => 'serial',
          'not null' => TRUE,
          'description' => 'Primary Key: Unique simpletest message ID.',
        ],
        'test_id' => [
          'type' => 'int',
          'not null' => TRUE,
          'default' => 0,
          'description' => 'Test ID, messages belonging to the same ID are reported together',
        ],
        'test_class' => [
          'type' => 'varchar_ascii',
          'length' => 255,
          'not null' => TRUE,
          'default' => '',
          'description' => 'The name of the class that created this message.',
        ],
        'status' => [
          'type' => 'varchar',
          'length' => 9,
          'not null' => TRUE,
          'default' => '',
          'description' => 'Message status. Core understands pass, fail, exception.',
        ],
        'message' => [
          'type' => 'text',
          'not null' => TRUE,
          'description' => 'The message itself.',
        ],
        'message_group' => [
          'type' => 'varchar_ascii',
          'length' => 255,
          'not null' => TRUE,
          'default' => '',
          'description' => 'The message group this message belongs to. For example: warning, browser, user.',
        ],
        'function' => [
          'type' => 'varchar_ascii',
          'length' => 255,
          'not null' => TRUE,
          'default' => '',
          'description' => 'Name of the assertion function or method that created this message.',
        ],
        'line' => [
          'type' => 'int',
          'not null' => TRUE,
          'default' => 0,
          'description' => 'Line number on which the function is called.',
        ],
        'file' => [
          'type' => 'varchar',
          'length' => 255,
          'not null' => TRUE,
          'default' => '',
          'description' => 'Name of the file where the function is called.',
        ],
      ],
      'primary key' => ['message_id'],
      'indexes' => [
        'reporter' => ['test_class', 'message_id'],
      ],
    ];
    $schema['simpletest_test_id'] = [
      'description' => 'Stores simpletest test IDs, used to auto-increment the test ID so that a fresh test ID is used.',
      'fields' => [
        'test_id' => [
          'type' => 'serial',
          'not null' => TRUE,
          'description' => 'Primary Key: Unique simpletest ID used to group test results together. Each time a set of tests are run a new test ID is used.',
        ],
        'last_prefix' => [
          'type' => 'varchar',
          'length' => 60,
          'not null' => FALSE,
          'default' => '',
          'description' => 'The last database prefix used during testing.',
        ],
      ],
      'primary key' => ['test_id'],
    ];
    return $schema;
  }

}
+12 −282

File changed.

Preview size limit exceeded, changes collapsed.

Loading