# Unit Testing in TYPO3 Unit tests are fast, isolated tests that verify individual components without external dependencies like databases or file systems. ## When to Use Unit Tests ✅ **Ideal for:** - Testing pure business logic - Validators, calculators, transformers - Value objects and DTOs - Utilities and helper functions - Domain models without persistence - **Controllers with dependency injection** (new in TYPO3 13) - **Services with injected dependencies** ❌ **Not suitable for:** - Database operations (use functional tests) - File system operations - Methods using `BackendUtility` or global state - Complex TYPO3 framework integration - Parent class behavior from framework classes ## Base Class All unit tests extend `TYPO3\TestingFramework\Core\Unit\UnitTestCase`: ```php subject = new EmailValidator(); } #[Test] public function validEmailPassesValidation(): void { $result = $this->subject->validate('user@example.com'); self::assertFalse($result->hasErrors()); } #[Test] public function invalidEmailFailsValidation(): void { $result = $this->subject->validate('invalid-email'); self::assertTrue($result->hasErrors()); } } ``` > **Note:** TYPO3 13+ with PHPUnit 11/12 uses PHP attributes (`#[Test]`) instead of `@test` annotations. > Use `private` instead of `protected` for properties when possible (better encapsulation). ## Key Principles ### 1. No External Dependencies Unit tests should NOT: - Access the database - Read/write files - Make HTTP requests - Use TYPO3 framework services ### 2. Fast Execution Unit tests should run in milliseconds: - No I/O operations - Minimal object instantiation - Use mocks for dependencies ### 3. Test Independence Each test should: - Be runnable standalone - Not depend on execution order - Clean up in tearDown() ## Test Structure ### Arrange-Act-Assert Pattern ```php /** * @test */ public function calculatesTotalPrice(): void { // Arrange: Set up test data $cart = new ShoppingCart(); $cart->addItem(new Item('product1', 10.00, 2)); $cart->addItem(new Item('product2', 5.50, 1)); // Act: Execute the code under test $total = $cart->calculateTotal(); // Assert: Verify the result self::assertSame(25.50, $total); } ``` ### setUp() and tearDown() ```php protected function setUp(): void { parent::setUp(); // Initialize test subject and dependencies $this->subject = new Calculator(); } protected function tearDown(): void { // Clean up resources unset($this->subject); parent::tearDown(); } ``` ## Testing with Dependency Injection (TYPO3 13+) Modern TYPO3 13 controllers and services use constructor injection. Here's how to test them: ### Basic Constructor Injection Test ```php createMock(ResourceFactory::class); $this->resourceFactoryMock = $resourceFactoryMock; $this->subject = new ImageController($this->resourceFactoryMock); } #[Test] public function getFileRetrievesFileFromFactory(): void { $fileId = 123; $fileMock = $this->createMock(\TYPO3\CMS\Core\Resource\File::class); $this->resourceFactoryMock ->expects(self::once()) ->method('getFileObject') ->with($fileId) ->willReturn($fileMock); $result = $this->subject->getFile($fileId); self::assertSame($fileMock, $result); } } ``` ### Multiple Dependencies with Intersection Types PHPUnit mocks require proper type hints using intersection types for PHPStan compliance: ```php createMock(ResourceFactory::class); /** @var ImageProcessor&MockObject $imageProcessorMock */ $imageProcessorMock = $this->createMock(ImageProcessor::class); /** @var LogManager&MockObject $logManagerMock */ $logManagerMock = $this->createMock(LogManager::class); $this->resourceFactoryMock = $resourceFactoryMock; $this->imageProcessorMock = $imageProcessorMock; $this->logManagerMock = $logManagerMock; $this->subject = new ImageController( $this->resourceFactoryMock, $this->imageProcessorMock, $this->logManagerMock, ); } #[Test] public function processImageUsesInjectedProcessor(): void { $fileMock = $this->createMock(\TYPO3\CMS\Core\Resource\File::class); $processedFileMock = $this->createMock(\TYPO3\CMS\Core\Resource\ProcessedFile::class); $this->imageProcessorMock ->expects(self::once()) ->method('process') ->with($fileMock, ['width' => 800]) ->willReturn($processedFileMock); $result = $this->subject->processImage($fileMock, ['width' => 800]); self::assertSame($processedFileMock, $result); } } ``` **Key Points:** - Use intersection types: `ResourceFactory&MockObject` for proper PHPStan type checking - Assign mocks to properly typed variables before passing to constructor - This pattern works with PHPUnit 11/12 and PHPStan Level 10 ### Handling $GLOBALS and Singleton State Some TYPO3 components still use global state. Handle this properly: ```php final class BackendControllerTest extends UnitTestCase { protected bool $resetSingletonInstances = true; #[Test] public function checksBackendUserPermissions(): void { // Mock backend user $backendUserMock = $this->createMock(BackendUserAuthentication::class); $backendUserMock->method('isAdmin')->willReturn(true); $GLOBALS['BE_USER'] = $backendUserMock; $result = $this->subject->hasAccess(); self::assertTrue($result); } #[Test] public function returnsFalseWhenNoBackendUser(): void { $GLOBALS['BE_USER'] = null; $result = $this->subject->hasAccess(); self::assertFalse($result); } } ``` **Important:** Set `protected bool $resetSingletonInstances = true;` when tests interact with TYPO3 singletons to prevent test pollution. ## Mocking Dependencies Use PHPUnit's built-in mocking (PHPUnit 11/12): ```php createMock(UserRepository::class); $this->repositoryMock = $repositoryMock; $this->subject = new UserService($this->repositoryMock); } #[Test] public function findsUserByEmail(): void { $email = 'test@example.com'; $user = new User('John'); $this->repositoryMock ->expects(self::once()) ->method('findByEmail') ->with($email) ->willReturn($user); $result = $this->subject->getUserByEmail($email); self::assertSame('John', $result->getName()); } #[Test] public function throwsExceptionWhenUserNotFound(): void { $email = 'nonexistent@example.com'; $this->repositoryMock ->method('findByEmail') ->with($email) ->willReturn(null); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('User not found'); $this->subject->getUserByEmail($email); } } ``` > **Note:** TYPO3 13+ with PHPUnit 11/12 uses `createMock()` instead of Prophecy. > Prophecy is deprecated and should not be used in new tests. ## Assertions ### Common Assertions ```php // Equality self::assertEquals($expected, $actual); self::assertSame($expected, $actual); // Strict comparison // Boolean self::assertTrue($condition); self::assertFalse($condition); // Null checks self::assertNull($value); self::assertNotNull($value); // Type checks self::assertIsString($value); self::assertIsInt($value); self::assertIsArray($value); self::assertInstanceOf(User::class, $object); // Collections self::assertCount(3, $array); self::assertEmpty($array); self::assertContains('item', $array); // Exceptions $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid input'); $subject->methodThatThrows(); ``` ### Specific Over Generic ```php // ❌ Too generic self::assertTrue($result > 0); self::assertEquals(true, $isValid); // ✅ Specific and clear self::assertGreaterThan(0, $result); self::assertTrue($isValid); ``` ## Data Providers Test multiple scenarios with data providers: ```php /** * @test * @dataProvider validEmailProvider */ public function validatesEmails(string $email, bool $expected): void { $result = $this->subject->isValid($email); self::assertSame($expected, $result); } public static function validEmailProvider(): array { return [ 'valid email' => ['user@example.com', true], 'email with subdomain' => ['user@mail.example.com', true], 'missing @' => ['userexample.com', false], 'missing domain' => ['user@', false], 'empty string' => ['', false], ]; } ``` ## Testing Private/Protected Methods **Preferred Approach**: Test through public API whenever possible: ```php // ✅ Best approach - test through public interface $result = $subject->publicMethodThatUsesPrivateMethod(); self::assertSame($expected, $result); ``` **When Reflection is Acceptable**: Sometimes protected methods contain complex logic that deserves dedicated testing (e.g., URL validation, attribute resolution). In these cases, use a helper method: ```php $args */ private function callProtectedMethod(string $methodName, array $args): mixed { $reflection = new ReflectionMethod($this->subject, $methodName); $reflection->setAccessible(true); return $reflection->invokeArgs($this->subject, $args); } #[Test] public function isExternalImageReturnsTrueForHttpsUrls(): void { $result = $this->callProtectedMethod('isExternalImage', ['https://example.com/image.jpg']); self::assertTrue($result); } #[Test] public function isExternalImageReturnsFalseForLocalPaths(): void { $result = $this->callProtectedMethod('isExternalImage', ['/fileadmin/images/test.jpg']); self::assertFalse($result); } } ``` **Important Considerations**: - Only use reflection when testing protected methods with complex logic worth testing independently - Never test private methods - refactor to protected if testing is needed - Prefer testing through public API when the logic is simple - Document why reflection testing is used for a specific method ## Configuration ### PHPUnit XML (Build/phpunit/UnitTests.xml) ```xml ../../Tests/Unit/ ``` ## Best Practices 1. **One Assert Per Test**: Focus tests on single behavior 2. **Clear Test Names**: Describe what is tested and expected result 3. **Arrange-Act-Assert**: Follow consistent structure 4. **No Logic in Tests**: Tests should be simple and readable 5. **Test Edge Cases**: Empty strings, null, zero, negative numbers 6. **Use Data Providers**: Test multiple scenarios efficiently 7. **Mock External Dependencies**: Keep tests isolated and fast ## Common Pitfalls ❌ **Testing Framework Code** ```php // Don't test TYPO3 core functionality $this->assertTrue(is_array([])); // Useless test ``` ❌ **Slow Tests** ```php // Don't access file system in unit tests file_put_contents('/tmp/test.txt', 'data'); ``` ❌ **Test Interdependence** ```php // Don't depend on test execution order /** @depends testCreate */ public function testUpdate(): void { } ``` ✅ **Focused, Fast, Isolated Tests** ```php /** * @test */ public function calculatesPriceWithDiscount(): void { $calculator = new PriceCalculator(); $price = $calculator->calculate(100.0, 0.2); self::assertSame(80.0, $price); } ``` ## Running Unit Tests ```bash # Via runTests.sh Build/Scripts/runTests.sh -s unit # Via PHPUnit directly vendor/bin/phpunit -c Build/phpunit/UnitTests.xml # Via Composer composer ci:test:php:unit # Single test file vendor/bin/phpunit Tests/Unit/Domain/Validator/EmailValidatorTest.php # Single test method vendor/bin/phpunit --filter testValidEmail ``` ## Troubleshooting Common Issues ### PHPStan Errors with Mocks **Problem**: PHPStan complains about mock type mismatches. ``` Method expects ResourceFactory but got ResourceFactory&MockObject ``` **Solution**: Use intersection type annotations: ```php /** @var ResourceFactory&MockObject */ private ResourceFactory $resourceFactoryMock; protected function setUp(): void { parent::setUp(); /** @var ResourceFactory&MockObject $resourceFactoryMock */ $resourceFactoryMock = $this->createMock(ResourceFactory::class); $this->resourceFactoryMock = $resourceFactoryMock; $this->subject = new MyController($this->resourceFactoryMock); } ``` ### Undefined Array Key Warnings **Problem**: Tests throw warnings about missing array keys. ``` Undefined array key "fileId" ``` **Solution**: Always provide all required keys in mock arrays: ```php // ❌ Incomplete mock data $requestMock->method('getQueryParams')->willReturn([ 'fileId' => 123, ]); // ✅ Complete mock data $requestMock->method('getQueryParams')->willReturn([ 'fileId' => 123, 'table' => 'tt_content', 'P' => [], ]); ``` ### Tests Requiring Functional Setup **Problem**: Unit tests fail with cache or framework errors. ``` NoSuchCacheException: A cache with identifier "runtime" does not exist. ``` **Solution**: Identify methods that require TYPO3 framework infrastructure and move them to functional tests: - Methods using `BackendUtility::getPagesTSconfig()` - Methods calling parent class framework behavior - Methods requiring global state like `$GLOBALS['TYPO3_CONF_VARS']` Add comments explaining the limitation: ```php // Note: getMaxDimensions tests require functional test setup due to BackendUtility dependency // These are better tested in functional tests ``` ### Singleton State Pollution **Problem**: Tests interfere with each other due to singleton state. **Solution**: Enable singleton reset in your test class: ```php final class MyControllerTest extends UnitTestCase { protected bool $resetSingletonInstances = true; #[Test] public function testWithGlobals(): void { $GLOBALS['BE_USER'] = $this->createMock(BackendUserAuthentication::class); // Test will clean up automatically } } ``` ### Exception Flow Issues **Problem**: Catching and re-throwing exceptions masks the original error. ```php // ❌ Inner exception caught by outer catch try { $file = $this->factory->getFile($id); if ($file->isDeleted()) { throw new RuntimeException('Deleted', 1234); } } catch (Exception $e) { throw new RuntimeException('Not found', 5678); } ``` **Solution**: Separate concerns - catch only what you need: ```php // ✅ Proper exception flow try { $file = $this->factory->getFile($id); } catch (Exception $e) { throw new RuntimeException('Not found', 5678, $e); } if ($file->isDeleted()) { throw new RuntimeException('Deleted', 1234); } ``` ## Testing DataHandler Hooks DataHandler hooks (`processDatamap_*`, `processCmdmap_*`) require careful testing as they interact with TYPO3 globals. ### Example: Testing processDatamap_postProcessFieldArray ```php createMock(ExtensionConfiguration::class); /** @var LogManager&MockObject $logManagerMock */ $logManagerMock = $this->createMock(LogManager::class); /** @var ResourceFactory&MockObject $resourceFactoryMock */ $resourceFactoryMock = $this->createMock(ResourceFactory::class); /** @var Context&MockObject $contextMock */ $contextMock = $this->createMock(Context::class); /** @var RequestFactory&MockObject $requestFactoryMock */ $requestFactoryMock = $this->createMock(RequestFactory::class); /** @var DefaultUploadFolderResolver&MockObject $uploadFolderResolverMock */ $uploadFolderResolverMock = $this->createMock(DefaultUploadFolderResolver::class); /** @var Logger&MockObject $loggerMock */ $loggerMock = $this->createMock(Logger::class); // Configure extension configuration mock with willReturnCallback $extensionConfigurationMock ->method('get') ->willReturnCallback(function ($extension, $key) { if ($extension === 'my_extension') { return match ($key) { 'enableFeature' => true, 'timeout' => 30, default => null, }; } return null; }); // Configure log manager to return logger mock $logManagerMock ->method('getLogger') ->with(MyDataHandlerHook::class) ->willReturn($loggerMock); // Assign mocks to properties $this->extensionConfigurationMock = $extensionConfigurationMock; $this->logManagerMock = $logManagerMock; $this->resourceFactoryMock = $resourceFactoryMock; $this->contextMock = $contextMock; $this->requestFactoryMock = $requestFactoryMock; $this->uploadFolderResolverMock = $uploadFolderResolverMock; $this->loggerMock = $loggerMock; // Create subject with all dependencies $this->subject = new MyDataHandlerHook( $this->extensionConfigurationMock, $this->logManagerMock, $this->resourceFactoryMock, $this->contextMock, $this->requestFactoryMock, $this->uploadFolderResolverMock, ); } #[Test] public function constructorInitializesWithDependencyInjection(): void { // Verify subject was created successfully with all dependencies self::assertInstanceOf(MyDataHandlerHook::class, $this->subject); } #[Test] public function processDatamapPostProcessFieldArrayHandlesFieldCorrectly(): void { $status = 'update'; $table = 'tt_content'; $id = '123'; $fieldArray = ['bodytext' => '

