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

18 KiB

Functional Testing in TYPO3

Functional tests verify components that interact with external systems like databases, using a full TYPO3 instance.

When to Use Functional Tests

  • Testing database operations (repositories, queries)
  • Controller and plugin functionality
  • Hook and event implementations
  • DataHandler operations
  • File and folder operations
  • Extension configuration behavior

Base Class

All functional tests extend TYPO3\TestingFramework\Core\Functional\FunctionalTestCase:

<?php

declare(strict_types=1);

namespace Vendor\Extension\Tests\Functional\Domain\Repository;

use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
use Vendor\Extension\Domain\Model\Product;
use Vendor\Extension\Domain\Repository\ProductRepository;

final class ProductRepositoryTest extends FunctionalTestCase
{
    protected ProductRepository $subject;

    protected array $testExtensionsToLoad = [
        'typo3conf/ext/my_extension',
    ];

    protected function setUp(): void
    {
        parent::setUp();
        $this->subject = $this->get(ProductRepository::class);
    }

    /**
     * @test
     */
    public function findsProductsByCategory(): void
    {
        $this->importCSVDataSet(__DIR__ . '/../Fixtures/Products.csv');

        $products = $this->subject->findByCategory(1);

        self::assertCount(3, $products);
    }
}

Test Database

Functional tests use an isolated test database:

  • Created before test execution
  • Populated with fixtures
  • Destroyed after test completion
  • Supports: MySQL, MariaDB, PostgreSQL, SQLite

Database Configuration

Set via environment or FunctionalTests.xml:

<php>
    <env name="typo3DatabaseDriver" value="mysqli"/>
    <env name="typo3DatabaseHost" value="localhost"/>
    <env name="typo3DatabasePort" value="3306"/>
    <env name="typo3DatabaseUsername" value="root"/>
    <env name="typo3DatabasePassword" value=""/>
    <env name="typo3DatabaseName" value="typo3_test"/>
</php>

Database Fixtures

CSV Format

Create fixtures in Tests/Functional/Fixtures/:

# pages.csv
uid,pid,title,doktype
1,0,"Root",1
2,1,"Products",1
3,1,"Services",1
# tx_myext_domain_model_product.csv
uid,pid,title,price,category
1,2,"Product A",10.00,1
2,2,"Product B",20.00,1
3,2,"Product C",15.00,2

Import Fixtures

/**
 * @test
 */
public function findsProducts(): void
{
    // Import fixture
    $this->importCSVDataSet(__DIR__ . '/../Fixtures/Products.csv');

    // Test repository
    $products = $this->subject->findAll();

    self::assertCount(3, $products);
}

Multiple Fixtures

protected function setUp(): void
{
    parent::setUp();

    // Import common fixtures
    $this->importCSVDataSet(__DIR__ . '/../Fixtures/pages.csv');
    $this->importCSVDataSet(__DIR__ . '/../Fixtures/be_users.csv');

    $this->subject = $this->get(ProductRepository::class);
}

Dependency Injection

Use $this->get() to retrieve services:

protected function setUp(): void
{
    parent::setUp();

    // Get service from container
    $this->subject = $this->get(ProductRepository::class);
    $this->dataMapper = $this->get(DataMapper::class);
}

Testing Extensions

Load Test Extensions

protected array $testExtensionsToLoad = [
    'typo3conf/ext/my_extension',
    'typo3conf/ext/dependency_extension',
];

Core Extensions

protected array $coreExtensionsToLoad = [
    'form',
    'workspaces',
];

Site Configuration

Create site configuration for frontend tests:

protected function setUp(): void
{
    parent::setUp();

    $this->importCSVDataSet(__DIR__ . '/../Fixtures/pages.csv');

    $this->writeSiteConfiguration(
        'test',
        [
            'rootPageId' => 1,
            'base' => 'http://localhost/',
        ]
    );
}

Frontend Requests

Test frontend rendering:

use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest;

/**
 * @test
 */
public function rendersProductList(): void
{
    $this->importCSVDataSet(__DIR__ . '/../Fixtures/pages.csv');
    $this->importCSVDataSet(__DIR__ . '/../Fixtures/Products.csv');

    $this->writeSiteConfiguration('test', ['rootPageId' => 1]);

    $response = $this->executeFrontendSubRequest(
        new InternalRequest('http://localhost/products')
    );

    self::assertStringContainsString('Product A', (string)$response->getBody());
}

Testing DataHandler Hooks (SC_OPTIONS)

Test DataHandler SC_OPTIONS hook integration with real framework:

<?php

declare(strict_types=1);

namespace Vendor\Extension\Tests\Functional\Database;

use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
use Vendor\Extension\Database\MyDataHandlerHook;

