Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:43:33 +08:00
commit 624998dbc3
27 changed files with 6036 additions and 0 deletions

View File

@@ -0,0 +1,468 @@
# Acceptance Testing in TYPO3
Acceptance tests verify complete user workflows through browser automation using Codeception and Selenium.
## When to Use Acceptance Tests
- Testing complete user journeys (login → browse → checkout)
- Frontend functionality validation
- Cross-browser compatibility
- JavaScript-heavy interactions
- Visual regression testing
## Requirements
- Docker and Docker Compose
- Codeception
- Selenium (ChromeDriver or Firefox)
- Web server (Nginx/Apache)
## Setup
### 1. Install Codeception
```bash
composer require --dev codeception/codeception codeception/module-webdriver codeception/module-asserts
```
### 2. Initialize Codeception
```bash
vendor/bin/codecept bootstrap
```
### 3. Docker Compose
Create `Build/docker-compose.yml`:
```yaml
version: '3.8'
services:
web:
image: php:8.2-apache
volumes:
- ../../:/var/www/html
ports:
- "8000:80"
environment:
- TYPO3_CONTEXT=Testing
depends_on:
- db
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: typo3_test
ports:
- "3306:3306"
selenium:
image: selenium/standalone-chrome:latest
ports:
- "4444:4444"
shm_size: 2gb
```
### 4. Codeception Configuration
Create `codeception.yml`:
```yaml
paths:
tests: Tests/Acceptance
output: var/log/acceptance
data: Tests/Acceptance/_data
support: Tests/Acceptance/_support
envs: Tests/Acceptance/_envs
actor_suffix: Tester
extensions:
enabled:
- Codeception\Extension\RunFailed
params:
- .env.testing
suites:
acceptance:
actor: AcceptanceTester
modules:
enabled:
- WebDriver:
url: http://web:8000
browser: chrome
host: selenium
port: 4444
- \\Helper\\Acceptance
```
## Test Structure
### Basic Test (Cest)
```php
<?php
declare(strict_types=1);
namespace Vendor\Extension\Tests\Acceptance;
use Vendor\Extension\Tests\Acceptance\AcceptanceTester;
final class LoginCest
{
public function _before(AcceptanceTester $I): void
{
// Runs before each test
$I->amOnPage('/');
}
public function loginAsBackendUser(AcceptanceTester $I): void
{
$I->amOnPage('/typo3');
$I->fillField('username', 'admin');
$I->fillField('password', 'password');
$I->click('Login');
$I->see('Dashboard');
$I->seeInCurrentUrl('/typo3/module/dashboard');
}
public function loginFailsWithWrongPassword(AcceptanceTester $I): void
{
$I->amOnPage('/typo3');
$I->fillField('username', 'admin');
$I->fillField('password', 'wrong_password');
$I->click('Login');
$I->see('Login error');
$I->seeInCurrentUrl('/typo3');
}
}
```
### Page Objects Pattern
Create reusable page objects:
```php
<?php
declare(strict_types=1);
namespace Vendor\Extension\Tests\Acceptance\PageObject;
use Vendor\Extension\Tests\Acceptance\AcceptanceTester;
final class LoginPage
{
public static string $URL = '/typo3';
public static string $usernameField = '#username';
public static string $passwordField = '#password';
public static string $loginButton = 'button[type="submit"]';
private AcceptanceTester $tester;
public function __construct(AcceptanceTester $I)
{
$this->tester = $I;
}
public function login(string $username, string $password): void
{
$I = $this->tester;
$I->amOnPage(self::$URL);
$I->fillField(self::$usernameField, $username);
$I->fillField(self::$passwordField, $password);
$I->click(self::$loginButton);
}
}
```
Use page object in test:
```php
public function loginWithPageObject(AcceptanceTester $I): void
{
$loginPage = new LoginPage($I);
$loginPage->login('admin', 'password');
$I->see('Dashboard');
}
```
## Common Actions
### Navigation
```php
// Navigate to URL
$I->amOnPage('/products');
$I->amOnUrl('https://example.com/page');
// Click links
$I->click('Products');
$I->click('#menu-products');
$I->click(['link' => 'View Details']);
```
### Form Interaction
```php
// Fill fields
$I->fillField('email', 'user@example.com');
$I->fillField('#password', 'secret');
// Select options
$I->selectOption('country', 'Germany');
$I->selectOption('category', ['Books', 'Electronics']);
// Checkboxes and radio buttons
$I->checkOption('terms');
$I->uncheckOption('newsletter');
// Submit forms
$I->submitForm('#contact-form', [
'name' => 'John Doe',
'email' => 'john@example.com',
]);
```
### Assertions
```php
// See text
$I->see('Welcome');
$I->see('Product added', '.success-message');
$I->dontSee('Error');
// See elements
$I->seeElement('.product-list');
$I->seeElement('#add-to-cart');
$I->dontSeeElement('.error-message');
// URL checks
$I->seeInCurrentUrl('/checkout');
$I->seeCurrentUrlEquals('/thank-you');
// Field values
$I->seeInField('email', 'user@example.com');
// Number of elements
$I->seeNumberOfElements('.product-item', 10);
```
### JavaScript
```php
// Execute JavaScript
$I->executeJS('window.scrollTo(0, document.body.scrollHeight);');
// Wait for JavaScript
$I->waitForJS('return document.readyState === "complete"', 5);
// Wait for element
$I->waitForElement('.product-list', 10);
$I->waitForElementVisible('#modal', 5);
// AJAX requests
$I->waitForAjaxLoad();
```
### Screenshots
```php
// Take screenshot
$I->makeScreenshot('product_page');
// Screenshot on failure (automatic in codeception.yml)
$I->makeScreenshot('FAILED_' . $test->getName());
```
## Data Management
### Using Fixtures
```php
public function _before(AcceptanceTester $I): void
{
// Reset database
$I->resetDatabase();
// Import fixtures
$I->importFixture('products.sql');
}
```
### Test Data
Create data providers:
```php
protected function productData(): array
{
return [
['name' => 'Product A', 'price' => 10.00],
['name' => 'Product B', 'price' => 20.00],
];
}
/**
* @dataProvider productData
*/
public function createsProduct(AcceptanceTester $I, \Codeception\Example $example): void
{
$I->amOnPage('/admin/products/new');
$I->fillField('name', $example['name']);
$I->fillField('price', $example['price']);
$I->click('Save');
$I->see($example['name']);
}
```
## Browser Configuration
### Multiple Browsers
```yaml
# codeception.yml
suites:
acceptance:
modules:
config:
WebDriver:
browser: '%BROWSER%'
# Run with different browsers
BROWSER=chrome vendor/bin/codecept run acceptance
BROWSER=firefox vendor/bin/codecept run acceptance
```
### Headless Mode
```yaml
WebDriver:
capabilities:
chromeOptions:
args: ['--headless', '--no-sandbox', '--disable-gpu']
```
## Running Tests
### Basic Execution
```bash
# All acceptance tests
vendor/bin/codecept run acceptance
# Specific test
vendor/bin/codecept run acceptance LoginCest
# Specific method
vendor/bin/codecept run acceptance LoginCest:loginAsBackendUser
# With HTML report
vendor/bin/codecept run acceptance --html
```
### Via runTests.sh
```bash
Build/Scripts/runTests.sh -s acceptance
```
### With Docker Compose
```bash
# Start services
docker-compose -f Build/docker-compose.yml up -d
# Run tests
vendor/bin/codecept run acceptance
# Stop services
docker-compose -f Build/docker-compose.yml down
```
## Best Practices
1. **Use Page Objects**: Reusable page representations
2. **Wait Strategically**: Use `waitFor*` methods for dynamic content
3. **Independent Tests**: Each test can run standalone
4. **Descriptive Names**: Clear test method names
5. **Screenshot on Failure**: Automatic debugging aid
6. **Minimal Setup**: Only necessary fixtures and data
7. **Stable Selectors**: Use IDs or data attributes, not fragile CSS
## Common Pitfalls
**No Waits for Dynamic Content**
```php
$I->click('Load More');
$I->see('Product 11'); // May fail if AJAX is slow
```
**Proper Waits**
```php
$I->click('Load More');
$I->waitForElement('.product-item:nth-child(11)', 5);
$I->see('Product 11');
```
**Brittle Selectors**
```php
$I->click('div.container > div:nth-child(3) > button'); // Fragile
```
**Stable Selectors**
```php
$I->click('[data-test="add-to-cart"]'); // Stable
$I->click('#product-add-button'); // Better
```
**Large Test Scenarios**
```php
// Don't test entire user journey in one test
public function completeUserJourney() { /* 50 steps */ }
```
**Focused Tests**
```php
public function addsProductToCart() { /* 5 steps */ }
public function proceedsToCheckout() { /* 7 steps */ }
```
## Debugging
### Interactive Mode
```bash
vendor/bin/codecept run acceptance --debug
vendor/bin/codecept run acceptance --steps
```
### Pause Execution
```php
$I->pauseExecution(); // Opens interactive shell
```
### HTML Reports
```bash
vendor/bin/codecept run acceptance --html
# View report at Tests/Acceptance/_output/report.html
```
## Resources
- [Codeception Documentation](https://codeception.com/docs/)
- [WebDriver Module](https://codeception.com/docs/modules/WebDriver)
- [Page Objects](https://codeception.com/docs/06-ReusingTestCode#pageobjects)
- [TYPO3 Acceptance Testing](https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/Testing/AcceptanceTests.html)

523
references/ci-cd.md Normal file
View File

@@ -0,0 +1,523 @@
# CI/CD Integration for TYPO3 Testing
Continuous Integration and Continuous Deployment workflows for automated TYPO3 extension testing.
## GitHub Actions
### Basic Workflow
Create `.github/workflows/tests.yml`:
```yaml
name: Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
lint:
name: Lint PHP
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Install dependencies
run: composer install --no-progress
- name: Run linting
run: composer ci:test:php:lint
phpstan:
name: PHPStan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Install dependencies
run: composer install --no-progress
- name: Run PHPStan
run: composer ci:test:php:phpstan
unit:
name: Unit Tests
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
steps:
- uses: actions/checkout@v4
- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: xdebug
- name: Install dependencies
run: composer install --no-progress
- name: Run unit tests
run: composer ci:test:php:unit
- name: Upload coverage
# Upload coverage for all PHP versions
uses: codecov/codecov-action@v3
functional:
name: Functional Tests
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
database: ['mysqli', 'pdo_mysql', 'postgres', 'sqlite']
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: typo3_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: typo3_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: ${{ matrix.database == 'postgres' && 'pdo_pgsql' || 'mysqli' }}
- name: Install dependencies
run: composer install --no-progress
- name: Run functional tests
run: |
export typo3DatabaseDriver=${{ matrix.database }}
export typo3DatabaseHost=127.0.0.1
export typo3DatabaseName=typo3_test
export typo3DatabaseUsername=${{ matrix.database == 'postgres' && 'postgres' || 'root' }}
export typo3DatabasePassword=${{ matrix.database == 'postgres' && 'postgres' || 'root' }}
composer ci:test:php:functional
```
### Matrix Strategy
Test multiple PHP and TYPO3 versions:
```yaml
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
typo3: ['12.4', '13.0']
exclude:
- php: '8.1'
typo3: '13.0' # TYPO3 v13 requires PHP 8.2+
steps:
- name: Install TYPO3 v${{ matrix.typo3 }}
run: |
composer require "typo3/cms-core:^${{ matrix.typo3 }}" --no-update
composer update --no-progress
```
### Caching Dependencies
```yaml
- name: Cache Composer dependencies
uses: actions/cache@v3
with:
path: ~/.composer/cache
key: composer-${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('composer.lock') }}
restore-keys: |
composer-${{ runner.os }}-${{ matrix.php }}-
composer-${{ runner.os }}-
- name: Install dependencies
run: composer install --no-progress --prefer-dist
```
### Code Coverage
```yaml
- name: Run tests with coverage
run: vendor/bin/phpunit -c Build/phpunit/UnitTests.xml --coverage-clover coverage.xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
flags: unittests
name: codecov-umbrella
```
## GitLab CI
### Basic Pipeline
Create `.gitlab-ci.yml`:
```yaml
variables:
COMPOSER_CACHE_DIR: ".composer-cache"
MYSQL_ROOT_PASSWORD: "root"
MYSQL_DATABASE: "typo3_test"
cache:
key: "$CI_COMMIT_REF_SLUG"
paths:
- .composer-cache/
stages:
- lint
- analyze
- test
.php:
image: php:${PHP_VERSION}-cli
before_script:
- apt-get update && apt-get install -y git zip unzip
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
- composer install --no-progress
lint:
extends: .php
stage: lint
variables:
PHP_VERSION: "8.2"
script:
- composer ci:test:php:lint
phpstan:
extends: .php
stage: analyze
variables:
PHP_VERSION: "8.2"
script:
- composer ci:test:php:phpstan
cgl:
extends: .php
stage: analyze
variables:
PHP_VERSION: "8.2"
script:
- composer ci:test:php:cgl
unit:8.1:
extends: .php
stage: test
variables:
PHP_VERSION: "8.1"
script:
- composer ci:test:php:unit
unit:8.2:
extends: .php
stage: test
variables:
PHP_VERSION: "8.2"
script:
- composer ci:test:php:unit
coverage: '/^\s*Lines:\s*\d+.\d+\%/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
functional:8.2:
extends: .php
stage: test
variables:
PHP_VERSION: "8.2"
typo3DatabaseDriver: "mysqli"
typo3DatabaseHost: "mysql"
typo3DatabaseName: "typo3_test"
typo3DatabaseUsername: "root"
typo3DatabasePassword: "root"
services:
- mysql:8.0
script:
- composer ci:test:php:functional
```
### Multi-Database Testing
```yaml
.functional:
extends: .php
stage: test
variables:
PHP_VERSION: "8.2"
script:
- composer ci:test:php:functional
functional:mysql:
extends: .functional
variables:
typo3DatabaseDriver: "mysqli"
typo3DatabaseHost: "mysql"
typo3DatabaseName: "typo3_test"
typo3DatabaseUsername: "root"
typo3DatabasePassword: "root"
services:
- mysql:8.0
functional:postgres:
extends: .functional
variables:
typo3DatabaseDriver: "pdo_pgsql"
typo3DatabaseHost: "postgres"
typo3DatabaseName: "typo3_test"
typo3DatabaseUsername: "postgres"
typo3DatabasePassword: "postgres"
services:
- postgres:15
before_script:
- apt-get update && apt-get install -y libpq-dev
- docker-php-ext-install pdo_pgsql
functional:sqlite:
extends: .functional
variables:
typo3DatabaseDriver: "pdo_sqlite"
```
## Best Practices
### 1. Fast Feedback Loop
Order jobs by execution time (fastest first):
```yaml
stages:
- lint # ~30 seconds
- analyze # ~1-2 minutes (PHPStan, CGL)
- unit # ~2-5 minutes
- functional # ~5-15 minutes
- acceptance # ~15-30 minutes
```
### 2. Fail Fast
```yaml
strategy:
fail-fast: true # Stop on first failure
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
```
### 3. Parallel Execution
```yaml
# GitHub Actions - parallel jobs
jobs:
lint: ...
phpstan: ...
unit: ...
# All run in parallel
# GitLab CI - parallel jobs
test:
parallel:
matrix:
- PHP_VERSION: ['8.1', '8.2', '8.3']
```
### 4. Cache Dependencies
GitHub Actions:
```yaml
- uses: actions/cache@v3
with:
path: ~/.composer/cache
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
```
GitLab CI:
```yaml
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .composer-cache/
```
### 5. Matrix Testing
Test critical combinations:
```yaml
strategy:
matrix:
include:
# Minimum supported versions
- php: '8.1'
typo3: '12.4'
# Current stable
- php: '8.2'
typo3: '12.4'
# Latest versions
- php: '8.3'
typo3: '13.0'
```
### 6. Artifacts and Reports
```yaml
- name: Archive test results
if: failure()
uses: actions/upload-artifact@v3
with:
name: test-results
path: |
var/log/
typo3temp/var/tests/
```
### 7. Notifications
GitHub Actions:
```yaml
- name: Slack Notification
if: failure()
uses: rtCamp/action-slack-notify@v2
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
```
## Quality Gates
### Required Checks
Define which checks must pass:
GitHub:
```yaml
# .github/branch-protection.json
{
"required_status_checks": {
"strict": true,
"contexts": [
"lint",
"phpstan",
"unit (8.2)",
"functional (8.2, mysqli)"
]
}
}
```
GitLab:
```yaml
# .gitlab-ci.yml
unit:8.2:
only:
- merge_requests
allow_failure: false # Required check
```
### Coverage Requirements
```yaml
- name: Check code coverage
run: |
coverage=$(vendor/bin/phpunit --coverage-text | grep "Lines:" | awk '{print $2}' | sed 's/%//')
if (( $(echo "$coverage < 80" | bc -l) )); then
echo "Coverage $coverage% is below 80%"
exit 1
fi
```
## Environment-Specific Configuration
### Development Branch
```yaml
on:
push:
branches: [ develop ]
# Run all checks, allow failures
jobs:
experimental:
continue-on-error: true
strategy:
matrix:
php: ['8.4'] # Experimental PHP version
```
### Production Branch
```yaml
on:
push:
branches: [ main ]
# Strict checks only
jobs:
tests:
strategy:
fail-fast: true
matrix:
php: ['8.2'] # LTS version only
```
### Pull Requests
```yaml
on:
pull_request:
# Full test matrix
jobs:
tests:
strategy:
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
database: ['mysqli', 'postgres']
```
## Resources
- [GitHub Actions Documentation](https://docs.github.com/actions)
- [GitLab CI Documentation](https://docs.gitlab.com/ee/ci/)
- [TYPO3 Tea Extension CI](https://github.com/TYPO3BestPractices/tea/tree/main/.github/workflows)
- [shivammathur/setup-php](https://github.com/shivammathur/setup-php)

View File

@@ -0,0 +1,720 @@
# 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
<?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`:
```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/`:
```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
<?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
<?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:**
```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:**
```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<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:
```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<string, mixed> $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
<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
<?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:
```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)

View File

@@ -0,0 +1,499 @@
# JavaScript and CKEditor Testing
**Purpose:** Testing patterns for TYPO3 CKEditor plugins, JavaScript functionality, and frontend code
## Overview
While TYPO3 extensions are primarily PHP, many include JavaScript for:
- CKEditor custom plugins and features
- Backend module interactions
- Frontend enhancements
- RTE (Rich Text Editor) extensions
This guide covers testing patterns for JavaScript code in TYPO3 extensions.
## CKEditor Plugin Testing
### Testing Model Attributes
CKEditor plugins define model attributes that must be properly handled through upcast (view→model) and downcast (model→view) conversions.
**Example from t3x-rte_ckeditor_image:**
The plugin added a `noScale` attribute to prevent image processing. This requires testing:
1. **Attribute schema registration**
2. **Upcast conversion** (HTML → CKEditor model)
3. **Downcast conversion** (CKEditor model → HTML)
4. **UI interaction** (dialog checkbox)
### Test Structure Pattern
```javascript
// Resources/Public/JavaScript/Plugins/__tests__/typo3image.test.js
import { typo3image } from '../typo3image';
describe('TYPO3 Image Plugin', () => {
let editor;
beforeEach(async () => {
editor = await createTestEditor();
});
afterEach(() => {
return editor.destroy();
});
describe('Model Schema', () => {
it('should allow noScale attribute', () => {
const schema = editor.model.schema;
expect(schema.checkAttribute('typo3image', 'noScale')).toBe(true);
});
});
describe('Upcast Conversion', () => {
it('should read data-noscale from HTML', () => {
const html = '<img src="test.jpg" data-noscale="true" />';
editor.setData(html);
const imageElement = editor.model.document.getRoot()
.getChild(0);
expect(imageElement.getAttribute('noScale')).toBe(true);
});
it('should handle missing data-noscale attribute', () => {
const html = '<img src="test.jpg" />';
editor.setData(html);
const imageElement = editor.model.document.getRoot()
.getChild(0);
expect(imageElement.getAttribute('noScale')).toBe(false);
});
});
describe('Downcast Conversion', () => {
it('should write data-noscale to HTML when enabled', () => {
editor.model.change(writer => {
const imageElement = writer.createElement('typo3image', {
src: 'test.jpg',
noScale: true
});
writer.insert(imageElement, editor.model.document.getRoot(), 0);
});
const html = editor.getData();
expect(html).toContain('data-noscale="true"');
});
it('should omit data-noscale when disabled', () => {
editor.model.change(writer => {
const imageElement = writer.createElement('typo3image', {
src: 'test.jpg',
noScale: false
});
writer.insert(imageElement, editor.model.document.getRoot(), 0);
});
const html = editor.getData();
expect(html).not.toContain('data-noscale');
});
});
});
```
### Testing data-* Attributes
Many TYPO3 CKEditor plugins use `data-*` attributes to pass information from editor to server-side rendering.
**Common Patterns:**
```javascript
describe('data-* Attribute Handling', () => {
it('should preserve TYPO3-specific attributes', () => {
const testCases = [
{ attr: 'data-htmlarea-file-uid', value: '123' },
{ attr: 'data-htmlarea-file-table', value: 'sys_file' },
{ attr: 'data-htmlarea-zoom', value: 'true' },
{ attr: 'data-noscale', value: 'true' },
{ attr: 'data-alt-override', value: 'false' },
{ attr: 'data-title-override', value: 'true' }
];
testCases.forEach(({ attr, value }) => {
const html = `<img src="test.jpg" ${attr}="${value}" />`;
editor.setData(html);
// Verify upcast preserves attribute
const output = editor.getData();
expect(output).toContain(`${attr}="${value}"`);
});
});
it('should handle boolean data attributes', () => {
// Test true value
editor.setData('<img src="test.jpg" data-noscale="true" />');
let imageElement = editor.model.document.getRoot().getChild(0);
expect(imageElement.getAttribute('noScale')).toBe(true);
// Test false value
editor.setData('<img src="test.jpg" data-noscale="false" />');
imageElement = editor.model.document.getRoot().getChild(0);
expect(imageElement.getAttribute('noScale')).toBe(false);
// Test missing attribute
editor.setData('<img src="test.jpg" />');
imageElement = editor.model.document.getRoot().getChild(0);
expect(imageElement.getAttribute('noScale')).toBe(false);
});
});
```
### Testing Dialog UI
CKEditor dialogs require testing user interactions:
```javascript
describe('Image Dialog', () => {
let dialog, $checkbox;
beforeEach(() => {
dialog = createImageDialog(editor);
$checkbox = dialog.$el.find('#checkbox-noscale');
});
it('should display noScale checkbox', () => {
expect($checkbox.length).toBe(1);
expect($checkbox.parent('label').text())
.toContain('Use original file (noScale)');
});
it('should set noScale attribute when checkbox checked', () => {
$checkbox.prop('checked', true);
dialog.save();
const imageElement = getSelectedImage(editor);
expect(imageElement.getAttribute('noScale')).toBe(true);
});
it('should remove noScale attribute when checkbox unchecked', () => {
// Start with noScale enabled
const imageElement = getSelectedImage(editor);
editor.model.change(writer => {
writer.setAttribute('noScale', true, imageElement);
});
// Uncheck and save
$checkbox.prop('checked', false);
dialog.save();
expect(imageElement.getAttribute('noScale')).toBe(false);
});
it('should load checkbox state from existing attribute', () => {
const imageElement = getSelectedImage(editor);
editor.model.change(writer => {
writer.setAttribute('noScale', true, imageElement);
});
dialog = createImageDialog(editor);
$checkbox = dialog.$el.find('#checkbox-noscale');
expect($checkbox.prop('checked')).toBe(true);
});
});
```
## JavaScript Test Frameworks
### Jest (Recommended)
**Installation:**
```bash
npm install --save-dev jest @babel/preset-env
```
**Configuration (jest.config.js):**
```javascript
module.exports = {
testEnvironment: 'jsdom',
transform: {
'^.+\\.js$': 'babel-jest'
},
moduleNameMapper: {
'\\.(css|less|scss)$': 'identity-obj-proxy'
},
collectCoverageFrom: [
'Resources/Public/JavaScript/**/*.js',
'!Resources/Public/JavaScript/**/*.test.js',
'!Resources/Public/JavaScript/**/__tests__/**'
],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70
}
}
};
```
### Mocha + Chai
Alternative for projects already using Mocha:
```javascript
// test/javascript/typo3image.test.js
const { expect } = require('chai');
const { JSDOM } = require('jsdom');
describe('TYPO3 Image Plugin', function() {
let editor;
beforeEach(async function() {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
global.window = dom.window;
global.document = window.document;
editor = await createTestEditor();
});
it('should handle noScale attribute', function() {
// Test implementation
});
});
```
## Testing Best Practices
### 1. Isolate Editor Instance
Each test should use a fresh editor instance:
```javascript
async function createTestEditor() {
const div = document.createElement('div');
document.body.appendChild(div);
const editor = await ClassicEditor.create(div, {
plugins: [Typo3Image, /* other plugins */],
typo3image: {
/* plugin config */
}
});
return editor;
}
```
### 2. Clean Up After Tests
Prevent memory leaks and DOM pollution:
```javascript
afterEach(async () => {
if (editor) {
await editor.destroy();
editor = null;
}
// Clean up any test DOM elements
document.body.innerHTML = '';
});
```
### 3. Test Both Happy Path and Edge Cases
```javascript
describe('Attribute Validation', () => {
it('should handle valid boolean values', () => {
// Happy path
});
it('should handle invalid attribute values', () => {
const html = '<img src="test.jpg" data-noscale="invalid" />';
editor.setData(html);
// Should default to false
const imageElement = editor.model.document.getRoot().getChild(0);
expect(imageElement.getAttribute('noScale')).toBe(false);
});
it('should handle malformed HTML', () => {
const html = '<img src="test.jpg" data-noscale>'; // Missing value
// Test graceful handling
});
});
```
### 4. Mock Backend Interactions
For plugins that communicate with TYPO3 backend:
```javascript
beforeEach(() => {
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ success: true })
})
);
});
afterEach(() => {
global.fetch.mockRestore();
});
it('should fetch image metadata from backend', async () => {
await plugin.fetchImageMetadata(123);
expect(fetch).toHaveBeenCalledWith(
'/typo3/ajax/image/metadata/123',
expect.any(Object)
);
});
```
## Integration with PHP Tests
JavaScript tests complement PHP unit tests:
**PHP Side (Backend):**
```php
// Tests/Unit/Controller/ImageRenderingControllerTest.php
public function testNoScaleAttribute(): void
{
$attributes = ['data-noscale' => 'true'];
$result = $this->controller->render($attributes);
// Verify noScale parameter passed to imgResource
$this->assertStringContainsString('noScale=1', $result);
}
```
**JavaScript Side (Frontend):**
```javascript
// Resources/Public/JavaScript/__tests__/typo3image.test.js
it('should generate data-noscale attribute', () => {
// Verify attribute is created in editor
editor.model.change(writer => {
const img = writer.createElement('typo3image', {
noScale: true
});
writer.insert(img, editor.model.document.getRoot(), 0);
});
expect(editor.getData()).toContain('data-noscale="true"');
});
```
**Together:** These tests ensure end-to-end functionality from editor UI → HTML attribute → PHP backend processing.
## CI/CD Integration
Add JavaScript tests to your CI pipeline:
**package.json:**
```json
{
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage",
"test:watch": "jest --watch"
},
"devDependencies": {
"jest": "^29.0.0",
"@babel/preset-env": "^7.20.0"
}
}
```
**GitHub Actions:**
```yaml
- name: Run JavaScript tests
run: |
npm install
npm run test:coverage
- name: Upload JS coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
flags: javascript
```
## Example: Complete Test Suite
See `t3x-rte_ckeditor_image` for a real-world example:
```
t3x-rte_ckeditor_image/
├── Resources/Public/JavaScript/
│ └── Plugins/
│ ├── typo3image.js # Main plugin
│ └── __tests__/
│ └── typo3image.test.js # JavaScript tests
└── Tests/Unit/
└── Controller/
└── ImageRenderingControllerTest.php # PHP tests
```
**Key Lessons:**
1. Test attribute schema registration
2. Test upcast/downcast conversions separately
3. Test UI interactions (checkboxes, inputs)
4. Test data-* attribute preservation
5. Clean up editor instances to prevent leaks
6. Mock backend API calls
7. Coordinate with PHP tests for full coverage
## Troubleshooting
### Tests Pass Locally but Fail in CI
**Cause:** DOM environment differences
**Solution:**
```javascript
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
testEnvironmentOptions: {
url: 'http://localhost'
}
};
```
### Memory Leaks in Test Suite
**Cause:** Editor instances not properly destroyed
**Solution:**
```javascript
afterEach(async () => {
if (editor && !editor.state === 'destroyed') {
await editor.destroy();
}
editor = null;
});
```
### Async Test Failures
**Cause:** Not waiting for editor initialization
**Solution:**
```javascript
beforeEach(async () => {
editor = await ClassicEditor.create(/* ... */);
// ☝️ await is critical
});
```
## References
- [CKEditor 5 Testing](https://ckeditor.com/docs/ckeditor5/latest/framework/guides/contributing/testing-environment.html)
- [Jest Documentation](https://jestjs.io/docs/getting-started)
- [TYPO3 RTE CKEditor Image](https://github.com/netresearch/t3x-rte_ckeditor_image)

395
references/quality-tools.md Normal file
View File

@@ -0,0 +1,395 @@
# Quality Tools for TYPO3 Development
Automated code quality and static analysis tools for TYPO3 extensions.
## Overview
- **PHPStan**: Static analysis for type safety and bugs
- **Rector**: Automated code refactoring and modernization
- **php-cs-fixer**: Code style enforcement (PSR-12, TYPO3 CGL)
- **phplint**: PHP syntax validation
## PHPStan
### Installation
```bash
composer require --dev phpstan/phpstan phpstan/phpstan-strict-rules saschaegerer/phpstan-typo3
```
### Configuration
Create `Build/phpstan.neon`:
```neon
includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon
- vendor/saschaegerer/phpstan-typo3/extension.neon
parameters:
level: max # Level 10 - maximum strictness
paths:
- Classes
- Tests
excludePaths:
- Tests/Acceptance/_output/*
reportUnmatchedIgnoredErrors: true
checkGenericClassInNonGenericObjectType: false
checkMissingIterableValueType: false
```
### Running PHPStan
```bash
# Via runTests.sh
Build/Scripts/runTests.sh -s phpstan
# Directly
vendor/bin/phpstan analyze --configuration Build/phpstan.neon
# With baseline (ignore existing errors)
vendor/bin/phpstan analyze --generate-baseline Build/phpstan-baseline.neon
# Clear cache
vendor/bin/phpstan clear-result-cache
```
### PHPStan Rule Levels
**Level 0-10** (use `max` for level 10): Increasing strictness
- **Level 0**: Basic checks (undefined variables, unknown functions)
- **Level 5**: Type checks, unknown properties, unknown methods
- **Level 9**: Strict mixed types, unused parameters
- **Level 10 (max)**: Maximum strictness - explicit mixed types, pure functions
**Recommendation**: Start with level 5, aim for level 10 (max) in modern TYPO3 13 projects.
**Why Level 10?**
- Enforces explicit type declarations (`mixed` must be declared, not implicit)
- Catches more potential bugs at development time
- Aligns with TYPO3 13 strict typing standards (`declare(strict_types=1)`)
- Required for PHPStan Level 10 compliant extensions
### Ignoring Errors
```php
/** @phpstan-ignore-next-line */
$value = $this->legacyMethod();
// Or in neon file
parameters:
ignoreErrors:
- '#Call to an undefined method.*::getRepository\(\)#'
```
### TYPO3-Specific Rules
```php
// PHPStan understands TYPO3 classes
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('pages');
// ✅ PHPStan knows this returns QueryBuilder
// Detects TYPO3 API misuse
TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(MyService::class);
// ✅ Checks if MyService is a valid class
```
## Rector
### Installation
```bash
composer require --dev rector/rector ssch/typo3-rector
```
### Configuration
Create `rector.php`:
```php
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;
use Rector\Set\ValueObject\SetList;
use Ssch\TYPO3Rector\Set\Typo3SetList;
return RectorConfig::configure()
->withPaths([
__DIR__ . '/Classes',
__DIR__ . '/Tests',
])
->withSkip([
__DIR__ . '/Tests/Acceptance/_output',
])
->withPhpSets(php82: true)
->withSets([
LevelSetList::UP_TO_PHP_82,
SetList::CODE_QUALITY,
SetList::DEAD_CODE,
SetList::TYPE_DECLARATION,
Typo3SetList::TYPO3_13,
]);
```
### Running Rector
```bash
# Dry run (show changes)
vendor/bin/rector process --dry-run
# Apply changes
vendor/bin/rector process
# Via runTests.sh
Build/Scripts/runTests.sh -s rector
```
### Common Refactorings
**TYPO3 API Modernization**:
```php
// Before
$GLOBALS['TYPO3_DB']->exec_SELECTgetRows('*', 'pages', 'uid=1');
// After (Rector auto-refactors)
GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('pages')
->select(['*'], 'pages', ['uid' => 1])
->fetchAllAssociative();
```
**Type Declarations**:
```php
// Before
public function process($data)
{
return $data;
}
// After
public function process(array $data): array
{
return $data;
}
```
## php-cs-fixer
### Installation
```bash
composer require --dev friendsofphp/php-cs-fixer
```
### Configuration
Create `Build/php-cs-fixer.php`:
```php
<?php
declare(strict_types=1);
$finder = (new PhpCsFixer\Finder())
->in(__DIR__ . '/../Classes')
->in(__DIR__ . '/../Tests')
->exclude('_output');
return (new PhpCsFixer\Config())
->setRules([
'@PSR12' => true,
'@PhpCsFixer' => true,
'array_syntax' => ['syntax' => 'short'],
'concat_space' => ['spacing' => 'one'],
'declare_strict_types' => true,
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'no_unused_imports' => true,
'single_line_throw' => false,
'phpdoc_align' => false,
'phpdoc_no_empty_return' => false,
'phpdoc_summary' => false,
])
->setRiskyAllowed(true)
->setFinder($finder);
```
### Running php-cs-fixer
```bash
# Check only (dry run)
vendor/bin/php-cs-fixer fix --config Build/php-cs-fixer.php --dry-run --diff
# Fix files
vendor/bin/php-cs-fixer fix --config Build/php-cs-fixer.php
# Via runTests.sh
Build/Scripts/runTests.sh -s cgl
```
### Common Rules
```php
// array_syntax: short
$array = [1, 2, 3]; // ✅
$array = array(1, 2, 3); // ❌
// concat_space: one
$message = 'Hello ' . $name; // ✅
$message = 'Hello '.$name; // ❌
// declare_strict_types
<?php
declare(strict_types=1); // ✅ Required at top of file
// ordered_imports
use Vendor\Extension\Domain\Model\Product; // ✅ Alphabetical
use Vendor\Extension\Domain\Repository\ProductRepository;
```
## phplint
### Installation
```bash
composer require --dev overtrue/phplint
```
### Configuration
Create `.phplint.yml`:
```yaml
path: ./
jobs: 10
cache: var/cache/phplint.cache
exclude:
- vendor
- var
- .Build
extensions:
- php
```
### Running phplint
```bash
# Lint all PHP files
vendor/bin/phplint
# Via runTests.sh
Build/Scripts/runTests.sh -s lint
# Specific directory
vendor/bin/phplint Classes/
```
## Composer Script Integration
```json
{
"scripts": {
"ci:test:php:lint": "phplint",
"ci:test:php:phpstan": "phpstan analyze --configuration Build/phpstan.neon --no-progress",
"ci:test:php:rector": "rector process --dry-run",
"ci:test:php:cgl": "php-cs-fixer fix --config Build/php-cs-fixer.php --dry-run --diff",
"ci:test:php:security": "composer audit",
"fix:cgl": "php-cs-fixer fix --config Build/php-cs-fixer.php",
"fix:rector": "rector process",
"ci:test": [
"@ci:test:php:lint",
"@ci:test:php:phpstan",
"@ci:test:php:rector",
"@ci:test:php:cgl",
"@ci:test:php:security"
]
}
}
```
> **Security Note**: `composer audit` checks for known security vulnerabilities in dependencies. Run this regularly and especially before releases.
## Pre-commit Hook
Create `.git/hooks/pre-commit`:
```bash
#!/bin/sh
echo "Running quality checks..."
# Lint
vendor/bin/phplint || exit 1
# PHPStan
vendor/bin/phpstan analyze --configuration Build/phpstan.neon --error-format=table --no-progress || exit 1
# Code style
vendor/bin/php-cs-fixer fix --config Build/php-cs-fixer.php --dry-run --diff || exit 1
echo "✓ All checks passed"
```
## CI/CD Integration
### GitHub Actions
```yaml
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4' # Use latest PHP for quality tools
- run: composer install
- run: composer ci:test:php:lint
- run: composer ci:test:php:phpstan
- run: composer ci:test:php:cgl
- run: composer ci:test:php:rector
- run: composer ci:test:php:security
```
## IDE Integration
### PHPStorm
1. **PHPStan**: Settings → PHP → Quality Tools → PHPStan
2. **php-cs-fixer**: Settings → PHP → Quality Tools → PHP CS Fixer
3. **File Watchers**: Auto-run on file save
### VS Code
```json
{
"php.validate.executablePath": "/usr/bin/php",
"phpstan.enabled": true,
"phpstan.configFile": "Build/phpstan.neon",
"php-cs-fixer.onsave": true,
"php-cs-fixer.config": "Build/php-cs-fixer.php"
}
```
## Best Practices
1. **PHPStan Level 10**: Aim for `level: max` in modern TYPO3 13 projects
2. **Baseline for Legacy**: Use baselines to track existing issues during migration
3. **Security Audits**: Run `composer audit` regularly and in CI
4. **Auto-fix in CI**: Run fixes automatically, fail on violations
5. **Consistent Rules**: Share config across team
6. **Pre-commit Checks**: Catch issues before commit (lint, PHPStan, CGL, security)
7. **Latest PHP**: Run quality tools with latest PHP version (8.4+)
8. **Regular Updates**: Keep tools and rules updated
## Resources
- [PHPStan Documentation](https://phpstan.org/user-guide/getting-started)
- [Rector Documentation](https://getrector.com/documentation)
- [PHP CS Fixer Documentation](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer)
- [TYPO3 Coding Guidelines](https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/CodingGuidelines/)

691
references/test-runners.md Normal file
View File

@@ -0,0 +1,691 @@
# Test Runners and Orchestration
The `runTests.sh` script is the standard TYPO3 pattern for orchestrating all quality checks and test suites.
## Purpose
- Single entry point for all testing and quality checks
- Consistent environment across local and CI/CD
- Handles Docker, database setup, and test execution
- Based on [TYPO3 Best Practices tea extension](https://github.com/TYPO3BestPractices/tea)
## Script Location
```
Build/Scripts/runTests.sh
```
## Basic Usage
```bash
# Show help
./Build/Scripts/runTests.sh -h
# Run specific test suite
./Build/Scripts/runTests.sh -s unit
./Build/Scripts/runTests.sh -s functional
./Build/Scripts/runTests.sh -s acceptance
# Run quality tools
./Build/Scripts/runTests.sh -s lint
./Build/Scripts/runTests.sh -s phpstan
./Build/Scripts/runTests.sh -s cgl
./Build/Scripts/runTests.sh -s rector
```
## Script Options
```
-s <suite> Test suite to run (required)
unit, functional, acceptance, lint, phpstan, cgl, rector
-d <driver> Database driver for functional tests
mysqli (default), pdo_mysql, postgres, sqlite
-p <version> PHP version (7.4, 8.1, 8.2, 8.3)
-e <command> Execute specific command in container
-n Don't pull Docker images
-u Update composer dependencies
-v Enable verbose output
-x Stop on first error (PHPUnit --stop-on-error)
```
## Examples
### Run Unit Tests
```bash
# Default PHP version
./Build/Scripts/runTests.sh -s unit
# Specific PHP version
./Build/Scripts/runTests.sh -s unit -p 8.3
# Stop on first error
./Build/Scripts/runTests.sh -s unit -x
```
### Run Functional Tests
```bash
# Default database (mysqli)
./Build/Scripts/runTests.sh -s functional
# PostgreSQL
./Build/Scripts/runTests.sh -s functional -d postgres
# SQLite (fastest for local development)
./Build/Scripts/runTests.sh -s functional -d sqlite
```
### Run Quality Tools
```bash
# Lint all PHP files
./Build/Scripts/runTests.sh -s lint
# PHPStan static analysis
./Build/Scripts/runTests.sh -s phpstan
# Code style check
./Build/Scripts/runTests.sh -s cgl
# Rector automated refactoring
./Build/Scripts/runTests.sh -s rector
```
### Custom Commands
```bash
# Run specific test file
./Build/Scripts/runTests.sh -s unit -e "bin/phpunit Tests/Unit/Domain/Model/ProductTest.php"
# Run with coverage
./Build/Scripts/runTests.sh -s unit -e "bin/phpunit --coverage-html coverage/"
```
## Composer Integration
Integrate runTests.sh into composer.json:
```json
{
"scripts": {
"ci:test": [
"@ci:test:php:lint",
"@ci:test:php:phpstan",
"@ci:test:php:cgl",
"@ci:test:php:rector",
"@ci:test:php:unit",
"@ci:test:php:functional"
],
"ci:test:php:lint": "Build/Scripts/runTests.sh -s lint",
"ci:test:php:phpstan": "Build/Scripts/runTests.sh -s phpstan",
"ci:test:php:cgl": "Build/Scripts/runTests.sh -s cgl",
"ci:test:php:rector": "Build/Scripts/runTests.sh -s rector",
"ci:test:php:unit": "Build/Scripts/runTests.sh -s unit",
"ci:test:php:functional": "Build/Scripts/runTests.sh -s functional"
}
}
```
Then run via composer:
```bash
composer ci:test # All checks
composer ci:test:php:unit # Just unit tests
composer ci:test:php:phpstan # Just PHPStan
```
## Script Structure
### Basic Template
```bash
#!/usr/bin/env bash
# Script configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
# Default values
TEST_SUITE=""
DATABASE_DRIVER="mysqli"
PHP_VERSION="8.4"
VERBOSE=""
# Parse arguments
while getopts ":s:d:p:e:nuvx" opt; do
case ${opt} in
s) TEST_SUITE=${OPTARG} ;;
d) DATABASE_DRIVER=${OPTARG} ;;
p) PHP_VERSION=${OPTARG} ;;
*) showHelp; exit 1 ;;
esac
done
# Validate required arguments
if [ -z "${TEST_SUITE}" ]; then
echo "Error: -s parameter (test suite) is required"
showHelp
exit 1
fi
# Execute test suite
case ${TEST_SUITE} in
unit)
runUnitTests
;;
functional)
runFunctionalTests
;;
lint)
runLint
;;
*)
echo "Error: Unknown test suite: ${TEST_SUITE}"
showHelp
exit 1
;;
esac
```
### Docker Integration
```bash
runUnitTests() {
CONTAINER_PATH="/app"
docker run \
--rm \
-v "${PROJECT_DIR}:${CONTAINER_PATH}" \
-w "${CONTAINER_PATH}" \
php:${PHP_VERSION}-cli \
bin/phpunit -c Build/phpunit/UnitTests.xml
}
runFunctionalTests() {
CONTAINER_PATH="/app"
docker run \
--rm \
-v "${PROJECT_DIR}:${CONTAINER_PATH}" \
-w "${CONTAINER_PATH}" \
-e typo3DatabaseDriver="${DATABASE_DRIVER}" \
-e typo3DatabaseHost="localhost" \
-e typo3DatabaseName="typo3_test" \
php:${PHP_VERSION}-cli \
bin/phpunit -c Build/phpunit/FunctionalTests.xml
}
```
### Quality Tool Functions
```bash
runLint() {
docker run \
--rm \
-v "${PROJECT_DIR}:/app" \
-w /app \
php:${PHP_VERSION}-cli \
vendor/bin/phplint
}
runPhpstan() {
docker run \
--rm \
-v "${PROJECT_DIR}:/app" \
-w /app \
php:${PHP_VERSION}-cli \
vendor/bin/phpstan analyze --configuration Build/phpstan.neon
}
runCgl() {
docker run \
--rm \
-v "${PROJECT_DIR}:/app" \
-w /app \
php:${PHP_VERSION}-cli \
vendor/bin/php-cs-fixer fix --config Build/php-cs-fixer.php --dry-run --diff
}
```
## Environment Variables
Configure via environment variables:
```bash
# Database configuration
export typo3DatabaseDriver=pdo_mysql
export typo3DatabaseHost=db
export typo3DatabasePort=3306
export typo3DatabaseName=typo3_test
export typo3DatabaseUsername=root
export typo3DatabasePassword=root
# TYPO3 context
export TYPO3_CONTEXT=Testing
# Run tests
./Build/Scripts/runTests.sh -s functional
```
## CI/CD Integration
### GitHub Actions
```yaml
name: Tests
on: [push, pull_request]
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
php: '8.2', '8.2', '8.3']
suite: ['unit', 'functional', 'lint', 'phpstan']
steps:
- uses: actions/checkout@v4
- name: Run ${{ matrix.suite }} tests
run: Build/Scripts/runTests.sh -s ${{ matrix.suite }} -p ${{ matrix.php }}
```
### GitLab CI
```yaml
.test:
image: php:${PHP_VERSION}-cli
script:
- Build/Scripts/runTests.sh -s ${TEST_SUITE} -p ${PHP_VERSION}
unit:8.2:
extends: .test
variables:
PHP_VERSION: "8.2"
TEST_SUITE: "unit"
functional:8.2:
extends: .test
variables:
PHP_VERSION: "8.2"
TEST_SUITE: "functional"
```
## Performance Optimization
### Parallel Execution
```bash
# Run linting in parallel (fast)
find . -name '*.php' -print0 | xargs -0 -n1 -P8 php -l
# PHPUnit parallel execution
vendor/bin/paratest -c Build/phpunit/UnitTests.xml --processes=4
```
### Caching
```bash
# Cache Composer dependencies
if [ ! -d "${PROJECT_DIR}/.cache/composer" ]; then
mkdir -p "${PROJECT_DIR}/.cache/composer"
fi
docker run \
--rm \
-v "${PROJECT_DIR}:/app" \
-v "${PROJECT_DIR}/.cache/composer:/tmp/composer-cache" \
php:${PHP_VERSION}-cli \
composer install --no-progress --no-suggest
```
## Best Practices
1. **Single Source of Truth**: Use runTests.sh for all test execution
2. **CI/CD Alignment**: CI should use same script as local development
3. **Docker Isolation**: Run tests in containers for consistency
4. **Fast Feedback**: Run lint and unit tests first (fastest)
5. **Matrix Testing**: Test multiple PHP versions and databases
6. **Caching**: Cache dependencies to speed up execution
7. **Verbose Mode**: Use `-v` flag for debugging test failures
## Troubleshooting
### Docker Permission Issues
```bash
# Run with current user
docker run \
--rm \
--user $(id -u):$(id -g) \
-v "${PROJECT_DIR}:/app" \
php:${PHP_VERSION}-cli \
bin/phpunit
```
### Database Connection Errors
```bash
# Verify database is accessible
docker run --rm --network host mysql:8.0 \
mysql -h localhost -u root -p -e "SELECT 1"
# Use SQLite for simple tests
./Build/Scripts/runTests.sh -s functional -d sqlite
```
### Missing Dependencies
```bash
# Update dependencies
./Build/Scripts/runTests.sh -s unit -u
```
## Resources
- [TYPO3 Tea Extension runTests.sh](https://github.com/TYPO3BestPractices/tea/blob/main/Build/Scripts/runTests.sh)
- [TYPO3 Testing Documentation](https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/Testing/)
- [PHPUnit Documentation](https://phpunit.de/documentation.html)
## Multi-PHP Version Testing
TYPO3 extensions should support multiple PHP versions to maximize compatibility. This section covers testing strategies for all supported PHP versions.
### Standard Approach: Using Build/Scripts/runTests.sh
The `runTests.sh` script with Docker containers is the recommended TYPO3 testing approach. It provides isolated environments and consistent results.
#### Testing Across PHP Versions with Docker
```bash
# Test with PHP 8.2
./Build/Scripts/runTests.sh -s unit -p 8.2
./Build/Scripts/runTests.sh -s functional -p 8.2 -d sqlite
# Test with PHP 8.3
./Build/Scripts/runTests.sh -s unit -p 8.3
./Build/Scripts/runTests.sh -s functional -p 8.3 -d sqlite
# Test with PHP 8.4
./Build/Scripts/runTests.sh -s unit -p 8.4
./Build/Scripts/runTests.sh -s functional -p 8.4 -d sqlite
```
**Advantages:**
- Isolated Docker containers per PHP version
- Consistent environment across local and CI/CD
- No need to install multiple PHP versions locally
- Handles database setup automatically
- Based on official TYPO3 best practices
#### Complete Test Matrix Example
```bash
#!/bin/bash
# Test all supported PHP versions and databases
PHP_VERSIONS=("8.2" "8.3" "8.4")
DATABASES=("sqlite" "mysql" "postgres")
for PHP in "${PHP_VERSIONS[@]}"; do
echo "Testing PHP ${PHP}..."
# Unit tests
./Build/Scripts/runTests.sh -s unit -p "${PHP}"
# Functional tests with different databases
for DB in "${DATABASES[@]}"; do
echo " Functional tests with ${DB}..."
./Build/Scripts/runTests.sh -s functional -p "${PHP}" -d "${DB}"
done
# Quality tools (run once per PHP version)
./Build/Scripts/runTests.sh -s lint -p "${PHP}"
./Build/Scripts/runTests.sh -s phpstan -p "${PHP}"
done
```
### Alternative Approach: Native PHP Versions (Without Docker)
For CI/CD environments or when Docker is unavailable, use locally installed PHP versions.
#### Testing with Native PHP Installations
```bash
# Test with PHP 8.2
php8.2 /usr/local/bin/composer update --no-interaction
php8.2 .Build/bin/phpunit --configuration=Build/phpunit/UnitTests.xml
typo3DatabaseDriver=pdo_sqlite php8.2 .Build/bin/phpunit --configuration=Build/phpunit/FunctionalTests.xml
# Test with PHP 8.3
php8.3 /usr/local/bin/composer update --no-interaction
php8.3 .Build/bin/phpunit --configuration=Build/phpunit/UnitTests.xml
typo3DatabaseDriver=pdo_sqlite php8.3 .Build/bin/phpunit --configuration=Build/phpunit/FunctionalTests.xml
# Test with PHP 8.4
php8.4 /usr/local/bin/composer update --no-interaction
php8.4 .Build/bin/phpunit --configuration=Build/phpunit/UnitTests.xml
typo3DatabaseDriver=pdo_sqlite php8.4 .Build/bin/phpunit --configuration=Build/phpunit/FunctionalTests.xml
```
**CRITICAL**: Always run `composer update` with the target PHP version FIRST. This ensures:
- Correct PHPUnit version selection (PHPUnit 11 for PHP 8.2, PHPUnit 12 for PHP 8.3+)
- Proper dependency resolution for the PHP version
- Compatible autoloader generation
### PHPUnit Version Compatibility
The `typo3/testing-framework` supports both PHPUnit 11 and 12, allowing Composer to automatically select the compatible version:
| PHP Version | PHPUnit Version | Auto-Selected by Composer |
|-------------|-----------------|---------------------------|
| 8.2 | 11.x | ✅ Yes |
| 8.3 | 11.x or 12.x | ✅ Yes (12.x preferred) |
| 8.4 | 11.x or 12.x | ✅ Yes (12.x preferred) |
**Example: Automatic PHPUnit Selection**
```bash
# PHP 8.2 automatically gets PHPUnit 11
$ php8.2 /usr/local/bin/composer update
# Installing typo3/testing-framework (v8.0.14)
# Installing phpunit/phpunit (11.5.42)
# PHP 8.3 automatically gets PHPUnit 12
$ php8.3 /usr/local/bin/composer update
# Installing typo3/testing-framework (v8.0.14)
# Installing phpunit/phpunit (12.4.1)
```
### GitHub Actions CI/CD Integration
#### Using runTests.sh (Recommended)
```yaml
name: CI
on: [push, pull_request]
jobs:
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4']
suite: ['unit', 'functional']
database: ['sqlite', 'mysql', 'postgres']
exclude:
# Only test sqlite for unit tests
- suite: unit
database: mysql
- suite: unit
database: postgres
steps:
- uses: actions/checkout@v4
- name: Run ${{ matrix.suite }} tests on PHP ${{ matrix.php }}
run: |
Build/Scripts/runTests.sh \
-s ${{ matrix.suite }} \
-p ${{ matrix.php }} \
-d ${{ matrix.database }}
```
#### Using Native PHP (Alternative)
```yaml
name: CI
on: [push, pull_request]
jobs:
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4']
steps:
- uses: actions/checkout@v4
- name: Set up PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, libxml, sqlite3
coverage: none
- name: Install dependencies
run: composer update --no-interaction --no-progress
- name: Run unit tests
run: .Build/bin/phpunit -c Build/phpunit/UnitTests.xml
- name: Run functional tests
env:
typo3DatabaseDriver: pdo_sqlite
run: .Build/bin/phpunit -c Build/phpunit/FunctionalTests.xml
```
### Common Pitfalls
#### ❌ Wrong: Testing Without Updating Dependencies
```bash
# This will use wrong PHPUnit version
php8.2 .Build/bin/phpunit -c Build/phpunit/UnitTests.xml
```
**Problem:** Uses PHPUnit version from previous `composer update`, may be incompatible.
#### ✅ Right: Update Dependencies First
```bash
# This ensures correct PHPUnit version
php8.2 /usr/local/bin/composer update --no-interaction
php8.2 .Build/bin/phpunit -c Build/phpunit/UnitTests.xml
```
#### ❌ Wrong: Removing PHP Version Support Due to Test Failures
```bash
# Don't do this!
# composer.json: "php": "^8.3 || ^8.4" # Removed 8.2
```
**Problem:** Unnecessarily reduces compatibility. Fix the testing approach instead.
#### ✅ Right: Fix Testing Process
```bash
# Run composer update with the problematic PHP version
php8.2 /usr/local/bin/composer update
# Composer automatically selects compatible dependencies
# Now tests work correctly
```
### Best Practices
1. **Standard Approach First**: Use `Build/Scripts/runTests.sh` with Docker whenever possible
2. **Update Before Testing**: Always run `composer update` with target PHP version first (native approach)
3. **Trust Composer**: Let Composer select compatible PHPUnit versions automatically
4. **Test All Versions**: Run full test suite on all supported PHP versions in CI
5. **SQLite for Speed**: Use SQLite for local functional testing (fastest)
6. **Matrix Testing**: Use CI matrix to test all PHP versions in parallel
7. **Don't Remove Support**: Fix testing process, don't remove PHP version support
### Troubleshooting
#### Docker Issues
```bash
# Update Docker images
./Build/Scripts/runTests.sh -u
# Check Docker is running
docker ps
# Clean up old containers
docker system prune -a
```
#### Native PHP Issues
```bash
# Verify PHP version
php8.2 --version
# Check installed extensions
php8.2 -m | grep -E 'dom|libxml|sqlite'
# Clean and reinstall dependencies
rm -rf .Build vendor composer.lock
php8.2 /usr/local/bin/composer install
```
#### PHPUnit Version Conflicts
```bash
# Clear Composer cache
php8.2 /usr/local/bin/composer clear-cache
# Show why PHPUnit version was selected
php8.2 /usr/local/bin/composer why phpunit/phpunit
# Force dependency resolution
rm composer.lock
php8.2 /usr/local/bin/composer update --with-all-dependencies
```
### Summary
| Aspect | Docker/runTests.sh | Native PHP |
|--------|-------------------|------------|
| **Use Case** | Standard TYPO3 development | CI/CD, no Docker available |
| **Isolation** | ✅ Full container isolation | ⚠️ System-wide PHP |
| **Setup** | Docker required | Multiple PHP versions required |
| **Speed** | Slower (container overhead) | Faster (native execution) |
| **Consistency** | ✅ Guaranteed environment | ⚠️ Depends on system config |
| **Recommendation** | **Recommended** | Alternative when needed |
**Choose Docker/runTests.sh for:**
- Local development and testing
- Consistent environment across team
- Official TYPO3 best practices
**Choose Native PHP for:**
- GitHub Actions/GitLab CI environments
- Systems without Docker
- Performance-critical testing pipelines

929
references/unit-testing.md Normal file
View File

@@ -0,0 +1,929 @@
# 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
<?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
```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
<?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
<?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:
```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
<?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
```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
<?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)
```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**
```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
<?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
- [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)