diff --git a/tests/Unit/Libraries/Cms/Log/DelegatingPsrLoggerTest.php b/tests/Unit/Libraries/Cms/Log/DelegatingPsrLoggerTest.php
new file mode 100644
index 0000000000000..2ba7d63be913c
--- /dev/null
+++ b/tests/Unit/Libraries/Cms/Log/DelegatingPsrLoggerTest.php
@@ -0,0 +1,286 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Tests\Unit\Libraries\Cms\Log;
+
+use Joomla\CMS\Log\DelegatingPsrLogger;
+use Joomla\CMS\Log\Log;
+use Joomla\CMS\Log\LogEntry;
+use Joomla\Tests\Unit\UnitTestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestDox;
+use Psr\Log\LogLevel;
+
+/**
+ * Test class for DelegatingPsrLogger.
+ *
+ * @package Joomla.UnitTest
+ * @subpackage Log
+ * @since __DEPLOY_VERSION__
+ */
+#[TestDox('The delegating PSR-3 logger')]
+class DelegatingPsrLoggerTest extends UnitTestCase
+{
+ /**
+ * Empties the Log singleton before each test.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ Log::setInstance(null);
+ }
+
+ /**
+ * Puts the singleton back.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function tearDown(): void
+ {
+ Log::setInstance(null);
+
+ parent::tearDown();
+ }
+
+ /**
+ * Returns a Log which records the entries it is handed instead of dispatching them.
+ *
+ * @return Log
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function createRecordingLog(): Log
+ {
+ return new class () extends Log {
+ /**
+ * @var LogEntry[]
+ */
+ public array $entries = [];
+
+ public function __construct()
+ {
+ }
+
+ protected function addLogEntry(LogEntry $entry)
+ {
+ $this->entries[] = $entry;
+ }
+ };
+ }
+
+ /**
+ * The PSR-3 levels and the Joomla priorities they map to.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function dataLevels(): array
+ {
+ return [
+ 'emergency' => [LogLevel::EMERGENCY, Log::EMERGENCY],
+ 'alert' => [LogLevel::ALERT, Log::ALERT],
+ 'critical' => [LogLevel::CRITICAL, Log::CRITICAL],
+ 'error' => [LogLevel::ERROR, Log::ERROR],
+ 'warning' => [LogLevel::WARNING, Log::WARNING],
+ 'notice' => [LogLevel::NOTICE, Log::NOTICE],
+ 'info' => [LogLevel::INFO, Log::INFO],
+ 'debug' => [LogLevel::DEBUG, Log::DEBUG],
+ ];
+ }
+
+ /**
+ * Tests that each PSR-3 level is mapped to the matching Joomla priority.
+ *
+ * @param string $level The PSR-3 level
+ * @param integer $priority The Joomla priority it must map to
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[DataProvider('dataLevels')]
+ #[TestDox('maps a PSR-3 level to the matching priority')]
+ public function testLevelIsMappedToPriority(string $level, int $priority): void
+ {
+ $log = $this->createRecordingLog();
+ Log::setInstance($log);
+
+ (new DelegatingPsrLogger($log))->log($level, 'a message');
+
+ $this->assertSame($priority, $log->entries[0]->priority);
+ }
+
+ /**
+ * Tests that the convenience methods inherited from the PSR-3 abstract logger work.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('supports the PSR-3 convenience methods')]
+ public function testConvenienceMethodsWork(): void
+ {
+ $log = $this->createRecordingLog();
+ Log::setInstance($log);
+
+ $psrLogger = new DelegatingPsrLogger($log);
+
+ $psrLogger->warning('warned');
+ $psrLogger->debug('debugged');
+
+ $this->assertSame(Log::WARNING, $log->entries[0]->priority);
+ $this->assertSame('warned', $log->entries[0]->message);
+ $this->assertSame(Log::DEBUG, $log->entries[1]->priority);
+ $this->assertSame('debugged', $log->entries[1]->message);
+ }
+
+ /**
+ * Tests that an unknown level is refused.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('refuses an unknown level')]
+ public function testUnknownLevelIsRefused(): void
+ {
+ $log = $this->createRecordingLog();
+ Log::setInstance($log);
+
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('An invalid log level has been given.');
+
+ (new DelegatingPsrLogger($log))->log('nonsense', 'a message');
+ }
+
+ /**
+ * Tests that a category in the context is lifted out into the entry.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('lifts the category out of the context')]
+ public function testCategoryIsLiftedOutOfTheContext(): void
+ {
+ $log = $this->createRecordingLog();
+ Log::setInstance($log);
+
+ (new DelegatingPsrLogger($log))->info('a message', ['category' => 'MyCategory']);
+
+ $this->assertSame('mycategory', $log->entries[0]->category);
+ }
+
+ /**
+ * Tests that a date in the context is lifted out into the entry.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('lifts the date out of the context')]
+ public function testDateIsLiftedOutOfTheContext(): void
+ {
+ $log = $this->createRecordingLog();
+ Log::setInstance($log);
+
+ (new DelegatingPsrLogger($log))->info('a message', ['date' => '2021-05-06 07:08:09']);
+
+ $this->assertSame('2021-05-06 07:08:09', $log->entries[0]->date->format('Y-m-d H:i:s'));
+ }
+
+ /**
+ * Tests that the whole context is still forwarded, including the keys which were lifted out.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('forwards the whole context as well')]
+ public function testWholeContextIsForwarded(): void
+ {
+ $log = $this->createRecordingLog();
+ Log::setInstance($log);
+
+ $context = ['category' => 'MyCategory', 'date' => '2021-05-06 07:08:09', 'extra' => 'kept'];
+
+ (new DelegatingPsrLogger($log))->info('a message', $context);
+
+ $this->assertSame($context, $log->entries[0]->context);
+ }
+
+ /**
+ * Tests that an object which can be cast to a string is accepted as the message.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('accepts a stringable message')]
+ public function testStringableMessageIsAccepted(): void
+ {
+ $log = $this->createRecordingLog();
+ Log::setInstance($log);
+
+ $message = new class () implements \Stringable {
+ public function __toString(): string
+ {
+ return 'from an object';
+ }
+ };
+
+ (new DelegatingPsrLogger($log))->info($message);
+
+ $this->assertSame('from an object', $log->entries[0]->message);
+ }
+
+ /**
+ * Tests that the message goes to whichever Log is the singleton, not to the one the logger was
+ * built with.
+ *
+ * The delegation is written as $this->logger::add(...), a static call resolved through the
+ * instance, so the injected Log only decides which class is used and never receives anything
+ * itself. This pins the current behaviour rather than endorsing it.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ *
+ * @todo The logger should dispatch to the Log it was constructed with.
+ */
+ #[TestDox('dispatches to the current singleton rather than the injected log (known defect)')]
+ public function testDispatchGoesToTheCurrentSingleton(): void
+ {
+ $injected = $this->createRecordingLog();
+ $other = $this->createRecordingLog();
+
+ Log::setInstance($injected);
+
+ $psrLogger = new DelegatingPsrLogger($injected);
+ $psrLogger->info('while the injected log is the singleton');
+
+ Log::setInstance($other);
+ $psrLogger->info('after the singleton was replaced');
+
+ $this->assertCount(1, $injected->entries);
+ $this->assertSame('while the injected log is the singleton', $injected->entries[0]->message);
+
+ $this->assertCount(1, $other->entries);
+ $this->assertSame('after the singleton was replaced', $other->entries[0]->message);
+ }
+}
diff --git a/tests/Unit/Libraries/Cms/Log/LogEntryTest.php b/tests/Unit/Libraries/Cms/Log/LogEntryTest.php
new file mode 100644
index 0000000000000..01b6885b7d69a
--- /dev/null
+++ b/tests/Unit/Libraries/Cms/Log/LogEntryTest.php
@@ -0,0 +1,234 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Tests\Unit\Libraries\Cms\Log;
+
+use Joomla\CMS\Date\Date;
+use Joomla\CMS\Log\Log;
+use Joomla\CMS\Log\LogEntry;
+use Joomla\Tests\Unit\UnitTestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestDox;
+
+/**
+ * Test class for LogEntry.
+ *
+ * @package Joomla.UnitTest
+ * @subpackage Log
+ * @since __DEPLOY_VERSION__
+ */
+#[TestDox('A log entry')]
+class LogEntryTest extends UnitTestCase
+{
+ /**
+ * Test data for the testPriorityIsSanitised method.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function dataPriorities(): array
+ {
+ return [
+ 'emergency' => [Log::EMERGENCY, Log::EMERGENCY],
+ 'alert' => [Log::ALERT, Log::ALERT],
+ 'critical' => [Log::CRITICAL, Log::CRITICAL],
+ 'error' => [Log::ERROR, Log::ERROR],
+ 'warning' => [Log::WARNING, Log::WARNING],
+ 'notice' => [Log::NOTICE, Log::NOTICE],
+ 'info' => [Log::INFO, Log::INFO],
+ 'debug' => [Log::DEBUG, Log::DEBUG],
+ 'unknown falls back to info' => [Log::INFO, 999],
+ 'zero falls back to info' => [Log::INFO, 0],
+ 'null falls back to info' => [Log::INFO, null],
+ 'numeric string is not a hit' => [Log::INFO, '64'],
+ ];
+ }
+
+ /**
+ * Tests that the priority is one of the known ones, or INFO.
+ *
+ * The check is a strict in_array(), so a priority given as a numeric string does not match and
+ * falls back to INFO like anything else unknown.
+ *
+ * @param integer $expected The priority the entry must end up with
+ * @param mixed $priority The priority handed to the constructor
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[DataProvider('dataPriorities')]
+ #[TestDox('keeps a known priority and falls back to INFO for anything else')]
+ public function testPriorityIsSanitised(int $expected, $priority): void
+ {
+ $this->assertSame($expected, (new LogEntry('A message', $priority))->priority);
+ }
+
+ /**
+ * Test data for the testCategoryIsSanitised method.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function dataCategories(): array
+ {
+ return [
+ 'spaces and punctuation go' => ['mycategory', 'My Category!'],
+ 'dots dashes underscores stay' => ['a.b-c_d', 'a.b-c_d'],
+ 'upper case is lowered' => ['allcaps', 'ALLCAPS'],
+ 'symbols are stripped' => ['symbols', 'sym#$%bols'],
+ ];
+ }
+
+ /**
+ * Tests that the category is reduced to the safe character set and lower cased.
+ *
+ * @param string $expected The category the entry must end up with
+ * @param string $category The category handed to the constructor
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[DataProvider('dataCategories')]
+ #[TestDox('reduces the category to a safe lower case token')]
+ public function testCategoryIsSanitised(string $expected, string $category): void
+ {
+ $this->assertSame($expected, (new LogEntry('A message', Log::INFO, $category))->category);
+ }
+
+ /**
+ * Tests that an empty category is left as null rather than an empty string.
+ *
+ * The loggers test the category with empty(), so both behave the same, but the property really
+ * does stay at its declared default.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('leaves an empty category as null')]
+ public function testEmptyCategoryStaysNull(): void
+ {
+ $this->assertNull((new LogEntry('A message', Log::INFO, ''))->category);
+ }
+
+ /**
+ * Tests that the site root is redacted out of the message.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('redacts the site root out of the message')]
+ public function testSiteRootIsRedacted(): void
+ {
+ $entry = new LogEntry(JPATH_ROOT . '/index.php failed');
+
+ $this->assertSame('[ROOT]/index.php failed', $entry->message);
+ }
+
+ /**
+ * Tests that the temporary directory is redacted out of the message.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('redacts the temporary directory out of the message')]
+ public function testTemporaryDirectoryIsRedacted(): void
+ {
+ $entry = new LogEntry(sys_get_temp_dir() . '/upload.tmp failed');
+
+ $this->assertSame('[TMP]/upload.tmp failed', $entry->message);
+ }
+
+ /**
+ * Tests that a message with nothing to redact is left alone, and that it is cast to a string.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('leaves a message with nothing to redact alone')]
+ public function testPlainMessageIsUnchanged(): void
+ {
+ $this->assertSame('nothing to redact', (new LogEntry('nothing to redact'))->message);
+ $this->assertSame('123', (new LogEntry(123))->message);
+ }
+
+ /**
+ * Tests that an explicit date is used, and that it becomes a Date object.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('turns the given date into a Date object')]
+ public function testGivenDateIsUsed(): void
+ {
+ $entry = new LogEntry('A message', Log::INFO, '', '2020-01-02 03:04:05');
+
+ $this->assertInstanceOf(Date::class, $entry->date);
+ $this->assertSame('2020-01-02 03:04:05', $entry->date->format('Y-m-d H:i:s'));
+ }
+
+ /**
+ * Tests that an omitted date becomes the current time.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('falls back to now when no date is given')]
+ public function testOmittedDateBecomesNow(): void
+ {
+ foreach ([null, ''] as $date) {
+ $entry = new LogEntry('A message', Log::INFO, '', $date);
+
+ $this->assertInstanceOf(Date::class, $entry->date);
+ $this->assertEqualsWithDelta(time(), $entry->date->toUnix(), 10);
+ }
+ }
+
+ /**
+ * Tests that the context is stored as it was given.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('stores the context as it was given')]
+ public function testContextIsStoredVerbatim(): void
+ {
+ $context = ['user' => 42, 'nested' => ['a' => 'b']];
+
+ $this->assertSame($context, (new LogEntry('A message', Log::INFO, '', null, $context))->context);
+ $this->assertSame([], (new LogEntry('A message'))->context);
+ }
+
+ /**
+ * Tests that the call stack is captured.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('captures the call stack')]
+ public function testCallStackIsCaptured(): void
+ {
+ $entry = new LogEntry('A message');
+
+ $this->assertIsArray($entry->callStack);
+ $this->assertNotEmpty($entry->callStack);
+ }
+}
diff --git a/tests/Unit/Libraries/Cms/Log/LogTest.php b/tests/Unit/Libraries/Cms/Log/LogTest.php
new file mode 100644
index 0000000000000..9ec89e9add368
--- /dev/null
+++ b/tests/Unit/Libraries/Cms/Log/LogTest.php
@@ -0,0 +1,388 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Tests\Unit\Libraries\Cms\Log;
+
+use Joomla\CMS\Log\DelegatingPsrLogger;
+use Joomla\CMS\Log\Log;
+use Joomla\CMS\Log\LogEntry;
+use Joomla\CMS\Log\Logger\InMemoryLogger;
+use Joomla\Tests\Unit\UnitTestCase;
+use PHPUnit\Framework\Attributes\TestDox;
+
+/**
+ * Test class for Log.
+ *
+ * Log is a swappable singleton: setInstance() accepts a Log or null, which is what makes it
+ * testable. Every test here puts it back to null afterwards.
+ *
+ * @package Joomla.UnitTest
+ * @subpackage Log
+ * @since __DEPLOY_VERSION__
+ */
+#[TestDox('The log singleton')]
+class LogTest extends UnitTestCase
+{
+ /**
+ * Empties the singleton and the entries the in memory logger collects, both of which are static
+ * and shared with the rest of the suite.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->setStaticProperty(InMemoryLogger::class, 'logEntries', []);
+
+ Log::setInstance(null);
+ }
+
+ /**
+ * Puts the singleton back.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function tearDown(): void
+ {
+ Log::setInstance(null);
+
+ parent::tearDown();
+ }
+
+ /**
+ * Returns a Log which exposes the protected registration and lookup internals.
+ *
+ * @return Log
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function createInspectableLog(): Log
+ {
+ return new class () extends Log {
+ public function __construct()
+ {
+ }
+
+ public function addLoggerFor(array $options, $priorities = Log::ALL, $categories = [], $exclude = false): void
+ {
+ $this->addLoggerInternal($options, $priorities, $categories, $exclude);
+ }
+
+ public function findLoggersFor($priority, $category): array
+ {
+ return $this->findLoggers($priority, $category);
+ }
+
+ public function getConfigurations(): array
+ {
+ return $this->configurations;
+ }
+ };
+ }
+
+ /**
+ * Tests that the singleton can be swapped and emptied.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('accepts a replacement instance and a null')]
+ public function testInstanceCanBeSwapped(): void
+ {
+ $log = $this->createInspectableLog();
+
+ Log::setInstance($log);
+ $this->assertSame($log, (new \ReflectionProperty(Log::class, 'instance'))->getValue());
+
+ Log::setInstance(null);
+ $this->assertNull((new \ReflectionProperty(Log::class, 'instance'))->getValue());
+ }
+
+ /**
+ * Tests that a message added through the static entry point reaches a registered logger.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('delivers a message to a registered logger')]
+ public function testMessageReachesTheRegisteredLogger(): void
+ {
+ Log::addLogger(['logger' => 'inmemory', 'group' => 'logtest'], Log::ALL);
+
+ Log::add('a message', Log::WARNING, 'system');
+
+ $options = ['group' => 'logtest'];
+ $entries = (new InMemoryLogger($options))->getCollectedEntries();
+
+ $this->assertCount(1, $entries);
+ $this->assertSame('a message', $entries[0]->message);
+ $this->assertSame(Log::WARNING, $entries[0]->priority);
+ $this->assertSame('system', $entries[0]->category);
+ }
+
+ /**
+ * Tests that a ready made entry is delivered, as a copy.
+ *
+ * Every logger is handed its own clone, so that one logger cannot change what another one
+ * sees. The entry which arrives is therefore equal to the one which was submitted but is not
+ * the same object.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('delivers a ready made entry as a copy')]
+ public function testReadyMadeEntryIsDeliveredAsACopy(): void
+ {
+ Log::addLogger(['logger' => 'inmemory', 'group' => 'logtest'], Log::ALL);
+
+ $entry = new LogEntry('prepared', Log::ERROR);
+ Log::add($entry);
+
+ $options = ['group' => 'logtest'];
+ $entries = (new InMemoryLogger($options))->getCollectedEntries();
+
+ $this->assertCount(1, $entries);
+ $this->assertNotSame($entry, $entries[0], 'Each logger is handed its own clone of the entry');
+ $this->assertEquals($entry, $entries[0]);
+ $this->assertSame('prepared', $entries[0]->message);
+ $this->assertSame(Log::ERROR, $entries[0]->priority);
+ }
+
+ /**
+ * Tests that two loggers each get their own copy of the entry.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('hands every logger its own copy of the entry')]
+ public function testEveryLoggerGetsItsOwnCopy(): void
+ {
+ Log::addLogger(['logger' => 'inmemory', 'group' => 'first'], Log::ALL);
+ Log::addLogger(['logger' => 'inmemory', 'group' => 'second'], Log::ALL);
+
+ Log::add('shared message', Log::ERROR);
+
+ $firstOptions = ['group' => 'first'];
+ $secondOptions = ['group' => 'second'];
+
+ $first = (new InMemoryLogger($firstOptions))->getCollectedEntries();
+ $second = (new InMemoryLogger($secondOptions))->getCollectedEntries();
+
+ $this->assertCount(1, $first);
+ $this->assertCount(1, $second);
+ $this->assertNotSame($first[0], $second[0]);
+ $this->assertSame('shared message', $first[0]->message);
+ $this->assertSame('shared message', $second[0]->message);
+ }
+
+ /**
+ * Tests that the priority is treated as a bitmask.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('treats the priority as a bitmask')]
+ public function testPriorityIsABitmask(): void
+ {
+ $log = $this->createInspectableLog();
+ $log->addLoggerFor(['logger' => 'inmemory'], Log::ERROR | Log::WARNING);
+
+ $this->assertCount(1, $log->findLoggersFor(Log::ERROR, ''));
+ $this->assertCount(1, $log->findLoggersFor(Log::WARNING, ''));
+ $this->assertCount(0, $log->findLoggersFor(Log::INFO, ''));
+ $this->assertCount(0, $log->findLoggersFor(Log::DEBUG, ''));
+ }
+
+ /**
+ * Tests that ALL covers every priority.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('covers every priority with ALL')]
+ public function testAllCoversEveryPriority(): void
+ {
+ $log = $this->createInspectableLog();
+ $log->addLoggerFor(['logger' => 'inmemory'], Log::ALL);
+
+ foreach ([Log::EMERGENCY, Log::ALERT, Log::CRITICAL, Log::ERROR, Log::WARNING, Log::NOTICE, Log::INFO, Log::DEBUG] as $priority) {
+ $this->assertCount(1, $log->findLoggersFor($priority, ''), 'Priority ' . $priority . ' is not covered by ALL');
+ }
+ }
+
+ /**
+ * Tests that a logger with no categories takes everything.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('sends every category to a logger which named none')]
+ public function testNoCategoriesMeansEveryCategory(): void
+ {
+ $log = $this->createInspectableLog();
+ $log->addLoggerFor(['logger' => 'inmemory'], Log::ALL);
+
+ $this->assertCount(1, $log->findLoggersFor(Log::INFO, ''));
+ $this->assertCount(1, $log->findLoggersFor(Log::INFO, 'anything'));
+ }
+
+ /**
+ * Tests that a logger which named categories only takes those, whatever their case.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('matches the named categories without regard to case')]
+ public function testCategoryMatchingIsCaseInsensitive(): void
+ {
+ $log = $this->createInspectableLog();
+ $log->addLoggerFor(['logger' => 'inmemory'], Log::ALL, ['MyCategory']);
+
+ $this->assertCount(1, $log->findLoggersFor(Log::INFO, 'mycategory'));
+ $this->assertCount(1, $log->findLoggersFor(Log::INFO, 'MYCATEGORY'));
+ $this->assertCount(0, $log->findLoggersFor(Log::INFO, 'other'));
+ $this->assertCount(0, $log->findLoggersFor(Log::INFO, ''));
+ }
+
+ /**
+ * Tests that excluding categories inverts the match, and that the empty category counts as one
+ * of the categories which are not excluded.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('inverts the match when the categories are excluded')]
+ public function testExcludedCategoriesInvertTheMatch(): void
+ {
+ $log = $this->createInspectableLog();
+ $log->addLoggerFor(['logger' => 'inmemory'], Log::ALL, ['noisy'], true);
+
+ $this->assertCount(0, $log->findLoggersFor(Log::INFO, 'noisy'));
+ $this->assertCount(1, $log->findLoggersFor(Log::INFO, 'quiet'));
+ $this->assertCount(1, $log->findLoggersFor(Log::INFO, ''));
+ }
+
+ /**
+ * Tests that registering the same options twice keeps one configuration.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('keeps one configuration for identical options')]
+ public function testIdenticalOptionsAreRegisteredOnce(): void
+ {
+ $log = $this->createInspectableLog();
+
+ $log->addLoggerFor(['logger' => 'inmemory', 'group' => 'same']);
+ $log->addLoggerFor(['logger' => 'inmemory', 'group' => 'same']);
+
+ $this->assertCount(1, $log->getConfigurations());
+
+ $log->addLoggerFor(['logger' => 'inmemory', 'group' => 'different']);
+
+ $this->assertCount(2, $log->getConfigurations());
+ }
+
+ /**
+ * Tests that the logger name is lower cased and defaults to the text file logger.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('lower cases the logger name and defaults to formattedtext')]
+ public function testLoggerNameIsNormalised(): void
+ {
+ $log = $this->createInspectableLog();
+
+ $log->addLoggerFor([]);
+ $log->addLoggerFor(['logger' => 'INMEMORY']);
+
+ $this->assertSame(['formattedtext', 'inmemory'], array_column($log->getConfigurations(), 'logger'));
+ }
+
+ /**
+ * Tests that two different closures produce two configurations.
+ *
+ * Closures cannot be serialised, so the signature is built from the object hash instead. If
+ * that swap were dropped, registering a second callback logger would either fail outright or
+ * silently overwrite the first.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('tells two callback loggers apart by their closures')]
+ public function testDifferentClosuresProduceDifferentConfigurations(): void
+ {
+ $log = $this->createInspectableLog();
+
+ $log->addLoggerFor(['logger' => 'callback', 'callback' => function () {
+ }]);
+ $log->addLoggerFor(['logger' => 'callback', 'callback' => function () {
+ }]);
+
+ $this->assertCount(2, $log->getConfigurations());
+ }
+
+ /**
+ * Tests that the closure is put back into the options after the signature was built.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('puts the closure back after building the signature')]
+ public function testClosureIsRestoredAfterHashing(): void
+ {
+ $log = $this->createInspectableLog();
+
+ $log->addLoggerFor(['logger' => 'callback', 'callback' => function () {
+ }]);
+
+ foreach ($log->getConfigurations() as $configuration) {
+ $this->assertIsCallable($configuration['callback']);
+ }
+ }
+
+ /**
+ * Tests that a delegated PSR-3 logger can be created, and that it builds the singleton when
+ * there is not one yet.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('creates a delegated PSR-3 logger')]
+ public function testDelegatedPsrLoggerIsCreated(): void
+ {
+ $this->assertNull((new \ReflectionProperty(Log::class, 'instance'))->getValue());
+
+ $psrLogger = Log::createDelegatedLogger();
+
+ $this->assertInstanceOf(DelegatingPsrLogger::class, $psrLogger);
+ $this->assertNotNull((new \ReflectionProperty(Log::class, 'instance'))->getValue());
+ }
+}
diff --git a/tests/Unit/Libraries/Cms/Log/Logger/CallbackLoggerTest.php b/tests/Unit/Libraries/Cms/Log/Logger/CallbackLoggerTest.php
new file mode 100644
index 0000000000000..3780cfe587a8b
--- /dev/null
+++ b/tests/Unit/Libraries/Cms/Log/Logger/CallbackLoggerTest.php
@@ -0,0 +1,168 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Tests\Unit\Libraries\Cms\Log\Logger;
+
+use Joomla\CMS\Log\Log;
+use Joomla\CMS\Log\LogEntry;
+use Joomla\CMS\Log\Logger\CallbackLogger;
+use Joomla\Tests\Unit\UnitTestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestDox;
+
+/**
+ * Test class for CallbackLogger.
+ *
+ * Note that Logger::__construct() takes its options array by reference, so the options must always
+ * be held in a variable rather than passed as a literal. That is also why the data provider below
+ * hands over the callback rather than a ready made options array.
+ *
+ * @package Joomla.UnitTest
+ * @subpackage Log
+ * @since __DEPLOY_VERSION__
+ */
+#[TestDox('The callback logger')]
+class CallbackLoggerTest extends UnitTestCase
+{
+ /**
+ * Tests that the entry is handed to the callback.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('hands the entry to the callback')]
+ public function testEntryIsHandedToTheCallback(): void
+ {
+ $received = [];
+
+ $options = [
+ 'callback' => function (LogEntry $entry) use (&$received) {
+ $received[] = $entry;
+ },
+ ];
+
+ $logger = new CallbackLogger($options);
+ $entry = new LogEntry('a message', Log::WARNING, 'system');
+
+ $logger->addEntry($entry);
+
+ $this->assertCount(1, $received);
+ $this->assertSame($entry, $received[0]);
+ }
+
+ /**
+ * Tests that the callback is called once per entry.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('calls the callback once for every entry')]
+ public function testCallbackIsCalledOncePerEntry(): void
+ {
+ $calls = 0;
+
+ $options = [
+ 'callback' => function () use (&$calls) {
+ $calls++;
+ },
+ ];
+
+ $logger = new CallbackLogger($options);
+
+ $logger->addEntry(new LogEntry('one'));
+ $logger->addEntry(new LogEntry('two'));
+
+ $this->assertSame(2, $calls);
+ }
+
+ /**
+ * Tests that a callable given as a class method pair is accepted.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('accepts a callable given as an object and method pair')]
+ public function testObjectMethodPairIsAccepted(): void
+ {
+ $collector = new class () {
+ public array $seen = [];
+
+ public function collect(LogEntry $entry): void
+ {
+ $this->seen[] = $entry->message;
+ }
+ };
+
+ $options = ['callback' => [$collector, 'collect']];
+ $logger = new CallbackLogger($options);
+
+ $logger->addEntry(new LogEntry('through a method'));
+
+ $this->assertSame(['through a method'], $collector->seen);
+ }
+
+ /**
+ * Tests that a callable given as a function name is accepted.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('accepts a callable given as a function name')]
+ public function testFunctionNameIsAccepted(): void
+ {
+ $options = ['callback' => 'trim'];
+
+ $this->assertInstanceOf(CallbackLogger::class, new CallbackLogger($options));
+ }
+
+ /**
+ * Callbacks which are not usable.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function dataInvalidCallbacks(): array
+ {
+ return [
+ 'missing' => [null],
+ 'null' => [null],
+ 'unknown function' => ['no_such_function_exists_here'],
+ 'empty array' => [[]],
+ 'plain string' => ['not a callable'],
+ 'integer' => [42],
+ ];
+ }
+
+ /**
+ * Tests that a logger without a usable callback refuses to be built.
+ *
+ * @param mixed $callback The unusable callback
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[DataProvider('dataInvalidCallbacks')]
+ #[TestDox('refuses to be built without a usable callback')]
+ public function testInvalidCallbackIsRefused($callback): void
+ {
+ $options = $callback === null ? [] : ['callback' => $callback];
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('created without valid callback function');
+
+ new CallbackLogger($options);
+ }
+}
diff --git a/tests/Unit/Libraries/Cms/Log/Logger/EchoLoggerTest.php b/tests/Unit/Libraries/Cms/Log/Logger/EchoLoggerTest.php
new file mode 100644
index 0000000000000..ec5cb411fab80
--- /dev/null
+++ b/tests/Unit/Libraries/Cms/Log/Logger/EchoLoggerTest.php
@@ -0,0 +1,151 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Tests\Unit\Libraries\Cms\Log\Logger;
+
+use Joomla\CMS\Log\Log;
+use Joomla\CMS\Log\LogEntry;
+use Joomla\CMS\Log\Logger\EchoLogger;
+use Joomla\Tests\Unit\UnitTestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestDox;
+
+/**
+ * Test class for EchoLogger.
+ *
+ * Note that Logger::__construct() takes its options array by reference, so the options must always
+ * be held in a variable rather than passed as a literal.
+ *
+ * @package Joomla.UnitTest
+ * @subpackage Log
+ * @since __DEPLOY_VERSION__
+ */
+#[TestDox('The echo logger')]
+class EchoLoggerTest extends UnitTestCase
+{
+ /**
+ * The priorities and the labels the logger writes for them.
+ *
+ * These come from the base Logger and are the long names, unlike the abbreviations the syslog
+ * logger uses.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function dataPriorities(): array
+ {
+ return [
+ 'emergency' => ['EMERGENCY', Log::EMERGENCY],
+ 'alert' => ['ALERT', Log::ALERT],
+ 'critical' => ['CRITICAL', Log::CRITICAL],
+ 'error' => ['ERROR', Log::ERROR],
+ 'warning' => ['WARNING', Log::WARNING],
+ 'notice' => ['NOTICE', Log::NOTICE],
+ 'info' => ['INFO', Log::INFO],
+ 'debug' => ['DEBUG', Log::DEBUG],
+ ];
+ }
+
+ /**
+ * Tests that each priority is written with its label.
+ *
+ * @param string $label The label the logger must write
+ * @param integer $priority The priority of the entry
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[DataProvider('dataPriorities')]
+ #[TestDox('writes the label of the priority')]
+ public function testPriorityLabelIsWritten(string $label, int $priority): void
+ {
+ $options = [];
+ $logger = new EchoLogger($options);
+
+ $this->expectOutputString($label . ": a message\n");
+
+ $logger->addEntry(new LogEntry('a message', $priority));
+ }
+
+ /**
+ * Tests that a category is written in brackets after the message.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('appends the category in brackets')]
+ public function testCategoryIsAppended(): void
+ {
+ $options = [];
+ $logger = new EchoLogger($options);
+
+ $this->expectOutputString("WARNING: a message [system]\n");
+
+ $logger->addEntry(new LogEntry('a message', Log::WARNING, 'system'));
+ }
+
+ /**
+ * Tests that no brackets are written when there is no category.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('writes no brackets when there is no category')]
+ public function testNoCategoryMeansNoBrackets(): void
+ {
+ $options = [];
+ $logger = new EchoLogger($options);
+
+ $this->expectOutputString("INFO: a message\n");
+
+ $logger->addEntry(new LogEntry('a message', Log::INFO, ''));
+ }
+
+ /**
+ * Tests that the line separator can be replaced.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('honours a line separator given in the options')]
+ public function testLineSeparatorIsHonoured(): void
+ {
+ $options = ['line_separator' => '
'];
+ $logger = new EchoLogger($options);
+
+ $this->expectOutputString('INFO: a message
');
+
+ $logger->addEntry(new LogEntry('a message', Log::INFO));
+ }
+
+ /**
+ * Tests that several entries are written one after the other.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('writes one line per entry')]
+ public function testOneLinePerEntry(): void
+ {
+ $options = [];
+ $logger = new EchoLogger($options);
+
+ $this->expectOutputString("WARNING: first [a]\nERROR: second\n");
+
+ $logger->addEntry(new LogEntry('first', Log::WARNING, 'a'));
+ $logger->addEntry(new LogEntry('second', Log::ERROR));
+ }
+}
diff --git a/tests/Unit/Libraries/Cms/Log/Logger/InMemoryLoggerTest.php b/tests/Unit/Libraries/Cms/Log/Logger/InMemoryLoggerTest.php
new file mode 100644
index 0000000000000..fbf5d7539eea5
--- /dev/null
+++ b/tests/Unit/Libraries/Cms/Log/Logger/InMemoryLoggerTest.php
@@ -0,0 +1,156 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Tests\Unit\Libraries\Cms\Log\Logger;
+
+use Joomla\CMS\Log\Log;
+use Joomla\CMS\Log\LogEntry;
+use Joomla\CMS\Log\Logger\InMemoryLogger;
+use Joomla\Tests\Unit\UnitTestCase;
+use PHPUnit\Framework\Attributes\TestDox;
+
+/**
+ * Test class for InMemoryLogger.
+ *
+ * Note that Logger::__construct() takes its options array by reference, so the options must always
+ * be held in a variable rather than passed as a literal.
+ *
+ * @package Joomla.UnitTest
+ * @subpackage Log
+ * @since __DEPLOY_VERSION__
+ */
+#[TestDox('The in memory logger')]
+class InMemoryLoggerTest extends UnitTestCase
+{
+ /**
+ * Empties the collected entries, which are held in a static property shared by every instance
+ * and are never cleared by the logger itself.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->setStaticProperty(InMemoryLogger::class, 'logEntries', []);
+ }
+
+ /**
+ * Builds a logger for the given group.
+ *
+ * @param string|null $group The group, or null for the default one
+ *
+ * @return InMemoryLogger
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function createLogger(?string $group = null): InMemoryLogger
+ {
+ $options = $group === null ? [] : ['group' => $group];
+
+ return new InMemoryLogger($options);
+ }
+
+ /**
+ * Tests that entries come back in the order they were added.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('hands the entries back in the order they were added')]
+ public function testEntriesComeBackInOrder(): void
+ {
+ $logger = $this->createLogger();
+
+ $logger->addEntry(new LogEntry('first', Log::WARNING));
+ $logger->addEntry(new LogEntry('second', Log::ERROR));
+
+ $entries = $logger->getCollectedEntries();
+
+ $this->assertCount(2, $entries);
+ $this->assertSame('first', $entries[0]->message);
+ $this->assertSame('second', $entries[1]->message);
+ }
+
+ /**
+ * Tests that a logger with no entries hands back an empty array.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('hands back an empty array when nothing was logged')]
+ public function testEmptyGroupIsAnEmptyArray(): void
+ {
+ $this->assertSame([], $this->createLogger('nothing-here')->getCollectedEntries());
+ }
+
+ /**
+ * Tests that two groups do not see each other's entries.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('keeps the groups apart')]
+ public function testGroupsAreKeptApart(): void
+ {
+ $first = $this->createLogger('first');
+ $second = $this->createLogger('second');
+
+ $first->addEntry(new LogEntry('for the first group'));
+
+ $this->assertCount(1, $first->getCollectedEntries());
+ $this->assertSame([], $second->getCollectedEntries());
+ }
+
+ /**
+ * Tests that a second logger on the same group sees what the first one collected.
+ *
+ * This is what makes the logger usable as a test double: one instance is registered with Log
+ * and another is built afterwards to read the entries back.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('shares its entries with another logger on the same group')]
+ public function testEntriesAreSharedWithinAGroup(): void
+ {
+ $writer = $this->createLogger('shared');
+ $writer->addEntry(new LogEntry('written by the first instance'));
+
+ $reader = $this->createLogger('shared');
+
+ $this->assertCount(1, $reader->getCollectedEntries());
+ $this->assertSame('written by the first instance', $reader->getCollectedEntries()[0]->message);
+ }
+
+ /**
+ * Tests that the group falls back to "default" and that an empty group option is ignored.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('falls back to the default group')]
+ public function testGroupFallsBackToDefault(): void
+ {
+ $withoutOption = $this->createLogger();
+ $withoutOption->addEntry(new LogEntry('a message'));
+
+ $emptyOption = $this->createLogger('');
+
+ $this->assertCount(1, $emptyOption->getCollectedEntries());
+ }
+}
diff --git a/tests/Unit/Libraries/Cms/Log/Logger/SyslogLoggerTest.php b/tests/Unit/Libraries/Cms/Log/Logger/SyslogLoggerTest.php
new file mode 100644
index 0000000000000..352ac27a09f4d
--- /dev/null
+++ b/tests/Unit/Libraries/Cms/Log/Logger/SyslogLoggerTest.php
@@ -0,0 +1,170 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Tests\Unit\Libraries\Cms\Log\Logger;
+
+use Joomla\CMS\Log\Log;
+use Joomla\CMS\Log\Logger\SyslogLogger;
+use Joomla\Tests\Unit\UnitTestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestDox;
+
+/**
+ * Test class for SyslogLogger.
+ *
+ * addEntry() really does write to the syslog of the machine running the tests, so it is never
+ * called here. What is worth guarding is the priority map, which differs from the one every other
+ * logger uses, and the way the constructor normalises its options.
+ *
+ * Two options are deliberately never set. `sys_facility` reaches an `IS_WIN` constant which the
+ * unit bootstrap does not define, so a test which sets it dies with an undefined constant error.
+ * `sys_use_stderr` turns on LOG_PERROR, which would write the entries into the test output.
+ *
+ * @package Joomla.UnitTest
+ * @subpackage Log
+ * @since __DEPLOY_VERSION__
+ */
+#[TestDox('The syslog logger')]
+class SyslogLoggerTest extends UnitTestCase
+{
+ /**
+ * The priorities and the syslog names the logger maps them to.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function dataPriorities(): array
+ {
+ return [
+ 'emergency' => [Log::EMERGENCY, 'EMERG'],
+ 'alert' => [Log::ALERT, 'ALERT'],
+ 'critical' => [Log::CRITICAL, 'CRIT'],
+ 'error' => [Log::ERROR, 'ERR'],
+ 'warning' => [Log::WARNING, 'WARNING'],
+ 'notice' => [Log::NOTICE, 'NOTICE'],
+ 'info' => [Log::INFO, 'INFO'],
+ 'debug' => [Log::DEBUG, 'DEBUG'],
+ ];
+ }
+
+ /**
+ * Reads the priority map without building a logger, so that no syslog connection is opened.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private static function priorityMap(): array
+ {
+ $instance = (new \ReflectionClass(SyslogLogger::class))->newInstanceWithoutConstructor();
+
+ return (new \ReflectionProperty(SyslogLogger::class, 'priorities'))->getValue($instance);
+ }
+
+ /**
+ * Tests that each priority maps to the expected syslog name.
+ *
+ * These are the abbreviated syslog names, not the long ones the other loggers write, and that
+ * difference is the whole reason the class overrides the map.
+ *
+ * @param integer $priority The Log priority
+ * @param string $name The syslog name it must map to
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[DataProvider('dataPriorities')]
+ #[TestDox('maps a priority to its syslog name')]
+ public function testPriorityMapsToSyslogName(int $priority, string $name): void
+ {
+ $this->assertSame($name, self::priorityMap()[$priority]);
+ }
+
+ /**
+ * Tests that every name in the map has a matching LOG_* constant.
+ *
+ * addEntry() builds the syslog priority with constant('LOG_' . $name), so a name without a
+ * constant would be a fatal error the moment anything was logged.
+ *
+ * @param integer $priority The Log priority
+ * @param string $name The syslog name it maps to
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[DataProvider('dataPriorities')]
+ #[TestDox('maps to a name which has a matching LOG_ constant')]
+ public function testEveryNameHasALogConstant(int $priority, string $name): void
+ {
+ $this->assertTrue(\defined('LOG_' . $name), 'LOG_' . $name . ' is not defined');
+ }
+
+ /**
+ * Tests that the map covers every priority and nothing else.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('covers every priority')]
+ public function testMapCoversEveryPriority(): void
+ {
+ $this->assertSame(
+ [Log::EMERGENCY, Log::ALERT, Log::CRITICAL, Log::ERROR, Log::WARNING, Log::NOTICE, Log::INFO, Log::DEBUG],
+ array_keys(self::priorityMap())
+ );
+ }
+
+ /**
+ * Tests the identity and process id options the constructor fills in.
+ *
+ * The options array is held by reference, so the normalised values can be read straight back
+ * off the array which was handed to the constructor.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('fills in the identity and process id options')]
+ public function testDefaultOptionsAreFilledIn(): void
+ {
+ $options = [];
+ $logger = new SyslogLogger($options);
+
+ $this->assertSame('Joomla Platform', $options['sys_ident']);
+ $this->assertTrue($options['sys_add_pid']);
+ $this->assertFalse($options['sys_use_stderr']);
+
+ // Close the syslog connection deterministically rather than at the end of the test run
+ unset($logger);
+ }
+
+ /**
+ * Tests that the options which were given are kept, and cast to a boolean where that applies.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('keeps the options it was given and casts the flags')]
+ public function testGivenOptionsAreKept(): void
+ {
+ $options = ['sys_ident' => 'My Application', 'sys_add_pid' => '0'];
+ $logger = new SyslogLogger($options);
+
+ $this->assertSame('My Application', $options['sys_ident']);
+ $this->assertFalse($options['sys_add_pid']);
+
+ unset($logger);
+ }
+}
diff --git a/tests/Unit/Libraries/Cms/Log/Logger/W3cLoggerTest.php b/tests/Unit/Libraries/Cms/Log/Logger/W3cLoggerTest.php
new file mode 100644
index 0000000000000..c175e0ac8df90
--- /dev/null
+++ b/tests/Unit/Libraries/Cms/Log/Logger/W3cLoggerTest.php
@@ -0,0 +1,170 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Tests\Unit\Libraries\Cms\Log\Logger;
+
+use Joomla\CMS\Log\Logger\FormattedtextLogger;
+use Joomla\CMS\Log\Logger\W3cLogger;
+use Joomla\Tests\Unit\UnitTestCase;
+use PHPUnit\Framework\Attributes\TestDox;
+
+/**
+ * Test class for W3cLogger.
+ *
+ * The logger extends FormattedtextLogger, which writes to disk in addEntry() and falls back to the
+ * application configuration for its path, so only the constructor is exercised here and it is
+ * always given an explicit path. addEntry() is never called, and the deferred mode is never turned
+ * on because the destructor flushes it to disk.
+ *
+ * @package Joomla.UnitTest
+ * @subpackage Log
+ * @since __DEPLOY_VERSION__
+ */
+#[TestDox('The W3C logger')]
+class W3cLoggerTest extends UnitTestCase
+{
+ /**
+ * Builds a logger against the temporary directory.
+ *
+ * @param array $options Extra options for the logger
+ *
+ * @return array The logger and the options array it was built with
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function createLogger(array $options = []): array
+ {
+ $options = array_merge(['text_file_path' => sys_get_temp_dir()], $options);
+ $logger = new W3cLogger($options);
+
+ return [$logger, $options];
+ }
+
+ /**
+ * Tests that the log file is named after the logger when nothing else is asked for.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('names its file error.w3c.php by default')]
+ public function testDefaultFileName(): void
+ {
+ [, $options] = $this->createLogger();
+
+ $this->assertSame('error.w3c.php', $options['text_file']);
+ }
+
+ /**
+ * Tests that a file name which was asked for is kept.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('keeps a file name it was given')]
+ public function testGivenFileNameIsKept(): void
+ {
+ [, $options] = $this->createLogger(['text_file' => 'custom.php']);
+
+ $this->assertSame('custom.php', $options['text_file']);
+ }
+
+ /**
+ * Tests the W3C entry format, which is the reason the class exists.
+ *
+ * It is tab separated and splits the timestamp into a date and a time, where the parent writes
+ * a single DATETIME field.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('uses the tab separated W3C entry format')]
+ public function testEntryFormat(): void
+ {
+ [$logger] = $this->createLogger();
+
+ $format = (new \ReflectionProperty(W3cLogger::class, 'format'))->getValue($logger);
+
+ $this->assertSame("{DATE}\t{TIME}\t{PRIORITY}\t{CLIENTIP}\t{CATEGORY}\t{MESSAGE}", $format);
+ }
+
+ /**
+ * Tests that the fields are parsed out of the format.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('parses the fields out of its format')]
+ public function testFieldsAreParsed(): void
+ {
+ [$logger] = $this->createLogger();
+
+ $fields = (new \ReflectionProperty(FormattedtextLogger::class, 'fields'))->getValue($logger);
+
+ $this->assertSame(['DATE', 'TIME', 'PRIORITY', 'CLIENTIP', 'CATEGORY', 'MESSAGE'], $fields);
+ }
+
+ /**
+ * Tests that the path is built from the directory and the file name.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('builds its path from the directory and the file name')]
+ public function testPathIsBuilt(): void
+ {
+ [$logger, $options] = $this->createLogger(['text_file' => 'custom.php']);
+
+ $path = (new \ReflectionProperty(FormattedtextLogger::class, 'path'))->getValue($logger);
+
+ $this->assertSame($options['text_file_path'] . '/custom.php', $path);
+ }
+
+ /**
+ * Tests that building the logger does not touch the filesystem.
+ *
+ * The file is only created when the first entry is written, which is what makes it safe to
+ * build the logger in a test.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('does not touch the filesystem until something is logged')]
+ public function testConstructorDoesNotTouchTheFilesystem(): void
+ {
+ $file = sys_get_temp_dir() . '/w3c-unittest-' . uniqid() . '.php';
+
+ $this->createLogger(['text_file' => basename($file)]);
+
+ $this->assertFileDoesNotExist($file);
+ }
+
+ /**
+ * Tests that a custom entry format given in the options wins.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('lets the options replace its entry format')]
+ public function testEntryFormatCanBeReplaced(): void
+ {
+ [$logger] = $this->createLogger(['text_entry_format' => '{DATETIME} {MESSAGE}']);
+
+ $fields = (new \ReflectionProperty(FormattedtextLogger::class, 'fields'))->getValue($logger);
+
+ $this->assertSame(['DATETIME', 'MESSAGE'], $fields);
+ }
+}
diff --git a/tests/Unit/Libraries/Cms/Log/LoggerRegistryTest.php b/tests/Unit/Libraries/Cms/Log/LoggerRegistryTest.php
new file mode 100644
index 0000000000000..59dcd6dae1ecb
--- /dev/null
+++ b/tests/Unit/Libraries/Cms/Log/LoggerRegistryTest.php
@@ -0,0 +1,193 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Tests\Unit\Libraries\Cms\Log;
+
+use Joomla\CMS\Log\Logger\CallbackLogger;
+use Joomla\CMS\Log\Logger\DatabaseLogger;
+use Joomla\CMS\Log\Logger\EchoLogger;
+use Joomla\CMS\Log\Logger\FormattedtextLogger;
+use Joomla\CMS\Log\Logger\InMemoryLogger;
+use Joomla\CMS\Log\Logger\MessagequeueLogger;
+use Joomla\CMS\Log\Logger\SyslogLogger;
+use Joomla\CMS\Log\Logger\W3cLogger;
+use Joomla\CMS\Log\LoggerRegistry;
+use Joomla\Tests\Unit\UnitTestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestDox;
+
+/**
+ * Test class for LoggerRegistry.
+ *
+ * The map is a private instance property, so every test gets a registry of its own and there is no
+ * static state to reset.
+ *
+ * @package Joomla.UnitTest
+ * @subpackage Log
+ * @since __DEPLOY_VERSION__
+ */
+#[TestDox('The logger registry')]
+class LoggerRegistryTest extends UnitTestCase
+{
+ /**
+ * The keys the registry ships with, and the classes they resolve to.
+ *
+ * @return array
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public static function dataDefaultLoggers(): array
+ {
+ return [
+ 'callback' => ['callback', CallbackLogger::class],
+ 'database' => ['database', DatabaseLogger::class],
+ 'echo' => ['echo', EchoLogger::class],
+ 'formattedtext' => ['formattedtext', FormattedtextLogger::class],
+ 'messagequeue' => ['messagequeue', MessagequeueLogger::class],
+ 'syslog' => ['syslog', SyslogLogger::class],
+ 'w3c' => ['w3c', W3cLogger::class],
+ 'inmemory' => ['inmemory', InMemoryLogger::class],
+ ];
+ }
+
+ /**
+ * Tests that every key the registry ships with resolves to its logger.
+ *
+ * @param string $key The registry key
+ * @param string $class The class it must resolve to
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[DataProvider('dataDefaultLoggers')]
+ #[TestDox('resolves a key it ships with')]
+ public function testDefaultLoggerIsRegistered(string $key, string $class): void
+ {
+ $registry = new LoggerRegistry();
+
+ $this->assertTrue($registry->hasLogger($key));
+ $this->assertSame($class, $registry->getLoggerClass($key));
+ }
+
+ /**
+ * Tests that an unknown key is reported as missing rather than resolved.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('reports an unknown key as missing')]
+ public function testUnknownKeyIsMissing(): void
+ {
+ $this->assertFalse((new LoggerRegistry())->hasLogger('nosuchlogger'));
+ }
+
+ /**
+ * Tests that asking for an unknown key throws.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('throws when an unknown key is asked for')]
+ public function testUnknownKeyThrows(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage("The 'nosuchlogger' key is not registered.");
+
+ (new LoggerRegistry())->getLoggerClass('nosuchlogger');
+ }
+
+ /**
+ * Tests that a new key can be registered.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('registers a new key')]
+ public function testNewKeyCanBeRegistered(): void
+ {
+ $registry = new LoggerRegistry();
+ $registry->register('custom', InMemoryLogger::class);
+
+ $this->assertTrue($registry->hasLogger('custom'));
+ $this->assertSame(InMemoryLogger::class, $registry->getLoggerClass('custom'));
+ }
+
+ /**
+ * Tests that registering over an existing key is refused unless it is asked for.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('refuses to overwrite an existing key')]
+ public function testExistingKeyIsNotOverwritten(): void
+ {
+ $registry = new LoggerRegistry();
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage("The 'echo' key is already registered.");
+
+ $registry->register('echo', InMemoryLogger::class);
+ }
+
+ /**
+ * Tests that an existing key is replaced when that is asked for.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('overwrites an existing key when asked to')]
+ public function testExistingKeyIsOverwrittenWhenRequested(): void
+ {
+ $registry = new LoggerRegistry();
+ $registry->register('echo', InMemoryLogger::class, true);
+
+ $this->assertSame(InMemoryLogger::class, $registry->getLoggerClass('echo'));
+ }
+
+ /**
+ * Tests that a class which does not exist is refused.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('refuses a class which does not exist')]
+ public function testMissingClassIsRefused(): void
+ {
+ $registry = new LoggerRegistry();
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage("The 'No\\Such\\Logger' class for key 'custom' does not exist.");
+
+ $registry->register('custom', 'No\\Such\\Logger');
+ }
+
+ /**
+ * Tests that two registries do not share their maps.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('keeps its map to itself')]
+ public function testRegistriesDoNotShareTheirMaps(): void
+ {
+ $first = new LoggerRegistry();
+ $first->register('custom', InMemoryLogger::class);
+
+ $this->assertFalse((new LoggerRegistry())->hasLogger('custom'));
+ }
+}
diff --git a/tests/Unit/Libraries/Cms/Log/LoggerTest.php b/tests/Unit/Libraries/Cms/Log/LoggerTest.php
new file mode 100644
index 0000000000000..991eaa81c79b0
--- /dev/null
+++ b/tests/Unit/Libraries/Cms/Log/LoggerTest.php
@@ -0,0 +1,135 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Tests\Unit\Libraries\Cms\Log;
+
+use Joomla\CMS\Log\Log;
+use Joomla\CMS\Log\LogEntry;
+use Joomla\CMS\Log\Logger;
+use Joomla\Tests\Unit\UnitTestCase;
+use PHPUnit\Framework\Attributes\TestDox;
+
+/**
+ * Test class for the abstract Logger.
+ *
+ * The class is abstract, so it is exercised through a small anonymous subclass which records the
+ * entries it is given.
+ *
+ * @package Joomla.UnitTest
+ * @subpackage Log
+ * @since __DEPLOY_VERSION__
+ */
+#[TestDox('The abstract logger')]
+class LoggerTest extends UnitTestCase
+{
+ /**
+ * Returns a logger which records what it is given, built with the options handed in.
+ *
+ * @param array $options The options array, taken by reference by the constructor
+ *
+ * @return Logger
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function createLogger(array &$options): Logger
+ {
+ return new class ($options) extends Logger {
+ /**
+ * @var LogEntry[]
+ */
+ public array $entries = [];
+
+ public function addEntry(LogEntry $entry)
+ {
+ $this->entries[] = $entry;
+ }
+
+ public function readOptions(): array
+ {
+ return $this->options;
+ }
+
+ public function readPriorities(): array
+ {
+ return $this->priorities;
+ }
+ };
+ }
+
+ /**
+ * Tests that the options array is held by reference rather than copied.
+ *
+ * This is the trap the whole package shares: the constructor signature is
+ * `__construct(array &$options)`, so an options array cannot be passed as a literal, and a
+ * subclass which fills in its defaults writes them back into the caller's array.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('holds the options by reference')]
+ public function testOptionsAreHeldByReference(): void
+ {
+ $options = ['group' => 'first'];
+ $logger = $this->createLogger($options);
+
+ $this->assertSame(['group' => 'first'], $logger->readOptions());
+
+ // Changing the caller's array must be visible inside the logger
+ $options['group'] = 'second';
+
+ $this->assertSame(['group' => 'second'], $logger->readOptions());
+ }
+
+ /**
+ * Tests that the priority labels cover every priority.
+ *
+ * These are the long names. The syslog logger deliberately overrides them with the abbreviated
+ * syslog names.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('labels every priority with its long name')]
+ public function testPriorityLabels(): void
+ {
+ $options = [];
+
+ $this->assertSame(
+ [
+ Log::EMERGENCY => 'EMERGENCY',
+ Log::ALERT => 'ALERT',
+ Log::CRITICAL => 'CRITICAL',
+ Log::ERROR => 'ERROR',
+ Log::WARNING => 'WARNING',
+ Log::NOTICE => 'NOTICE',
+ Log::INFO => 'INFO',
+ Log::DEBUG => 'DEBUG',
+ ],
+ $this->createLogger($options)->readPriorities()
+ );
+ }
+
+ /**
+ * Tests that an empty options array is accepted.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[TestDox('accepts an empty options array')]
+ public function testEmptyOptionsAreAccepted(): void
+ {
+ $options = [];
+
+ $this->assertSame([], $this->createLogger($options)->readOptions());
+ }
+}
diff --git a/tests/Unit/UnitTestCase.php b/tests/Unit/UnitTestCase.php
index 3650d440c51c8..a9dbcfd033a54 100644
--- a/tests/Unit/UnitTestCase.php
+++ b/tests/Unit/UnitTestCase.php
@@ -10,9 +10,16 @@
namespace Joomla\Tests\Unit;
+use Joomla\CMS\Factory;
+use Joomla\CMS\Language\Language;
use Joomla\Database\DatabaseInterface;
use Joomla\Database\DatabaseQuery;
use Joomla\Database\QueryInterface;
+use Joomla\DI\Container;
+use Joomla\Registry\Registry;
+use PHPUnit\Framework\Attributes\After;
+use PHPUnit\Framework\Attributes\Before;
+use PHPUnit\Framework\MockObject\Stub;
/**
* Base Unit Test case for common behaviour across unit tests
@@ -21,6 +28,248 @@
*/
abstract class UnitTestCase extends \PHPUnit\Framework\TestCase
{
+ /**
+ * The Factory static properties which are snapshotted before, and restored after, every test.
+ *
+ * @var string[]
+ * @since __DEPLOY_VERSION__
+ */
+ private const FACTORY_STATE_PROPERTIES = [
+ 'application',
+ 'cache',
+ 'config',
+ 'container',
+ 'database',
+ 'dates',
+ 'document',
+ 'language',
+ 'mailer',
+ 'session',
+ ];
+
+ /**
+ * The values of the Factory static properties as they were before the current test started,
+ * or null when no snapshot has been taken.
+ *
+ * @var array|null
+ * @since __DEPLOY_VERSION__
+ */
+ private $factoryStateSnapshot;
+
+ /**
+ * The static properties changed through setStaticProperty(), and the values they held before.
+ *
+ * Each entry is [class, property, previous value], in the order the changes were made.
+ *
+ * @var array
+ * @since __DEPLOY_VERSION__
+ */
+ private $staticPropertySnapshots = [];
+
+ /**
+ * Records the global Factory state before the test runs.
+ *
+ * This is deliberately a #[Before] hook rather than setUp(), so that it also runs for the test
+ * cases which override setUp() without calling parent::setUp().
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[Before]
+ protected function snapshotFactoryState(): void
+ {
+ $snapshot = [];
+
+ foreach (self::FACTORY_STATE_PROPERTIES as $property) {
+ $snapshot[$property] = Factory::$$property;
+ }
+
+ $this->factoryStateSnapshot = $snapshot;
+ }
+
+ /**
+ * Puts the global Factory state back the way the test found it.
+ *
+ * This is deliberately a #[After] hook rather than tearDown(), so that it also runs for the test
+ * cases which override tearDown() without calling parent::tearDown(). It is a no-op when no
+ * snapshot was taken.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[After]
+ protected function restoreFactoryState(): void
+ {
+ if ($this->factoryStateSnapshot === null) {
+ return;
+ }
+
+ foreach ($this->factoryStateSnapshot as $property => $value) {
+ Factory::$$property = $value;
+ }
+
+ $this->factoryStateSnapshot = null;
+ }
+
+ /**
+ * Puts back the static properties which were changed through setStaticProperty().
+ *
+ * The changes are undone in reverse order, so that repeated changes to the same property end up
+ * back at the value it held before the first one.
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ #[After]
+ protected function restoreStaticProperties(): void
+ {
+ foreach (array_reverse($this->staticPropertySnapshots) as [$class, $property, $value]) {
+ $reflectionProperty = new \ReflectionProperty($class, $property);
+ $reflectionProperty->setValue(null, $value);
+ }
+
+ $this->staticPropertySnapshots = [];
+ }
+
+ /**
+ * Sets a static property of a class, remembering the previous value so that it can be put back
+ * after the test.
+ *
+ * Use it for the static caches which the CMS does not expose a reset method for, for example
+ * ComponentHelper::$components or HTMLHelper::$registry.
+ *
+ * @param string $class The class holding the property
+ * @param string $property The name of the static property
+ * @param mixed $value The value to set
+ *
+ * @return void
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function setStaticProperty(string $class, string $property, $value): void
+ {
+ $reflectionProperty = new \ReflectionProperty($class, $property);
+
+ $this->staticPropertySnapshots[] = [$class, $property, $reflectionProperty->getValue()];
+
+ $reflectionProperty->setValue(null, $value);
+ }
+
+ /**
+ * Returns a minimal dependency injection container, pre-populated with the services which CMS
+ * classes most commonly resolve through Factory::getContainer().
+ *
+ * The container is not installed into Factory::$container, call setUpContainer() for that.
+ *
+ * @param array $services Additional services, keyed by container key. They are merged over
+ * the defaults, so passing an existing key replaces that default.
+ *
+ * @return Container
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function createContainer(array $services = []): Container
+ {
+ $services = array_merge(
+ [
+ 'config' => new Registry(),
+ DatabaseInterface::class => $this->createStub(DatabaseInterface::class),
+ Language::class => $this->createLanguageStub(),
+ ],
+ $services
+ );
+
+ $container = new Container();
+
+ foreach ($services as $key => $service) {
+ // Share, so that the container hands back the very same instance rather than a clone of it
+ $container->share($key, $service);
+ }
+
+ return $container;
+ }
+
+ /**
+ * Creates a minimal dependency injection container and installs it as the global container.
+ *
+ * The previous container is restored after the test by restoreFactoryState().
+ *
+ * @param array $services Additional services, see createContainer()
+ *
+ * @return Container The container which was installed
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function setUpContainer(array $services = []): Container
+ {
+ Factory::$container = $this->createContainer($services);
+
+ return Factory::$container;
+ }
+
+ /**
+ * Returns a Language stub whose translation methods hand the language key back unchanged.
+ *
+ * Assertions can therefore be written against the untranslated key, which keeps them independent
+ * of the contents of the language ini files.
+ *
+ * @param array $strings Optional map of language key to translation, for the keys which the
+ * test does care about. The keys are upper cased, as Language does.
+ *
+ * @return Language&Stub
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function createLanguageStub(array $strings = []): Language
+ {
+ $strings = array_change_key_case($strings, CASE_UPPER);
+
+ $language = $this->createStub(Language::class);
+
+ $language->method('_')
+ ->willReturnCallback(
+ static function ($string) use ($strings) {
+ return $strings[strtoupper($string)] ?? $string;
+ }
+ );
+
+ $language->method('hasKey')
+ ->willReturnCallback(
+ static function ($string) use ($strings) {
+ return isset($strings[strtoupper($string)]);
+ }
+ );
+
+ $language->method('getTag')->willReturn('en-GB');
+ $language->method('getName')->willReturn('English (en-GB)');
+
+ return $language;
+ }
+
+ /**
+ * Installs a Language stub, so that the static calls to Text::_(), Text::sprintf() and friends
+ * return something assertable.
+ *
+ * Text resolves the language through Factory::getLanguage(), which hands back Factory::$language
+ * when it is set, so setting that property is enough. It is restored after the test by
+ * restoreFactoryState().
+ *
+ * @param array $strings Optional map of language key to translation, see createLanguageStub()
+ *
+ * @return Language&Stub The language which was installed
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ protected function setUpLanguage(array $strings = []): Language
+ {
+ Factory::$language = $this->createLanguageStub($strings);
+
+ return Factory::$language;
+ }
+
/**
* Returns a database query instance.
*