# 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 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`: ```xml ``` ## Database Fixtures ### CSV Format Create fixtures in `Tests/Functional/Fixtures/`: ```csv # pages.csv uid,pid,title,doktype 1,0,"Root",1 2,1,"Products",1 3,1,"Services",1 ``` ```csv # 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 ```php /** * @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 ```php 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: ```php 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 ```php protected array $testExtensionsToLoad = [ 'typo3conf/ext/my_extension', 'typo3conf/ext/dependency_extension', ]; ``` ### Core Extensions ```php protected array $coreExtensionsToLoad = [ 'form', 'workspaces', ]; ``` ## Site Configuration Create site configuration for frontend tests: ```php 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: ```php 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 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' => '

Test content with

', ]; /** @var DataHandler $dataHandler */ $dataHandler = $this->get(DataHandler::class); // Configure TCA for RTE field /** @var array $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 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:** ```csv uid,pid,name,driver,configuration,is_default,is_browsable,is_public,is_writable,is_online 1,0,"fileadmin","Local","fileadmin/relative1",1,1,1,1,1 ``` **sys_file.csv:** ```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: ```php // Configure TCA for RTE field /** @var array $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: ```php // ❌ 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(): ```php // ❌ 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: ```php // 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 $tcaConfig */ $tcaConfig = ['type' => 'text']; ``` ## Backend User Context Test with backend user: ```php 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: ```php /** * @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) ```xml ../../Tests/Functional/ ``` ### Bootstrap (Build/phpunit/FunctionalTestsBootstrap.php) ```php 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: ```php // ❌ 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: ```php /** * @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** ```php // Don't import unnecessary data $this->importCSVDataSet('AllData.csv'); // 10,000 records ``` ❌ **No Fixtures** ```php // Don't expect data to exist $products = $this->subject->findAll(); self::assertCount(0, $products); // Always true without fixtures ``` ❌ **Missing Extensions** ```php // Don't forget to load extension under test // Missing: protected array $testExtensionsToLoad = ['typo3conf/ext/my_extension']; ``` ✅ **Focused, Well-Documented Tests** ```php /** * @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 ```bash # 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 - [TYPO3 Functional Testing Documentation](https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/Testing/FunctionalTests.html) - [Testing Framework](https://github.com/typo3/testing-framework) - [CSV Fixture Format](https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/Testing/FunctionalTests.html#importing-data)