final class MyDataHandlerHookTest extends FunctionalTestCase
{
    protected array $testExtensionsToLoad = [
        'typo3conf/ext/my_extension',
    ];

    protected array $coreExtensionsToLoad = [
        'typo3/cms-rte-ckeditor', // If testing RTE-related hooks
    ];

    protected function setUp(): void
    {
        parent::setUp();

        $this->importCSVDataSet(__DIR__ . '/Fixtures/pages.csv');
        $this->importCSVDataSet(__DIR__ . '/Fixtures/tt_content.csv');
    }

    private function createSubject(): MyDataHandlerHook
    {
        // Get services from container with proper DI
        return new MyDataHandlerHook(
            $this->get(ExtensionConfiguration::class),
            $this->get(LogManager::class),
            $this->get(ResourceFactory::class),
        );
    }

    /**
     * @test
     */
    public function processDatamapPostProcessFieldArrayHandlesRteField(): void
    {
        $subject = $this->createSubject();

        $status     = 'update';
        $table      = 'tt_content';
        $id         = '1';
        $fieldArray = [
            'bodytext' => '<p>Test content with <img src="image.jpg" /></p>',
        ];

        /** @var DataHandler $dataHandler */
        $dataHandler = $this->get(DataHandler::class);

        // Configure TCA for RTE field
        /** @var array<string, mixed> $tcaConfig */
        $tcaConfig = [
            'type'           => 'text',
            'enableRichtext' => true,
        ];
        // @phpstan-ignore-next-line offsetAccess.nonOffsetAccessible
        $GLOBALS['TCA']['tt_content']['columns']['bodytext']['config'] = $tcaConfig;

        $subject->processDatamap_postProcessFieldArray(
            $status,
            $table,
            $id,
            $fieldArray,
            $dataHandler,
        );

        // Field should be processed by hook
        self::assertArrayHasKey('bodytext', $fieldArray);
        self::assertIsString($fieldArray['bodytext']);
        self::assertNotEmpty($fieldArray['bodytext']);
        self::assertStringContainsString('Test content', $fieldArray['bodytext']);
    }

    /**
     * @test
     */
    public function hookIsRegisteredInGlobals(): void
    {
        // Verify hook is properly registered in TYPO3_CONF_VARS
        self::assertIsArray($GLOBALS['TYPO3_CONF_VARS']);
        self::assertArrayHasKey('SC_OPTIONS', $GLOBALS['TYPO3_CONF_VARS']);

        $scOptions = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'];
        self::assertIsArray($scOptions);
        self::assertArrayHasKey('t3lib/class.t3lib_tcemain.php', $scOptions);

        $tcemainOptions = $scOptions['t3lib/class.t3lib_tcemain.php'];
        self::assertIsArray($tcemainOptions);
        self::assertArrayHasKey('processDatamapClass', $tcemainOptions);

        $registeredHooks = $tcemainOptions['processDatamapClass'];
        self::assertIsArray($registeredHooks);

        // Hook class should be registered
        self::assertContains(MyDataHandlerHook::class, $registeredHooks);
    }
}

Key Patterns for DataHandler Hook Testing

  1. Use Factory Method Pattern: Create createSubject() method to avoid uninitialized property PHPStan errors
  2. Test Real Framework Integration: Don't mock DataHandler, test actual hook execution
  3. Configure TCA Dynamically: Set up $GLOBALS['TCA'] in tests for field configuration
  4. Verify Hook Registration: Test that hooks are properly registered in $GLOBALS['TYPO3_CONF_VARS']
  5. Test Multiple Scenarios: new vs update, single vs multiple fields, RTE vs non-RTE

Testing File Abstraction Layer (FAL)

Test ResourceFactory and FAL storage integration:

<?php

declare(strict_types=1);

namespace Vendor\Extension\Tests\Functional\Controller;

use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Resource\ResourceStorage;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
use Vendor\Extension\Controller\ImageRenderingController;

final class ImageRenderingControllerTest extends FunctionalTestCase
{
    protected array $testExtensionsToLoad = [
        'typo3conf/ext/my_extension',
    ];

    protected function setUp(): void
    {
        parent::setUp();

        $this->importCSVDataSet(__DIR__ . '/Fixtures/sys_file_storage.csv');
        $this->importCSVDataSet(__DIR__ . '/Fixtures/sys_file.csv');
    }

    /**
     * @test
     */
    public function storageIsAccessible(): void
    {
        /** @var ResourceFactory $resourceFactory */
        $resourceFactory = $this->get(ResourceFactory::class);
        $storage         = $resourceFactory->getStorageObject(1);

        self::assertInstanceOf(ResourceStorage::class, $storage);
        self::assertTrue($storage->isOnline());
    }

