Issue #3352851 by catch, Fabianx, mondrake, xjm, alexpott: Allow assertions on the number of database queries run during tests
parent
5cd249e923
commit
b1a8736022
|
@ -2205,7 +2205,9 @@ abstract class Connection {
|
|||
* 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
name: 'Performance test'
|
||||
type: module
|
||||
description: 'Supports performance testing with PerformanceTestTrait'
|
||||
package: Testing
|
||||
version: VERSION
|
|
@ -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 }
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
|
@ -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() {}
|
||||
|
||||
}
|
|
@ -15,6 +15,11 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
|
|||
class PerformanceTestBase extends WebDriverTestBase {
|
||||
use PerformanceTestTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['performance_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
|
|
|
@ -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 @@ class PerformanceData {
|
|||
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.
|
||||
*
|
||||
|
|
|
@ -93,12 +93,63 @@ trait PerformanceTestTrait {
|
|||
* 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;
|
||||
|
|
Loading…
Reference in New Issue