Repository: facile-it/paraunit (php)

Issue:
Change injection point
As of now, 2.x injects itself inside PHPUnit using the `--bootstrap` option. We should switch to https://phpunit.readthedocs.io/en/main/extending-phpunit.html#extending-the-test-runner

This may require manual or automatic change of the PHPUnit config; we could require it to the end user, or try to change it automatically when Paraunit runs for the first time on a specific config file.

Pull request description:
Change injection point, use phpunit extension
Fixes #179 

Patch:
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index ba6357c5..88989055 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -1,37 +1,26 @@
 <?xml version="1.0"?>
-<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
-         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd" 
-         backupGlobals="false" 
-         cacheResult="false"
-         colors="true"
-         bootstrap="vendor/autoload.php"
-         failOnWarning="true"
-         failOnRisky="true"
-         timeoutForSmallTests="1"
-         timeoutForMediumTests="5"
-         timeoutForLargeTests="10"
-         beStrictAboutOutputDuringTests="true"
->
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd" backupGlobals="false" cacheResult="false" colors="true" bootstrap="vendor/autoload.php" failOnWarning="true" failOnRisky="true" timeoutForSmallTests="1" timeoutForMediumTests="5" timeoutForLargeTests="10" beStrictAboutOutputDuringTests="true">
   <php>
     <ini name="error_reporting" value="-1"/>
     <ini name="intl.default_locale" value="en"/>
     <ini name="intl.error_level" value="0"/>
     <ini name="memory_limit" value="-1"/>
   </php>
-
   <testsuites>
     <testsuite name="unit">
       <file>tests/Unit/Proxy/Coverage/FakeDriverTest.php</file>
-<!--      <directory>tests/Unit</directory>-->
+      <!--      <directory>tests/Unit</directory>-->
     </testsuite>
     <testsuite name="functional">
       <directory>tests/Functional</directory>
     </testsuite>
   </testsuites>
-
   <coverage>
     <include>
       <directory>src/</directory>
     </include>
   </coverage>
+  <extensions>
+    <bootstrap class="Paraunit\Configuration\ParaunitExtension"/>
+  </extensions>
 </phpunit>
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 044d6de4..906e5167 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -1,5 +1,13 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<files psalm-version="dev-allow-diff-5-on-5@680696a28a199b488d10774bce9698aa9ed11a74">
+<files psalm-version="5.6.0@e784128902dfe01d489c4123d69918a9f3c1eac5">
+  <file src="src/Configuration/PHPUnitConfig.php">
+    <DocblockTypeContradiction>
+      <code>$extension-&gt;attributes</code>
+    </DocblockTypeContradiction>
+    <RedundantConditionGivenDocblockType>
+      <code>$extension-&gt;attributes?-&gt;getNamedItem('class')</code>
+    </RedundantConditionGivenDocblockType>
+  </file>
   <file src="src/Configuration/ParallelConfiguration.php">
     <MixedArgument>
       <code>$input-&gt;getArgument('stringFilter')</code>
@@ -30,12 +38,6 @@
     </MixedMethodCall>
   </file>
   <file src="src/Filter/Filter.php">
-    <MixedArgument>
-      <code>$fileName</code>
-    </MixedArgument>
-    <MixedAssignment>
-      <code>$fileName</code>
-    </MixedAssignment>
     <PossiblyNullOperand>
       <code>$fileNode-&gt;nodeValue</code>
     </PossiblyNullOperand>
diff --git a/src/Command/ParallelCommand.php b/src/Command/ParallelCommand.php
index 2f506186..f36d1df0 100644
--- a/src/Command/ParallelCommand.php
+++ b/src/Command/ParallelCommand.php
@@ -9,10 +9,13 @@ use Paraunit\Configuration\PHPUnitConfig;
 use Paraunit\Configuration\PHPUnitOption;
 use Paraunit\Runner\Runner;
 use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\FormatterHelper;