    /**
     * @test
     */
    public function canRetrieveFileFromStorage(): void
    {
        /** @var ResourceFactory $resourceFactory */
        $resourceFactory = $this->get(ResourceFactory::class);

        // Get file from test data
        $file = $resourceFactory->getFileObject(1);

        self::assertInstanceOf(File::class, $file);
        self::assertSame('test-image.jpg', $file->getName());
    }

    /**
     * @test
     */
    public function canAccessStorageRootFolder(): void
    {
        /** @var ResourceFactory $resourceFactory */
        $resourceFactory = $this->get(ResourceFactory::class);
        $storage         = $resourceFactory->getStorageObject(1);

        $rootFolder = $storage->getRootLevelFolder();

        self::assertInstanceOf(Folder::class, $rootFolder);
        self::assertSame('/', $rootFolder->getIdentifier());
    }
}

FAL Test Fixtures

sys_file_storage.csv:

uid,pid,name,driver,configuration,is_default,is_browsable,is_public,is_writable,is_online
1,0,"fileadmin","Local","<?xml version=""1.0"" encoding=""utf-8"" standalone=""yes"" ?><T3FlexForms><data><sheet index=""sDEF""><language index=""lDEF""><field index=""basePath""><value index=""vDEF"">fileadmin/</value></field><field index=""pathType""><value index=""vDEF"">relative</value></field><field index=""caseSensitive""><value index=""vDEF"">1</value></field></language></sheet></data></T3FlexForms>",1,1,1,1,1

sys_file.csv:

uid,pid,storage,identifier,name,type,mime_type,size,sha1,extension
1,0,1,"/test-image.jpg","test-image.jpg",2,"image/jpeg",12345,"da39a3ee5e6b4b0d3255bfef95601890afd80709","jpg"

Key Patterns for FAL Testing

  1. Test Storage Configuration: Verify storage is properly configured and online
  2. Test File Retrieval: Use getFileObject() to retrieve files from sys_file
  3. Test Folder Operations: Verify folder access and structure
  4. Use CSV Fixtures: Import sys_file_storage and sys_file test data
  5. Test Real Services: Use container's ResourceFactory, don't mock

PHPStan Type Safety in Functional Tests

Handling $GLOBALS['TCA'] with PHPStan Level 9

PHPStan cannot infer types for runtime-configured $GLOBALS arrays. Use ignore annotations:

// Configure TCA for RTE field
/** @var array<string, mixed> $tcaConfig */
$tcaConfig = [
    'type'           => 'text',
    'enableRichtext' => true,
];
// @phpstan-ignore-next-line offsetAccess.nonOffsetAccessible
$GLOBALS['TCA']['tt_content']['columns']['bodytext']['config'] = $tcaConfig;

Type Assertions for Dynamic Arrays

When testing field arrays that are modified by reference:

// ❌ PHPStan cannot verify this is still an array
self::assertStringContainsString('Test', $fieldArray['bodytext']);

// ✅ Add type assertions
self::assertArrayHasKey('bodytext', $fieldArray);
self::assertIsString($fieldArray['bodytext']);
self::assertStringContainsString('Test', $fieldArray['bodytext']);

Avoiding Uninitialized Property Errors

Use factory methods instead of properties initialized in setUp():

// ❌ PHPStan warns about uninitialized property
private MyService $subject;

protected function setUp(): void
{
    $this->subject = $this->get(MyService::class);
}

// ✅ Use factory method
private function createSubject(): MyService
{
    return $this->get(MyService::class);
}

public function testSomething(): void
{
    $subject = $this->createSubject();
    // Use $subject
}

PHPStan Annotations for Functional Tests

Common patterns:

// Ignore $GLOBALS access
// @phpstan-ignore-next-line offsetAccess.nonOffsetAccessible
$GLOBALS['TCA']['table']['columns']['field']['config'] = $config;

// Type hint service retrieval
/** @var DataHandler $dataHandler */
$dataHandler = $this->get(DataHandler::class);

// Type hint config arrays
/** @var array<string, mixed> $tcaConfig */
$tcaConfig = ['type' => 'text'];

Backend User Context

Test with backend user:

use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest;

/**
 * @test
 */
public function editorCanEditRecord(): void
{
    $this->importCSVDataSet(__DIR__ . '/../Fixtures/be_users.csv');
    $this->importCSVDataSet(__DIR__ . '/../Fixtures/Products.csv');

    $this->setUpBackendUser(1); // uid from be_users.csv

    $dataHandler = $this->get(DataHandler::class);
    $dataHandler->start(
        [
            'tx_myext_domain_model_product' => [
                1 => ['title' => 'Updated Product']
            ]
        ],
        []
    );
    $dataHandler->process_datamap();

    self::assertEmpty($dataHandler->errorLog);
}