Content with processing

']; /** @var DataHandler&MockObject $dataHandlerMock */ $dataHandlerMock = $this->createMock(DataHandler::class); // Mock TCA configuration for RTE field $GLOBALS['TCA']['tt_content']['columns']['bodytext']['config'] = [ 'type' => 'text', 'enableRichtext' => true, ]; // Test the hook processes the field $this->subject->processDatamap_postProcessFieldArray( $status, $table, $id, $fieldArray, $dataHandlerMock, ); // Assert field was processed (actual assertion depends on implementation) self::assertNotEmpty($fieldArray['bodytext']); } #[Test] public function constructorLoadsExtensionConfiguration(): void { /** @var ExtensionConfiguration&MockObject $configMock */ $configMock = $this->createMock(ExtensionConfiguration::class); $configMock ->expects(self::exactly(2)) ->method('get') ->willReturnCallback(function ($extension, $key) { self::assertSame('my_extension', $extension); return match ($key) { 'enableFeature' => true, 'timeout' => 30, default => null, }; }); new MyDataHandlerHook( $configMock, $this->logManagerMock, $this->resourceFactoryMock, $this->contextMock, $this->requestFactoryMock, $this->uploadFolderResolverMock, ); } } ``` **Key Testing Patterns for DataHandler Hooks:** 1. **Intersection Types for PHPStan**: Use `ResourceFactory&MockObject` for strict type compliance 2. **TCA Globals**: Set `$GLOBALS['TCA']` in tests to simulate TYPO3 table configuration 3. **Extension Configuration**: Use `willReturnCallback` with `match` expressions for flexible config mocking 4. **DataHandler Mock**: Create mock for `$dataHandler` parameter (required in hook signature) 5. **Reset Singletons**: Always set `protected bool $resetSingletonInstances = true;` 6. **Constructor DI**: Inject all dependencies via constructor (TYPO3 13+ best practice) ## Resources - [TYPO3 Unit Testing Documentation](https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/Testing/UnitTests.html) - [PHPUnit Documentation](https://phpunit.de/documentation.html) - [PHPUnit 11 Migration Guide](https://phpunit.de/announcements/phpunit-11.html) - [TYPO3 DataHandler Hooks](https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/ApiOverview/Hooks/DataHandler/Index.html)