26 KiB
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
BackendUtilityor global state - Complex TYPO3 framework integration
- Parent class behavior from framework classes
Base Class
All unit tests extend TYPO3\TestingFramework\Core\Unit\UnitTestCase:
<?php
declare(strict_types=1);
namespace Vendor\Extension\Tests\Unit\Domain\Validator;
use PHPUnit\Framework\Attributes\Test;
use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
use Vendor\Extension\Domain\Validator\EmailValidator;
/**
* Unit tests for EmailValidator.
*
* @covers \Vendor\Extension\Domain\Validator\EmailValidator
*/
final class EmailValidatorTest extends UnitTestCase
{
private EmailValidator $subject;
protected function setUp(): void
{
parent::setUp();
$this->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@testannotations. Useprivateinstead ofprotectedfor 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
/**
* @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()
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
declare(strict_types=1);
namespace Vendor\Extension\Tests\Unit\Controller;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
use Vendor\Extension\Controller\ImageController;
final class ImageControllerTest extends UnitTestCase
{
private ImageController $subject;
/** @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 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
declare(strict_types=1);
namespace Vendor\Extension\Tests\Unit\Controller;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use TYPO3\CMS\Core\Log\LogManager;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
use Vendor\Extension\Controller\ImageController;
use Vendor\Extension\Utils\ImageProcessor;
final class ImageControllerTest extends UnitTestCase
{
private ImageController $subject;
/** @var ResourceFactory&MockObject */
private ResourceFactory $resourceFactoryMock;
/** @var ImageProcessor&MockObject */
private ImageProcessor $imageProcessorMock;
/** @var LogManager&MockObject */
private LogManager $logManagerMock;
protected function setUp(): void
{
parent::setUp();
/** @var ResourceFactory&MockObject $resourceFactoryMock */
$resourceFactoryMock = $this->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&MockObjectfor 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:
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
declare(strict_types=1);
namespace Vendor\Extension\Tests\Unit\Service;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
use Vendor\Extension\Domain\Model\User;
use Vendor\Extension\Domain\Repository\UserRepository;
use Vendor\Extension\Service\UserService;
final class UserServiceTest extends UnitTestCase
{
private UserService $subject;
/** @var UserRepository&MockObject */
private UserRepository $repositoryMock;
protected function setUp(): void
{
parent::setUp();
/** @var UserRepository&MockObject $repositoryMock */
$repositoryMock = $this->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
// 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
// ❌ 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:
/**
* @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:
// ✅ 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
declare(strict_types=1);
namespace Vendor\Extension\Tests\Unit\Controller;
use PHPUnit\Framework\Attributes\Test;
use ReflectionMethod;
use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
use Vendor\Extension\Controller\ImageController;
final class ImageControllerTest extends UnitTestCase
{
private ImageController $subject;
/**
* Helper method to access protected methods.
*
* @param array<int, mixed> $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)
<phpunit
bootstrap="../../vendor/autoload.php"
cacheResult="false"
beStrictAboutTestsThatDoNotTestAnything="true"
beStrictAboutOutputDuringTests="true"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true"
failOnRisky="true">
<testsuites>
<testsuite name="Unit tests">
<directory>../../Tests/Unit/</directory>
</testsuite>
</testsuites>
</phpunit>
Best Practices
- One Assert Per Test: Focus tests on single behavior
- Clear Test Names: Describe what is tested and expected result
- Arrange-Act-Assert: Follow consistent structure
- No Logic in Tests: Tests should be simple and readable
- Test Edge Cases: Empty strings, null, zero, negative numbers
- Use Data Providers: Test multiple scenarios efficiently
- Mock External Dependencies: Keep tests isolated and fast
Common Pitfalls
❌ Testing Framework Code
// Don't test TYPO3 core functionality
$this->assertTrue(is_array([])); // Useless test
❌ Slow Tests
// Don't access file system in unit tests
file_put_contents('/tmp/test.txt', 'data');
❌ Test Interdependence
// Don't depend on test execution order
/** @depends testCreate */
public function testUpdate(): void { }
✅ Focused, Fast, Isolated Tests
/**
* @test
*/
public function calculatesPriceWithDiscount(): void
{
$calculator = new PriceCalculator();
$price = $calculator->calculate(100.0, 0.2);
self::assertSame(80.0, $price);
}
Running Unit Tests
# 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:
/** @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:
// ❌ 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:
// 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:
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.
// ❌ 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:
// ✅ 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
declare(strict_types=1);
namespace Vendor\Extension\Tests\Unit\Database;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Log\LogManager;
use TYPO3\CMS\Core\Log\Logger;
use TYPO3\CMS\Core\Resource\DefaultUploadFolderResolver;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
use Vendor\Extension\Database\MyDataHandlerHook;
/**
* Unit tests for MyDataHandlerHook.
*
* @covers \Vendor\Extension\Database\MyDataHandlerHook
*/
final class MyDataHandlerHookTest extends UnitTestCase
{
protected bool $resetSingletonInstances = true;
private MyDataHandlerHook $subject;
/** @var ExtensionConfiguration&MockObject */
private ExtensionConfiguration $extensionConfigurationMock;
/** @var LogManager&MockObject */
private LogManager $logManagerMock;
/** @var ResourceFactory&MockObject */
private ResourceFactory $resourceFactoryMock;
/** @var Context&MockObject */
private Context $contextMock;
/** @var RequestFactory&MockObject */
private RequestFactory $requestFactoryMock;
/** @var DefaultUploadFolderResolver&MockObject */
private DefaultUploadFolderResolver $uploadFolderResolverMock;
/** @var Logger&MockObject */
private Logger $loggerMock;
protected function setUp(): void
{
parent::setUp();
// Create all required mocks with intersection types for PHPStan compliance
/** @var ExtensionConfiguration&MockObject $extensionConfigurationMock */
$extensionConfigurationMock = $this->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' => '<p>Content with processing</p>'];
/** @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:
- Intersection Types for PHPStan: Use
ResourceFactory&MockObjectfor strict type compliance - TCA Globals: Set
$GLOBALS['TCA']in tests to simulate TYPO3 table configuration - Extension Configuration: Use
willReturnCallbackwithmatchexpressions for flexible config mocking - DataHandler Mock: Create mock for
$dataHandlerparameter (required in hook signature) - Reset Singletons: Always set
protected bool $resetSingletonInstances = true; - Constructor DI: Inject all dependencies via constructor (TYPO3 13+ best practice)