File Operations

Test file handling:

/**
 * @test
 */
public function uploadsFile(): void
{
    $fileStorage = $this->get(StorageRepository::class)->getDefaultStorage();

    $file = $fileStorage->addFile(
        __DIR__ . '/../Fixtures/Files/test.jpg',
        $fileStorage->getDefaultFolder(),
        'test.jpg'
    );

    self::assertFileExists($file->getForLocalProcessing(false));
}

Configuration

PHPUnit XML (Build/phpunit/FunctionalTests.xml)

<phpunit
    bootstrap="FunctionalTestsBootstrap.php"
    cacheResult="false"
    beStrictAboutTestsThatDoNotTestAnything="true"
    failOnDeprecation="true"
    failOnNotice="true"
    failOnWarning="true">
    <testsuites>
        <testsuite name="Functional tests">
            <directory>../../Tests/Functional/</directory>
        </testsuite>
    </testsuites>
    <php>
        <const name="TYPO3_TESTING_FUNCTIONAL_REMOVE_ERROR_HANDLER" value="true" />
        <env name="TYPO3_CONTEXT" value="Testing"/>
        <env name="typo3DatabaseDriver" value="mysqli"/>
    </php>
</phpunit>

Bootstrap (Build/phpunit/FunctionalTestsBootstrap.php)

<?php

declare(strict_types=1);

call_user_func(static function () {
    $testbase = new \TYPO3\TestingFramework\Core\Testbase();
    $testbase->defineOriginalRootPath();
    $testbase->createDirectory(ORIGINAL_ROOT . 'typo3temp/var/tests');
    $testbase->createDirectory(ORIGINAL_ROOT . 'typo3temp/var/transient');
});

Fixture Strategy

Minimal Fixtures

Keep fixtures focused on test requirements:

// ❌ Too much data
$this->importCSVDataSet(__DIR__ . '/../Fixtures/AllProducts.csv'); // 500 records

// ✅ Minimal test data
$this->importCSVDataSet(__DIR__ . '/../Fixtures/ProductsByCategory.csv'); // 3 records

Reusable Fixtures

Create shared fixtures for common scenarios:

Tests/Functional/Fixtures/
├── pages.csv              # Basic page tree
├── be_users.csv           # Test backend users
├── Products/
│   ├── BasicProducts.csv  # 3 simple products
│   ├── ProductsWithCategories.csv
│   └── ProductsWithImages.csv

Fixture Documentation

Document fixture purpose in test or AGENTS.md:

/**
 * @test
 */
public function findsProductsByCategory(): void
{
    // Fixture contains: 3 products in category 1, 2 products in category 2
    $this->importCSVDataSet(__DIR__ . '/../Fixtures/ProductsByCategory.csv');

    $products = $this->subject->findByCategory(1);

    self::assertCount(3, $products);
}

Best Practices

  1. Use setUp() for Common Setup: Import shared fixtures in setUp()
  2. One Test Database: Each test gets clean database instance
  3. Test Isolation: Don't depend on other test execution
  4. Minimal Fixtures: Only data required for specific test
  5. Clear Assertions: Test specific behavior, not implementation
  6. Cleanup: Testing framework handles cleanup automatically

Common Pitfalls

Large Fixtures

// Don't import unnecessary data
$this->importCSVDataSet('AllData.csv'); // 10,000 records

No Fixtures

// Don't expect data to exist
$products = $this->subject->findAll();
self::assertCount(0, $products); // Always true without fixtures

Missing Extensions

// Don't forget to load extension under test
// Missing: protected array $testExtensionsToLoad = ['typo3conf/ext/my_extension'];

Focused, Well-Documented Tests

/**
 * @test
 */
public function findsByCategory(): void
{
    // Fixture: 3 products in category 1
    $this->importCSVDataSet(__DIR__ . '/../Fixtures/CategoryProducts.csv');

    $products = $this->subject->findByCategory(1);

    self::assertCount(3, $products);
    self::assertSame('Product A', $products[0]->getTitle());
}

Running Functional Tests

# Via runTests.sh
Build/Scripts/runTests.sh -s functional

# Via PHPUnit directly
vendor/bin/phpunit -c Build/phpunit/FunctionalTests.xml

# Via Composer
composer ci:test:php:functional

# With specific database
typo3DatabaseDriver=pdo_mysql vendor/bin/phpunit -c Build/phpunit/FunctionalTests.xml

# Single test
vendor/bin/phpunit Tests/Functional/Domain/Repository/ProductRepositoryTest.php

Resources