+use Symfony\Component\Console\Helper\QuestionHelper;
 use Symfony\Component\Console\Input\InputArgument;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
 
 class ParallelCommand extends Command
 {
@@ -95,6 +98,8 @@ class ParallelCommand extends Command
 
         /** @var PHPUnitConfig $config */
         $config = $container->get(PHPUnitConfig::class);
+        $this->checkForExtension($config, $input, $output);
+        $this->assertExtensionIsInstalled($config, $input);
         $this->addPHPUnitOptions($config, $input);
 
         /** @var Runner $runner */
@@ -103,6 +108,41 @@ class ParallelCommand extends Command
         return $runner->run();
     }
 
+    private function checkForExtension(PHPUnitConfig $config, InputInterface $input, OutputInterface $output): void
+    {
+        if ($config->isParaunitExtensionRegistered()) {
+            return;
+        }
+
+        $formatter = $this->getHelper('formatter');
+        if (! $formatter instanceof FormatterHelper) {
+            throw new \InvalidArgumentException();
+        }
+
+        $output->writeln(
+            $formatter->formatBlock([
+                'Paraunit extension is not registered in the current PHPUnit configuration',
+                'Configuration in use: ' . $config->getFileFullPath(),
+                '',
+                'Do you want to update your configuration automatically? [y/N] ',
+            ], 'question', true)
+        );
+
+        $question = new ConfirmationQuestion('> ', false);
+        $questionHelper = $this->getHelper('question');
+        if (! $questionHelper instanceof QuestionHelper) {
+            throw new \InvalidArgumentException();
+        }
+
+        if ($questionHelper->ask($input, $output, $question)) {
+            $config->installExtension();
+
+            $output->writeln(
+                $formatter->formatBlock('Configuration updated successfully', 'info', true)
+            );
+        }
+    }
+
     private function addPHPUnitOptions(PHPUnitConfig $config, InputInterface $input): PHPUnitConfig
     {
         foreach ($this->phpunitOptions as $option) {
@@ -135,4 +175,17 @@ class ParallelCommand extends Command
 
         return true;
     }
+
+    protected function assertExtensionIsInstalled(PHPUnitConfig $config, InputInterface $input): void
+    {
+        if (! $config->isParaunitExtensionRegistered()) {
+            $errorMessage = 'Paraunit extension is not registered, unable to proceed';
+
+            if (! $input->isInteractive()) {
+                $errorMessage .= PHP_EOL . 'Re-run command in interactive mode to install it with an automatic procedure';
+            }
+
+            throw new \RuntimeException($errorMessage);
+        }
+    }
 }
diff --git a/src/Configuration/PHPUnitConfig.php b/src/Configuration/PHPUnitConfig.php
index 75923c05..5940e980 100644
--- a/src/Configuration/PHPUnitConfig.php
+++ b/src/Configuration/PHPUnitConfig.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Paraunit\Configuration;
 
