From b1a87360228e3bb9cfc29972833a8238d4a149b5 Mon Sep 17 00:00:00 2001 From: Alex Pott <alex.a.pott@googlemail.com> Date: Wed, 6 Dec 2023 11:09:40 +0000 Subject: [PATCH] Issue #3352851 by catch, Fabianx, mondrake, xjm, alexpott: Allow assertions on the number of database queries run during tests --- core/lib/Drupal/Core/Database/Connection.php | 4 +- .../performance_test.info.yml | 5 + .../performance_test.services.yml | 9 + .../src/DatabaseEventEnabler.php | 32 +++ .../src/PerformanceDataCollector.php | 51 +++++ .../NoJavaScriptAnonymousTest.php | 85 -------- .../StandardPerformanceTest.php | 202 ++++++++++++++++++ .../PerformanceTestBase.php | 5 + core/tests/Drupal/Tests/PerformanceData.php | 100 +++++++++ .../Drupal/Tests/PerformanceTestTrait.php | 53 ++++- 10 files changed, 459 insertions(+), 87 deletions(-) create mode 100644 core/modules/system/tests/modules/performance_test/performance_test.info.yml create mode 100644 core/modules/system/tests/modules/performance_test/performance_test.services.yml create mode 100644 core/modules/system/tests/modules/performance_test/src/DatabaseEventEnabler.php create mode 100644 core/modules/system/tests/modules/performance_test/src/PerformanceDataCollector.php delete mode 100644 core/profiles/standard/tests/src/FunctionalJavascript/NoJavaScriptAnonymousTest.php create mode 100644 core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php diff --git a/core/lib/Drupal/Core/Database/Connection.php b/core/lib/Drupal/Core/Database/Connection.php index 97b528c25bed..cdaf0d19ddd5 100644 --- a/core/lib/Drupal/Core/Database/Connection.php +++ b/core/lib/Drupal/Core/Database/Connection.php @@ -2205,7 +2205,9 @@ public static function removeDatabaseEntriesFromDebugBacktrace(array $backtrace, * The debug backtrace. */ protected function getDebugBacktrace(): array { - return debug_backtrace(); + // @todo: allow a backtrace including all arguments as an option. + // See https://www.drupal.org/project/drupal/issues/3401906 + return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); } } diff --git a/core/modules/system/tests/modules/performance_test/performance_test.info.yml b/core/modules/system/tests/modules/performance_test/performance_test.info.yml new file mode 100644 index 000000000000..8d909c8a09ef --- /dev/null +++ b/core/modules/system/tests/modules/performance_test/performance_test.info.yml @@ -0,0 +1,5 @@ +name: 'Performance test' +type: module +description: 'Supports performance testing with PerformanceTestTrait' +package: Testing +version: VERSION diff --git a/core/modules/system/tests/modules/performance_test/performance_test.services.yml b/core/modules/system/tests/modules/performance_test/performance_test.services.yml new file mode 100644 index 000000000000..e598315bea9a --- /dev/null +++ b/core/modules/system/tests/modules/performance_test/performance_test.services.yml @@ -0,0 +1,9 @@ +services: + Drupal\performance_test\PerformanceDataCollector: + tags: + - { name: event_subscriber } + - { name: needs_destruction, priority: -1000 } + Drupal\performance_test\DatabaseEventEnabler: + arguments: ['@database'] + tags: + - { name: http_middleware, priority: 1000, responder: true } diff --git a/core/modules/system/tests/modules/performance_test/src/DatabaseEventEnabler.php b/core/modules/system/tests/modules/performance_test/src/DatabaseEventEnabler.php new file mode 100644 index 000000000000..ec75884fd972 --- /dev/null +++ b/core/modules/system/tests/modules/performance_test/src/DatabaseEventEnabler.php @@ -0,0 +1,32 @@ +<?php + +namespace Drupal\performance_test; + +use Drupal\Core\Database\Connection; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Drupal\Core\Database\Event\StatementExecutionEndEvent; +use Drupal\Core\Database\Event\StatementExecutionStartEvent; + +class DatabaseEventEnabler implements HttpKernelInterface { + + public function __construct(protected readonly HttpKernelInterface $httpKernel, protected readonly Connection $connection) {} + + /** + * {@inheritdoc} + */ + public function handle(Request $request, $type = self::MAIN_REQUEST, $catch = TRUE): Response { + if ($type === static::MAIN_REQUEST) { + $this->connection->enableEvents([ + // StatementExecutionStartEvent must be enabled in order for + // StatementExecutionEndEvent to be fired, even though we only subscribe + // to the latter event. + StatementExecutionStartEvent::class, + StatementExecutionEndEvent::class, + ]); + } + return $this->httpKernel->handle($request, $type, $catch); + } + +} diff --git a/core/modules/system/tests/modules/performance_test/src/PerformanceDataCollector.php b/core/modules/system/tests/modules/performance_test/src/PerformanceDataCollector.php new file mode 100644 index 000000000000..318ff23528fa --- /dev/null +++ b/core/modules/system/tests/modules/performance_test/src/PerformanceDataCollector.php @@ -0,0 +1,51 @@ +<?php + +namespace Drupal\performance_test; + +use Drupal\Core\Database\Event\StatementExecutionEndEvent; +use Drupal\Core\DestructableInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +class PerformanceDataCollector implements EventSubscriberInterface, DestructableInterface { + + /** + * Database events collected during the request. + * + * @var Drupal\Core\Database\Event\StatementExecutionEndEvent[] + */ + protected array $databaseEvents = []; + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + StatementExecutionEndEvent::class => 'onStatementExecutionEnd', + ]; + } + + /** + * Logs database statements. + */ + public function onStatementExecutionEnd(StatementExecutionEndEvent $event): void { + // Use the event object as a value object. + $this->databaseEvents[] = $event; + } + + /** + * {@inheritdoc} + */ + public function destruct(): void { + // Get the events now before issuing any more database queries so that this + // logging does not become part of the recorded data. + $database_events = $this->databaseEvents; + + // Deliberately do not use an injected key value service to avoid any + // overhead up until this point. + $collection = \Drupal::keyValue('performance_test'); + $existing_data = $collection->get('performance_test_data') ?? ['database_events' => []]; + $existing_data['database_events'] = array_merge($existing_data['database_events'], $database_events); + $collection->set('performance_test_data', $existing_data); + } + +} diff --git a/core/profiles/standard/tests/src/FunctionalJavascript/NoJavaScriptAnonymousTest.php b/core/profiles/standard/tests/src/FunctionalJavascript/NoJavaScriptAnonymousTest.php deleted file mode 100644 index 67495663e48c..000000000000 --- a/core/profiles/standard/tests/src/FunctionalJavascript/NoJavaScriptAnonymousTest.php +++ /dev/null @@ -1,85 +0,0 @@ -<?php - -namespace Drupal\Tests\standard\FunctionalJavascript; - -use Drupal\Tests\PerformanceData; -use Drupal\FunctionalJavascriptTests\PerformanceTestBase; -use Drupal\node\NodeInterface; - -/** - * Tests that anonymous users are not served any JavaScript. - * - * This is tested with the core modules that are enabled in the 'standard' - * profile. - * - * @group Common - */ -class NoJavaScriptAnonymousTest extends PerformanceTestBase { - - /** - * {@inheritdoc} - */ - protected $defaultTheme = 'stark'; - - /** - * {@inheritdoc} - */ - protected $profile = 'standard'; - - /** - * {@inheritdoc} - */ - protected function setUp(): void { - parent::setUp(); - - // Grant the anonymous user the permission to look at user profiles. - user_role_grant_permissions('anonymous', ['access user profiles']); - } - - /** - * Tests that anonymous users are not served any JavaScript. - */ - public function testNoJavaScript() { - // Create a node of content type 'article' that is listed on the frontpage. - $this->drupalCreateNode([ - 'type' => 'article', - 'promote' => NodeInterface::PROMOTED, - ]); - - // Test frontpage. - $performance_data = $this->collectPerformanceData(function () { - $this->drupalGet(''); - }); - $this->assertNoJavaScript($performance_data); - - // Test node page. - $performance_data = $this->collectPerformanceData(function () { - $this->drupalGet('node/1'); - }); - $this->assertNoJavaScript($performance_data); - - // Test user profile page. - $user = $this->drupalCreateUser(); - $performance_data = $this->collectPerformanceData(function () use ($user) { - $this->drupalGet('user/' . $user->id()); - }); - $this->assertNoJavaScript($performance_data); - } - - /** - * Passes if no JavaScript is found on the page. - * - * @param Drupal\Tests\PerformanceData $performance_data - * A PerformanceData value object. - * - * @internal - */ - protected function assertNoJavaScript(PerformanceData $performance_data): void { - // Ensure drupalSettings is not set. - $settings = $this->getDrupalSettings(); - $this->assertEmpty($settings, 'drupalSettings is not set.'); - $this->assertSession()->responseNotMatches('/\.js/'); - $this->assertSame(0, $performance_data->getScriptCount()); - } - -} diff --git a/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php b/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php new file mode 100644 index 000000000000..334da1444476 --- /dev/null +++ b/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php @@ -0,0 +1,202 @@ +<?php + +namespace Drupal\Tests\standard\FunctionalJavascript; + +use Drupal\FunctionalJavascriptTests\PerformanceTestBase; +use Drupal\Tests\PerformanceData; +use Drupal\node\NodeInterface; + +/** + * Tests the performance of basic functionality in the standard profile. + * + * Stark is used as the default theme so that this test is not Olivero specific. + * + * @group Common + */ +class StandardPerformanceTest extends PerformanceTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected $profile = 'standard'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // Grant the anonymous user the permission to look at user profiles. + user_role_grant_permissions('anonymous', ['access user profiles']); + } + + /** + * Tests performance for anonymous users. + */ + public function testAnonymous() { + // Create two nodes to be shown on the front page. + $this->drupalCreateNode([ + 'type' => 'article', + 'promote' => NodeInterface::PROMOTED, + ]); + // Request a page that we're not otherwise explicitly testing to warm some + // caches. + $this->drupalGet('search'); + + // Test frontpage. + $performance_data = $this->collectPerformanceData(function () { + $this->drupalGet(''); + }); + $this->assertNoJavaScript($performance_data); + // This test observes a variable number of cache gets and sets, so to avoid + // random test failures, assert greater than equal the highest and lowest + // number of observed during test runs. + // See https://www.drupal.org/project/drupal/issues/3402610 + $this->assertGreaterThanOrEqual(58, $performance_data->getQueryCount()); + $this->assertLessThanOrEqual(66, $performance_data->getQueryCount()); + + $this->assertGreaterThanOrEqual(129, $performance_data->getCacheGetCount()); + $this->assertLessThanOrEqual(132, $performance_data->getCacheGetCount()); + $this->assertSame(59, $performance_data->getCacheSetCount()); + $this->assertSame(0, $performance_data->getCacheDeleteCount()); + + // Test node page. + $performance_data = $this->collectPerformanceData(function () { + $this->drupalGet('node/1'); + }); + $this->assertNoJavaScript($performance_data); + $this->assertSame(38, $performance_data->getQueryCount()); + + // This test observes a variable number of cache gets and sets, so to avoid + // random test failures, assert greater than equal the highest and lowest + // number of queries observed during test runs. + // See https://www.drupal.org/project/drupal/issues/3402610 + $this->assertGreaterThanOrEqual(87, $performance_data->getCacheGetCount()); + $this->assertLessThanOrEqual(88, $performance_data->getCacheGetCount()); + $this->assertSame(20, $performance_data->getCacheSetCount()); + $this->assertSame(0, $performance_data->getCacheDeleteCount()); + + // Test user profile page. + $user = $this->drupalCreateUser(); + $performance_data = $this->collectPerformanceData(function () use ($user) { + $this->drupalGet('user/' . $user->id()); + }); + $this->assertNoJavaScript($performance_data); + $this->assertSame(40, $performance_data->getQueryCount()); + + // This test observes a variable number of cache gets and sets, so to avoid + // random test failures, assert greater than equal the highest and lowest + // number of queries observed during test runs. + // See https://www.drupal.org/project/drupal/issues/3402610 + $this->assertGreaterThanOrEqual(74, $performance_data->getCacheGetCount()); + $this->assertLessThanOrEqual(80, $performance_data->getCacheGetCount()); + $this->assertSame(19, $performance_data->getCacheSetCount()); + $this->assertSame(0, $performance_data->getCacheDeleteCount()); + } + + /** + * Tests the performance of logging in. + */ + public function testLogin(): void { + // Create a user and log them in to warm all caches. Manually submit the + // form so that we repeat the same steps when recording performance data. Do + // this twice so that any caches which take two requests to warm are also + // covered. + $account = $this->drupalCreateUser(); + foreach (range(0, 1) as $index) { + $this->drupalGet('node'); + $this->drupalGet('user/login'); + $this->submitLoginForm($account); + $this->drupalLogout(); + } + + $this->drupalGet('node'); + $this->drupalGet('user/login'); + $performance_data = $this->collectPerformanceData(function () use ($account) { + $this->submitLoginForm($account); + }); + + // This test observes a variable number of database queries, so to avoid + // random test failures, assert greater than equal the highest and lowest + // number of queries observed during test runs. + // See https://www.drupal.org/project/drupal/issues/3402610 + $this->assertLessThanOrEqual(40, $performance_data->getQueryCount()); + $this->assertGreaterThanOrEqual(39, $performance_data->getQueryCount()); + $this->assertSame(28, $performance_data->getCacheGetCount()); + $this->assertSame(1, $performance_data->getCacheSetCount()); + $this->assertSame(1, $performance_data->getCacheDeleteCount()); + } + + /** + * Tests the performance of logging in via the user login block. + */ + public function testLoginBlock(): void { + $this->drupalPlaceBlock('user_login_block'); + // Create a user and log them in to warm all caches. Manually submit the + // form so that we repeat the same steps when recording performance data. Do + // this twice so that any caches which take two requests to warm are also + // covered. + $account = $this->drupalCreateUser(); + $this->drupalLogout(); + + foreach (range(0, 1) as $index) { + $this->drupalGet('node'); + $this->assertSession()->responseContains('Password'); + $this->submitLoginForm($account); + $this->drupalLogout(); + } + + $this->drupalGet('node'); + $this->assertSession()->responseContains('Password'); + $performance_data = $this->collectPerformanceData(function () use ($account) { + $this->submitLoginForm($account); + }); + $this->assertSame(48, $performance_data->getQueryCount()); + $this->assertSame(30, $performance_data->getCacheGetCount()); + + // This test observes a variable number of cache sets, so to avoid random + // test failures, assert greater than equal the highest and lowest number + // observed during test runs. + // See https://www.drupal.org/project/drupal/issues/3402610 + $this->assertLessThanOrEqual(4, $performance_data->getCacheSetCount()); + $this->assertGreaterThanOrEqual(1, $performance_data->getCacheSetCount()); + $this->assertSame(1, $performance_data->getCacheDeleteCount()); + } + + /** + * Submit the user login form. + */ + protected function submitLoginForm($account) { + $this->submitForm([ + 'name' => $account->getAccountName(), + 'pass' => $account->passRaw, + ], 'Log in'); + } + + /** + * Passes if no JavaScript is found on the page. + * + * @param Drupal\Tests\PerformanceData $performance_data + * A PerformanceData value object. + * + * @internal + */ + protected function assertNoJavaScript(PerformanceData $performance_data): void { + // Ensure drupalSettings is not set. + $settings = $this->getDrupalSettings(); + $this->assertEmpty($settings, 'drupalSettings is not set.'); + $this->assertSession()->responseNotMatches('/\.js/'); + $this->assertSame(0, $performance_data->getScriptCount()); + } + + /** + * Provides an empty implementation to prevent the resetting of caches. + */ + protected function refreshVariables() {} + +} diff --git a/core/tests/Drupal/FunctionalJavascriptTests/PerformanceTestBase.php b/core/tests/Drupal/FunctionalJavascriptTests/PerformanceTestBase.php index b7b2bed33ef6..fbd7cd32e0bd 100644 --- a/core/tests/Drupal/FunctionalJavascriptTests/PerformanceTestBase.php +++ b/core/tests/Drupal/FunctionalJavascriptTests/PerformanceTestBase.php @@ -15,6 +15,11 @@ class PerformanceTestBase extends WebDriverTestBase { use PerformanceTestTrait; + /** + * {@inheritdoc} + */ + protected static $modules = ['performance_test']; + /** * {@inheritdoc} */ diff --git a/core/tests/Drupal/Tests/PerformanceData.php b/core/tests/Drupal/Tests/PerformanceData.php index 528fefd81c95..0336c3a398e3 100644 --- a/core/tests/Drupal/Tests/PerformanceData.php +++ b/core/tests/Drupal/Tests/PerformanceData.php @@ -19,6 +19,26 @@ class PerformanceData { */ protected int $scriptCount = 0; + /** + * The number of database queries recorded. + */ + protected int $queryCount = 0; + + /** + * The number of cache gets recorded. + */ + protected int $cacheGetCount = 0; + + /** + * The number of cache sets recorded. + */ + protected int $cacheSetCount = 0; + + /** + * The number of cache deletes recorded. + */ + protected int $cacheDeleteCount = 0; + /** * The original return value. */ @@ -64,6 +84,86 @@ public function getScriptCount(): int { return $this->scriptCount; } + /** + * Sets the query count. + * + * @param int $count + * The number of database queries recorded. + */ + public function setQueryCount(int $count): void { + $this->queryCount = $count; + } + + /** + * Gets the query count. + * + * @return int + * The number of database queries recorded. + */ + public function getQueryCount(): int { + return $this->queryCount; + } + + /** + * Sets the cache get count. + * + * @param int $count + * The number of cache gets recorded. + */ + public function setCacheGetCount(int $count): void { + $this->cacheGetCount = $count; + } + + /** + * Gets the cache get count. + * + * @return int + * The number of cache gets recorded. + */ + public function getCacheGetCount(): int { + return $this->cacheGetCount; + } + + /** + * Sets the cache set count. + * + * @param int $count + * The number of cache sets recorded. + */ + public function setCacheSetCount(int $count): void { + $this->cacheSetCount = $count; + } + + /** + * Gets the cache set count. + * + * @return int + * The number of cache sets recorded. + */ + public function getCacheSetCount(): int { + return $this->cacheSetCount; + } + + /** + * Sets the cache delete count. + * + * @param int $count + * The number of cache deletes recorded. + */ + public function setCacheDeleteCount(int $count): void { + $this->cacheDeleteCount = $count; + } + + /** + * Gets the cache delete count. + * + * @return int + * The number of cache deletes recorded. + */ + public function getCacheDeleteCount(): int { + return $this->cacheDeleteCount; + } + /** * Sets the original return value. * diff --git a/core/tests/Drupal/Tests/PerformanceTestTrait.php b/core/tests/Drupal/Tests/PerformanceTestTrait.php index 77bdfb21573a..9f910d338775 100644 --- a/core/tests/Drupal/Tests/PerformanceTestTrait.php +++ b/core/tests/Drupal/Tests/PerformanceTestTrait.php @@ -93,12 +93,63 @@ private function doGetMinkDriverArgs(): string { * A PerformanceData value object. */ public function collectPerformanceData(callable $callable, ?string $service_name = NULL): PerformanceData { + // Clear all existing performance logs before collecting new data. This is + // necessary because responses are returned back to tests prior to image + // and asset responses are returning to the browser, and before + // post-response tasks are guaranteed to have run. Assume that if there is + // no performance data logged by the child request within one second, that + // this means everything has finished. + $collection = \Drupal::keyValue('performance_test'); + while ($collection->get('performance_test_data')) { + $collection->deleteAll(); + sleep(1); + } + $session = $this->getSession(); $session->getDriver()->getWebDriverSession()->log('performance'); + $collection = \Drupal::keyValue('performance_test'); + $collection->deleteAll(); $return = $callable(); $performance_data = $this->processChromeDriverPerformanceLogs($service_name); if (isset($return)) { - $performance_data->setReturnValue($performance_data); + $performance_data->setReturnValue($return); + } + + $performance_test_data = $collection->get('performance_test_data'); + if ($performance_test_data) { + // Separate queries into two buckets, one for queries from the cache + // backend, and one for everything else (including those for cache tags). + $query_count = 0; + $cache_get_count = 0; + $cache_set_count = 0; + $cache_delete_count = 0; + foreach ($performance_test_data['database_events'] as $event) { + if (isset($event->caller['class']) && is_a(str_replace('\\\\', '\\', $event->caller['class']), '\Drupal\Core\Cache\DatabaseBackend', TRUE)) { + $method = strtolower($event->caller['function']); + if (str_contains($method, 'get')) { + $cache_get_count++; + } + elseif (str_contains($method, 'set')) { + $cache_set_count++; + } + elseif (str_contains($method, 'delete')) { + $cache_delete_count++; + } + elseif ($event->caller['function'] === 'ensureBinExists') { + // Don't record anything for ensureBinExists(). + } + else { + throw new \Exception("Tried to record a cache operation but did not recognize {$event->caller['function']}"); + } + } + else { + $query_count++; + } + } + $performance_data->setQueryCount($query_count); + $performance_data->setCacheGetCount($cache_get_count); + $performance_data->setCacheSetCount($cache_set_count); + $performance_data->setCacheDeleteCount($cache_delete_count); } return $performance_data; -- GitLab