Files
2025-11-30 08:43:13 +08:00

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 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

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 @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

/**
 * @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&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:

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

  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

// 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:

  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