+use PHPUnit\Util\Xml\Loader;
+
 class PHPUnitConfig
 {
     final public const DEFAULT_FILE_NAME = 'phpunit.xml';
@@ -15,12 +17,88 @@ class PHPUnitConfig
     /** @var PHPUnitOption[] */
     private array $phpunitOptions = [];
 
+    private readonly Loader $xmlLoader;
+
     /**
      * @throws \InvalidArgumentException
      */
     public function __construct(string $inputPathOrFileName)
     {
         $this->configFilename = $this->getConfigFileRealpath($inputPathOrFileName);
+        /** @psalm-suppress InternalClass */
+        $this->xmlLoader = new Loader();
+    }
+
+    public function isParaunitExtensionRegistered(): bool
+    {
+        /** @psalm-suppress InternalMethod */
+        $config = $this->xmlLoader->loadFile($this->configFilename);
+        $xpath = new \DOMXPath($config);
+        $extensions = $xpath->query('extensions/bootstrap');
+
+        if (! $extensions instanceof \DOMNodeList) {
+            return false;
+        }
+
+        $extensionName = ParaunitExtension::class;
+        foreach ($extensions as $extension) {
+            if (! $extension instanceof \DOMElement) {
+                continue;
+            }
+
+            $class = $extension->attributes?->getNamedItem('class');
+            if (! $class instanceof \DOMAttr) {
+                continue;
+            }
+
+            $className = ltrim($class->value, '\\');
+
+            if ($className === $extensionName) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    public function installExtension(): void
+    {
+        if ($this->isParaunitExtensionRegistered()) {
+            return;
+        }
+
+        /** @psalm-suppress InternalMethod */
+        $config = $this->xmlLoader->loadFile($this->configFilename);
+        $config->preserveWhiteSpace = false;
+        $config->formatOutput = true;
+
+        $extensionsNode = $this->getExtensionsNode($config);
+        $paraunitExtension = $config->createElement('bootstrap');
+        $paraunitExtension->setAttribute('class', ParaunitExtension::class);
+        $extensionsNode->prepend($paraunitExtension);
+
+        $config->save($this->configFilename);
+    }
+
+    private function getExtensionsNode(\DOMDocument $config): \DOMElement
+    {
+        $nodes = iterator_to_array($config->getElementsByTagName('phpunit')->getIterator());
+        $phpunitNode = array_pop($nodes);
+
+        if (! $phpunitNode instanceof \DOMElement) {
+            throw new \InvalidArgumentException('PHPUnit configuration is malformed - unable to install ParaunitExtension automatically');
+        }
+
+        foreach ($phpunitNode->childNodes->getIterator() as $childNode) {
+            if ($childNode instanceof \DOMElement && $childNode->nodeName === 'extensions') {
+                return $childNode;
+            }
+        }
+
+        $extensionsNode = $config->createElement('extensions');
+        $phpunitNode->append($extensionsNode);
+
+        return $extensionsNode;
     }
 
     /**
diff --git a/src/Configuration/ParaunitExtension.php b/src/Configuration/ParaunitExtension.php
new file mode 100644
index 00000000..99f73061
--- /dev/null
+++ b/src/Configuration/ParaunitExtension.php
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Paraunit\Configuration;
+
+use Paraunit\Logs\TestHook\Deprecation;
+use Paraunit\Logs\TestHook\Error;
+use Paraunit\Logs\TestHook\ExecutionStarted;
+use Paraunit\Logs\TestHook\Failure;
+use Paraunit\Logs\TestHook\Incomplete;
+use Paraunit\Logs\TestHook\Passed;
+use Paraunit\Logs\TestHook\PhpUnitWarning;
+use Paraunit\Logs\TestHook\PhpWarning;
+use Paraunit\Logs\TestHook\Risky;
+use Paraunit\Logs\TestHook\Skipped;
+use Paraunit\Logs\TestHook\TestFinished;
+use Paraunit\Logs\TestHook\TestPrepared;
+use Paraunit\Logs\TestHook\TestRunnerWarning;
+use Paraunit\Logs\TestHook\TestWarning;
+use PHPUnit\Runner\Extension\Extension;
+use PHPUnit\Runner\Extension\Facade;
+use PHPUnit\Runner\Extension\ParameterCollection;
+use PHPUnit\TextUI\Configuration\Configuration;
+
+class ParaunitExtension implements Extension
+{
+    public function bootstrap(Configuration $configuration, Facade $facade, ParameterCollection $parameters): void
+    {
+        if (false === getenv(EnvVariables::PROCESS_UNIQUE_ID)) {
+            return;
+        }
+
+        $facade->registerSubscribers(
+            new TestPrepared(),
+            new TestFinished(),
+            new Error(),
+            new Failure(),
+            new ExecutionStarted(),
+            new Incomplete(),
+            new Risky(),
+            new Deprecation(),
+            new Skipped(),
+            new Passed(),
+            new PhpWarning(),
+            new PhpUnitWarning(),
+            new TestWarning(),
+            new TestRunnerWarning(),
+        );
+    }
+}
diff --git a/src/Configuration/register_subscribers.php b/src/Configuration/register_subscribers.php
deleted file mode 100644
index 5da9c7fe..00000000
--- a/src/Configuration/register_subscribers.php
+++ /dev/null
@@ -1,37 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Paraunit\Configuration;
-
-use Paraunit\Logs\TestHook\Deprecation;
-use Paraunit\Logs\TestHook\Error;
-use Paraunit\Logs\TestHook\ExecutionStarted;
-use Paraunit\Logs\TestHook\Failure;
-use Paraunit\Logs\TestHook\Incomplete;
-use Paraunit\Logs\TestHook\Passed;
-use Paraunit\Logs\TestHook\PhpUnitWarning;
-use Paraunit\Logs\TestHook\PhpWarning;
-use Paraunit\Logs\TestHook\Risky;
-use Paraunit\Logs\TestHook\Skipped;
-use Paraunit\Logs\TestHook\TestFinished;
-use Paraunit\Logs\TestHook\TestPrepared;
-use Paraunit\Logs\TestHook\TestRunnerWarning;
-use Paraunit\Logs\TestHook\TestWarning;
-use PHPUnit\Event\Facade;
-
-// TODO - use extensions - https://github.com/facile-it/paraunit/issues/179
-Facade::registerSubscriber(new TestPrepared());
-Facade::registerSubscriber(new TestFinished());
-Facade::registerSubscriber(new Error());
-Facade::registerSubscriber(new Failure());
-Facade::registerSubscriber(new ExecutionStarted());
-Facade::registerSubscriber(new Incomplete());
-Facade::registerSubscriber(new Risky());
-Facade::registerSubscriber(new Deprecation());
-Facade::registerSubscriber(new Skipped());
-Facade::registerSubscriber(new Passed());
-Facade::registerSubscriber(new PhpWarning());
-Facade::registerSubscriber(new PhpUnitWarning());
-Facade::registerSubscriber(new TestWarning());
-Facade::registerSubscriber(new TestRunnerWarning());
diff --git a/src/Process/CommandLine.php b/src/Process/CommandLine.php
index fdd1e0cc..96e6db7c 100644
--- a/src/Process/CommandLine.php
+++ b/src/Process/CommandLine.php
@@ -32,10 +32,7 @@ class CommandLine
      */
     public function getOptions(PHPUnitConfig $config): array
     {
-        // TODO - what happens if the users wants to use its own bootstrap script?
-        $options = [
-            '--bootstrap=' . dirname(__DIR__) . '/Configuration/register_subscribers.php',
-        ];
+        $options = [];
 
         if (! $this->chunkSize->isChunked()) {
             $options[] = '--configuration=' . $config->getFileFullPath();


Test patch:
diff --git a/tests/BaseTestCase.php b/tests/BaseTestCase.php
index 84fc0091..da66c766 100644
--- a/tests/BaseTestCase.php
+++ b/tests/BaseTestCase.php
@@ -26,6 +26,23 @@ class BaseTestCase extends TestCase
         return $filename;
     }
 
+    protected function createConfigWithoutExtension(): string
+    {
+        $tmpDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'paraunit-test';
+        $this->assertTrue(is_dir($tmpDir) || mkdir($tmpDir), 'Failure while creating tmp dir');
+
+        $filename = tempnam($tmpDir, 'config_');
+        $this->assertNotFalse($filename, 'Error from tmp filesystem');
+        $this->assertTrue(copy($this->getConfigWithoutExtension(), $filename), 'Error while preparing config copy');
+
+        return $filename;
+    }
+
+    protected function getConfigWithoutExtension(): string
+    {
+        return $this->getStubPath() . 'phpunit_without_paraunit_installed.xml';
+    }
+
     protected function getConfigForStubs(): string
     {
         return $this->getStubPath() . 'phpunit_for_stubs.xml';
diff --git a/tests/Functional/Command/CoverageCommandTest.php b/tests/Functional/Command/CoverageCommandTest.php
index 32766eb9..f53e0f10 100644
--- a/tests/Functional/Command/CoverageCommandTest.php
+++ b/tests/Functional/Command/CoverageCommandTest.php
@@ -15,6 +15,20 @@ class CoverageCommandTest extends BaseTestCase
 {
     private const COMMAND_NAME = 'coverage';
 
+    public function testExecutionFailsWithoutExtension(): void
+    {
+        $commandTester = $this->createCommandTester();
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('Paraunit extension is not registered');
+
+        $commandTester->execute([
+            '--configuration' => $this->createConfigWithoutExtension(),
+            '--text' => null,
+            'stringFilter' => 'nothing',
+        ]);
+    }
+
     public function testExecutionWithTextToFile(): void
     {
         if ($this->isXdebugCoverageDisabled()) {
diff --git a/tests/Functional/Command/ParallelCommandTest.php b/tests/Functional/Command/ParallelCommandTest.php
index f2c90b31..477e93bc 100644
--- a/tests/Functional/Command/ParallelCommandTest.php
+++ b/tests/Functional/Command/ParallelCommandTest.php
@@ -6,6 +6,7 @@ namespace Tests\Functional\Command;
 
 use Paraunit\Command\ParallelCommand;
 use Paraunit\Configuration\ParallelConfiguration;
+use Paraunit\Configuration\PHPUnitConfig;
 use Paraunit\Logs\ValueObject\Test;
 use Symfony\Component\Console\Application;
 use Symfony\Component\Console\Tester\CommandTester;
@@ -18,6 +19,48 @@ use Tests\Stub\RaisingNoticeTestStub;
 
 class ParallelCommandTest extends BaseTestCase
 {
+    public function testExecutionFailsWithoutExtension(): void
+    {
+        $application = new Application();
+        $application->add(new ParallelCommand(new ParallelConfiguration()));
+
+        $command = $application->find('run');
+        $commandTester = new CommandTester($command);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('Paraunit extension is not registered');
+
+        $commandTester->execute([
+            'command' => $command->getName(),
+            '--configuration' => $this->createConfigWithoutExtension(),
+            'stringFilter' => 'nothing',
+        ]);
+    }
+
+    public function testExecutionUpgradesTheConfig(): void
+    {
+        $configWithoutExtension = $this->createConfigWithoutExtension();
+        $configChecker = new PHPUnitConfig($configWithoutExtension);
+        $this->assertFalse($configChecker->isParaunitExtensionRegistered());
+
+        $application = new Application();
+        $application->add(new ParallelCommand(new ParallelConfiguration()));
+
+        $command = $application->find('run');
+        $commandTester = new CommandTester($command);
+        $commandTester->setInputs(['Y']);
+
+        $commandTester->execute([
+            'command' => $command->getName(),
+            '--configuration' => $configWithoutExtension,
+            'stringFilter' => 'nothing',
+        ], ['interactive' => true]);
+
+        $this->assertTrue($configChecker->isParaunitExtensionRegistered(), 'Configuration not updated correctly');
+        $this->assertSame(0, $commandTester->getStatusCode(), 'Command failed with non-zero exit code');
+        $this->assertStringContainsString('Configuration updated successfully', $commandTester->getDisplay());
+    }
+
     public function testExecutionAllGreen(): void
     {
         $configurationPath = $this->getConfigForStubs();
diff --git a/tests/Stub/phpunit_for_stubs.xml b/tests/Stub/phpunit_for_stubs.xml
index 5d058521..65573477 100644
--- a/tests/Stub/phpunit_for_stubs.xml
+++ b/tests/Stub/phpunit_for_stubs.xml
@@ -1,26 +1,22 @@
-<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
-         backupGlobals="false"
-         colors="true"
-         bootstrap="../../vendor/autoload.php"
-         beStrictAboutOutputDuringTests="true"
-        >
-    <php>
-        <ini name="error_reporting" value="-1"/>
-        <ini name="intl.default_locale" value="en"/>
-        <ini name="intl.error_level" value="0"/>
-        <ini name="memory_limit" value="-1"/>
-    </php>
-
-    <testsuites>
-        <testsuite name="stubs" >
-            <directory suffix="TestStub.php">./</directory>
-        </testsuite>
-    </testsuites>
-
-    <coverage>
-        <include>
-            <file>StubbedParaunitProcess.php</file>
-        </include>
-    </coverage>
+<?xml version="1.0"?>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd" backupGlobals="false" colors="true" bootstrap="../../vendor/autoload.php" beStrictAboutOutputDuringTests="true">
+  <php>
+    <ini name="error_reporting" value="-1"/>
+    <ini name="intl.default_locale" value="en"/>
+    <ini name="intl.error_level" value="0"/>
+    <ini name="memory_limit" value="-1"/>
+  </php>
+  <testsuites>
+    <testsuite name="stubs">
+      <directory suffix="TestStub.php">./</directory>
+    </testsuite>
+  </testsuites>
+  <coverage>
+    <include>
+      <file>StubbedParaunitProcess.php</file>
+    </include>
+  </coverage>
+  <extensions>
+    <bootstrap class="Paraunit\Configuration\ParaunitExtension"/>
+  </extensions>
 </phpunit>
diff --git a/tests/Stub/phpunit_with_deprecations.xml b/tests/Stub/phpunit_with_deprecations.xml
index 214f71a2..acf92aec 100644
--- a/tests/Stub/phpunit_with_deprecations.xml
+++ b/tests/Stub/phpunit_with_deprecations.xml
@@ -1,25 +1,22 @@
-<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
-         backupGlobals="false"
-         colors="true"
-         bootstrap="../../vendor/autoload.php"
-        >
-    <php>
-        <ini name="error_reporting" value="-1"/>
-        <ini name="intl.default_locale" value="en"/>
-        <ini name="intl.error_level" value="0"/>
-        <ini name="memory_limit" value="-1"/>
-    </php>
-
-    <testsuites>
-        <testsuite name="deprecations" >
-            <file>./RaisingDeprecationTestStub.php</file>
-        </testsuite>
-    </testsuites>
-
-    <coverage>
-        <include>
-            <file>./RaisingDeprecationTestStub.php</file>
-        </include>
-    </coverage>
+<?xml version="1.0"?>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd" backupGlobals="false" colors="true" bootstrap="../../vendor/autoload.php">
+  <php>
+    <ini name="error_reporting" value="-1"/>
+    <ini name="intl.default_locale" value="en"/>
+    <ini name="intl.error_level" value="0"/>
+    <ini name="memory_limit" value="-1"/>
+  </php>
+  <testsuites>
+    <testsuite name="deprecations">
+      <file>./RaisingDeprecationTestStub.php</file>
+    </testsuite>
+  </testsuites>
+  <coverage>
+    <include>
+      <file>./RaisingDeprecationTestStub.php</file>
+    </include>
+  </coverage>
+  <extensions>
+    <bootstrap class="Paraunit\Configuration\ParaunitExtension"/>
+  </extensions>
 </phpunit>
diff --git a/tests/Stub/phpunit_without_paraunit_installed.xml b/tests/Stub/phpunit_without_paraunit_installed.xml
new file mode 100644
index 00000000..1e7c4b71
--- /dev/null
+++ b/tests/Stub/phpunit_without_paraunit_installed.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0"?>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd" backupGlobals="false" colors="true" bootstrap="../../vendor/autoload.php" beStrictAboutOutputDuringTests="true">
+  <php>
+    <ini name="error_reporting" value="-1"/>
+    <ini name="intl.default_locale" value="en"/>
+    <ini name="intl.error_level" value="0"/>
+    <ini name="memory_limit" value="-1"/>
+  </php>
+  <testsuites>
+    <testsuite name="stubs">
+      <directory suffix="TestStub.php">./</directory>
+    </testsuite>
+  </testsuites>
+  <coverage>
+    <include>
+      <file>StubbedParaunitProcess.php</file>
+    </include>
+  </coverage>
+</phpunit>
diff --git a/tests/Unit/Configuration/PHPUnitConfigTest.php b/tests/Unit/Configuration/PHPUnitConfigTest.php
index 35298ac1..36413762 100644
--- a/tests/Unit/Configuration/PHPUnitConfigTest.php
+++ b/tests/Unit/Configuration/PHPUnitConfigTest.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Tests\Unit\Configuration;
 
 use Paraunit\Configuration\PHPUnitConfig;
+use PHPUnit\Framework\AssertionFailedError;
 use Tests\BaseUnitTestCase;
 
 class PHPUnitConfigTest extends BaseUnitTestCase
@@ -50,11 +51,10 @@ class PHPUnitConfigTest extends BaseUnitTestCase
 
     public function testGetFileFullPathWithFileDoesNotExistWillThrowException(): void
     {
-        $dir = $this->getStubPath() . 'PHPUnitJSONLogOutput';
         $this->expectException(\InvalidArgumentException::class);
         $this->expectExceptionMessage(PHPUnitConfig::FALLBACK_CONFIG_FILE_NAME . ' does not exist');
 
-        new PHPUnitConfig($dir);
+        new PHPUnitConfig(__DIR__);
     }
 
     public function testGetFileFullPathWithPathDoesNotExistWillThrowException(): void
@@ -65,4 +65,71 @@ class PHPUnitConfigTest extends BaseUnitTestCase
 
         new PHPUnitConfig($dir);
     }
+
+    /**
+     * @dataProvider configWithExtensionDataProvider
+     */
+    public function testIsParaunitExtensionRegistered(bool $expectedResult, string $configContent): void
+    {
+        $configFile = $this->createMockConfiguration($configContent);
+
+        $config = new PHPUnitConfig($configFile);
+
+        $this->assertSame($expectedResult, $config->isParaunitExtensionRegistered());
+    }
+
+    /**
+     * @dataProvider configWithExtensionDataProvider
+     */
+    public function testInstallExtension(bool $expectedResult, string $configContent): void
+    {
+        $configFile = $this->createMockConfiguration($configContent);
+        $config = new PHPUnitConfig($configFile);
+        $this->assertSame($expectedResult, $config->isParaunitExtensionRegistered());
+
+        $config->installExtension();
+
+        try {
+            $this->assertTrue($config->isParaunitExtensionRegistered());
+        } catch (\Throwable $throwable) {
+            throw new AssertionFailedError('Configuration broken: ' . PHP_EOL . file_get_contents($config->getFileFullPath()), 500, $throwable);
+        }
+    }
+
+    /**
+     * @return array<string, array{bool, string}>
+     */
+    public static function configWithExtensionDataProvider(): array
+    {
+        return [
+            'no extensions section' => [
+                false,
+                '<?xml version="1.0"?><phpunit></phpunit>',
+            ],
+            'no Paraunit extension' => [
+                false,
+                '<?xml version="1.0"?><phpunit><extensions><bootstrap class="Foo" /></extensions></phpunit>',
+            ],
+            'Paraunit extension registered' => [
+                true,
+                '<?xml version="1.0"?><phpunit><extensions><bootstrap class="\Paraunit\Configuration\ParaunitExtension" /></extensions></phpunit>',
+            ],
+            'Paraunit extension registered but not first one' => [
+                true,
+                '<?xml version="1.0"?><phpunit><extensions><bootstrap class="\Another\Extension" /><bootstrap class="\Paraunit\Configuration\ParaunitExtension" /></extensions></phpunit>',
+            ],
+            'Paraunit extension registered with no starting backslash' => [
+                true,
+                '<?xml version="1.0"?><phpunit><extensions><bootstrap class="Paraunit\Configuration\ParaunitExtension" /></extensions></phpunit>',
+            ],
+        ];
+    }
+
+    private function createMockConfiguration(string $configContent): string
+    {
+        $configFile = uniqid(sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'paraunit-test-config-');
+        $this->assertNotFalse(file_put_contents($configFile, $configContent), 'Failed preparing mocked config');
+
+        return $configFile;
+    }
 }