assets/.php-cs-fixer.dist.php
<?php
declare(strict_types=1);
/*
* PHP CS Fixer Configuration for TYPO3 Extensions
*
* Run check: Build/Scripts/runTests.sh -s cgl -n
* Run fix: Build/Scripts/runTests.sh -s cgl
*
* CUSTOMIZATION:
* - Adjust paths in Finder::create() for your directory structure
* - Update @PHP8x* migration rule for your minimum PHP version
*/
use PhpCsFixer\Config;
use PhpCsFixer\Finder;
use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
$finder = Finder::create()
->in(__DIR__ . '/Classes')
->in(__DIR__ . '/Configuration')
->in(__DIR__ . '/Tests')
->ignoreDotFiles(false)
->ignoreVCSIgnored(true);
return (new Config())
->setParallelConfig(ParallelConfigFactory::detect())
->setRiskyAllowed(true)
->setRules([
// Base ruleset: PER Coding Style (PHP-FIG standard)
'@PER-CS' => true,
// PHP version migration (adjust to your minimum: @PHP80Migration, @PHP81Migration, etc.)
'@PHP82Migration' => true,
// Strict rules
'declare_strict_types' => true,
'strict_comparison' => true,
'strict_param' => true,
// Array notation
'array_syntax' => ['syntax' => 'short'],
'no_whitespace_before_comma_in_array' => true,
'whitespace_after_comma_in_array' => true,
'trim_array_spaces' => true,
'normalize_index_brace' => true,
// Casing
'constant_case' => true,
'lowercase_keywords' => true,
'native_function_casing' => true,
// Class notation
'class_attributes_separation' => [
'elements' => [
'const' => 'one',
'method' => 'one',
'property' => 'one',
],
],
'final_class' => false, // Use final explicitly where needed
'no_blank_lines_after_class_opening' => true,
'ordered_class_elements' => [
'order' => [
'case', // Enum cases must come first
'use_trait',
'constant_public',
'constant_protected',
'constant_private',
'property_public',
'property_protected',
'property_private',
'construct',
'destruct',
'magic',
'phpunit',
'method_public',
'method_protected',
'method_private',
],
],
'self_accessor' => true,
'single_class_element_per_statement' => true,
// Control structure
'no_alternative_syntax' => true,
'no_superfluous_elseif' => true,
'no_useless_else' => true,
'simplified_if_return' => true,
'trailing_comma_in_multiline' => [
'elements' => ['arguments', 'arrays', 'match', 'parameters'],
],
// Function notation
'function_declaration' => ['closure_function_spacing' => 'one'],
'method_argument_space' => [
'on_multiline' => 'ensure_fully_multiline',
],
'native_function_invocation' => [
'include' => ['@compiler_optimized'],
'scope' => 'namespaced',
'strict' => true,
],
'nullable_type_declaration_for_default_null_value' => true,
'return_type_declaration' => ['space_before' => 'none'],
'single_line_throw' => false,
'void_return' => true,
// Import
'fully_qualified_strict_types' => true,
'global_namespace_import' => [
'import_classes' => true,
'import_constants' => false,
'import_functions' => false,
],
'no_unused_imports' => true,
'ordered_imports' => [
'imports_order' => ['class', 'function', 'const'],
'sort_algorithm' => 'alpha',
],
// Language construct
'combine_consecutive_issets' => true,
'combine_consecutive_unsets' => true,
'declare_parentheses' => true,
'single_space_around_construct' => true,
// Namespace
'blank_line_after_namespace' => true,
'clean_namespace' => true,
'no_leading_namespace_whitespace' => true,
// Operator
'binary_operator_spaces' => [
'default' => 'single_space',
],
'concat_space' => ['spacing' => 'one'],
'new_with_parentheses' => true,
'not_operator_with_successor_space' => false,
'object_operator_without_whitespace' => true,
'operator_linebreak' => ['only_booleans' => true],
'standardize_not_equals' => true,
'ternary_operator_spaces' => true,
'unary_operator_spaces' => ['only_dec_inc' => false],
// PHPDoc
'align_multiline_comment' => ['comment_type' => 'phpdocs_only'],
'no_blank_lines_after_phpdoc' => true,
'no_empty_phpdoc' => true,
'no_superfluous_phpdoc_tags' => [
'allow_mixed' => true,
'remove_inheritdoc' => true,
],
'phpdoc_align' => ['align' => 'left'],
'phpdoc_indent' => true,
'phpdoc_line_span' => [
'const' => 'single',
'method' => 'multi',
'property' => 'single',
],
'phpdoc_no_empty_return' => true,
'phpdoc_order' => true,
'phpdoc_scalar' => true,
'phpdoc_separation' => true,
'phpdoc_single_line_var_spacing' => true,
'phpdoc_trim' => true,
'phpdoc_trim_consecutive_blank_line_separation' => true,
'phpdoc_types' => true,
'phpdoc_types_order' => [
'null_adjustment' => 'always_last',
'sort_algorithm' => 'none',
],
'phpdoc_var_without_name' => true,
// Return notation
'no_useless_return' => true,
'return_assignment' => true,
// Semicolon
'multiline_whitespace_before_semicolons' => ['strategy' => 'no_multi_line'],
'no_empty_statement' => true,
'no_singleline_whitespace_before_semicolons' => true,
'semicolon_after_instruction' => true,
// String notation
'single_quote' => true,
// Whitespace
'array_indentation' => true,
'blank_line_before_statement' => [
'statements' => ['return', 'throw', 'try'],
],
'compact_nullable_type_declaration' => true,
'heredoc_indentation' => ['indentation' => 'same_as_start'],
'method_chaining_indentation' => true,
'no_extra_blank_lines' => [
'tokens' => [
'break',
'case',
'continue',
'curly_brace_block',
'default',
'extra',
'parenthesis_brace_block',
'return',
'square_brace_block',
'switch',
'throw',
'use',
],
],
'no_spaces_around_offset' => true,
'no_whitespace_in_blank_line' => true,
'types_spaces' => ['space' => 'none'],
])
->setFinder($finder);
assets/AGENTS.md
# Testing Context for AI Assistants
This directory contains tests for the TYPO3 extension.
## Test Type
**[Unit|Functional|E2E]** tests
## Test Strategy
<!-- Describe what this directory tests and why -->
<!-- Example: "Unit tests for domain models - validates business logic without database" -->
<!-- Example: "Functional tests for repositories - verifies database queries and persistence" -->
<!-- Example: "E2E tests for checkout workflow - validates complete user journey from cart to payment" -->
**Scope:**
**Key Scenarios:**
**Not Covered:** <!-- What is intentionally not tested here -->
## Testing Framework
- **TYPO3 Testing Framework** (typo3/testing-framework)
- **PHPUnit** for assertions and test execution
- **[Additional tools for this test type]:**
- Unit: Prophecy for mocking
- Functional: CSV fixtures for database data
- E2E: Playwright + axe-core for browser automation and accessibility
## Test Structure
### Base Class
Tests in this directory extend:
- **Unit**: `TYPO3\TestingFramework\Core\Unit\UnitTestCase`
- **Functional**: `TYPO3\TestingFramework\Core\Functional\FunctionalTestCase`
- **E2E**: Playwright test fixtures from `setup-fixtures.ts`
### Naming Convention
- **Unit/Functional**: `*Test.php` (e.g., `ProductTest.php`, `ProductRepositoryTest.php`)
- **E2E**: `*.spec.ts` (e.g., `backend-module.spec.ts`, `checkout.spec.ts`)
## Key Patterns
### setUp() and tearDown() (PHP Tests)
```php
protected function setUp(): void
{
parent::setUp();
// Initialize test dependencies
}
protected function tearDown(): void
{
// Clean up resources
parent::tearDown();
}
```
### Assertions
Use specific assertions over generic ones:
- `self::assertTrue()`, `self::assertFalse()` for booleans
- `self::assertSame()` for strict equality
- `self::assertInstanceOf()` for type checks
- `self::assertCount()` for arrays/collections
### Fixtures (Functional Tests Only)
```php
$this->importCSVDataSet(__DIR__ . '/../Fixtures/MyFixture.csv');
```
**Fixture Files:** `Tests/Functional/Fixtures/`
**Strategy:**
- Keep fixtures minimal (only required data)
- One fixture per test scenario
- Document fixture contents in test or below
### Mocking (Unit Tests Only)
```php
use Prophecy\PhpUnit\ProphecyTrait;
$repository = $this->prophesize(UserRepository::class);
$repository->findByEmail('test@example.com')->willReturn($user);
```
### Page Objects (E2E Tests Only)
```typescript
import { test, expect } from '../fixtures/setup-fixtures';
test('can access module', async ({ backend }) => {
await backend.gotoModule('web_myextension');
await backend.moduleLoaded();
await expect(backend.contentFrame.locator('h1')).toBeVisible();
});
```
## Running Tests
```bash
# All PHP tests in this directory
composer ci:test:php:[unit|functional]
# Via runTests.sh
Build/Scripts/runTests.sh -s [unit|functional|e2e]
# Specific PHP test file
vendor/bin/phpunit Tests/[Unit|Functional]/Path/To/TestFile.php
# E2E tests (Playwright)
cd Build && npm run playwright:run
# Specific E2E test
cd Build && npx playwright test e2e/backend-module.spec.ts
```
## Fixtures Documentation (Functional Tests)
<!-- Document what each fixture contains -->
### `Fixtures/BasicProducts.csv`
- 3 products in category 1
- 2 products in category 2
- All products visible and published
### `Fixtures/PageTree.csv`
- Root page (uid: 1)
- Products page (uid: 2, pid: 1)
- Services page (uid: 3, pid: 1)
## Test Dependencies
<!-- List any special dependencies or requirements -->
- [ ] Database (functional tests only)
- [ ] Node.js 22.18+ (E2E tests only)
- [ ] Playwright browsers (E2E tests only)
- [ ] Specific TYPO3 extensions: <!-- list if any -->
- [ ] External services: <!-- list if any -->
## Common Issues
<!-- Document common test failures and solutions -->
**Database connection errors:**
- Verify database driver configuration in `FunctionalTests.xml`
- Check Docker database service is running
**Fixture import errors:**
- Verify CSV format (proper escaping, matching table structure)
- Check file paths are correct relative to test class
**E2E test failures:**
- Verify TYPO3 backend is running and accessible
- Run `npm run playwright:install` to install browsers
- Check `playwright.config.ts` baseURL matches your environment
- Use `npx playwright test --debug` for interactive debugging
**Flaky tests:**
- Use proper waits in E2E tests (`waitForLoadState`, `waitForSelector`)
- Avoid timing dependencies in unit/functional tests
- Ensure test independence (no shared state)
## Resources
- [Unit Testing Guide](~/.claude/skills/typo3-testing/references/unit-testing.md)
- [Functional Testing Guide](~/.claude/skills/typo3-testing/references/functional-testing.md)
- [E2E Testing Guide](~/.claude/skills/typo3-testing/references/e2e-testing.md)
- [Accessibility Testing Guide](~/.claude/skills/typo3-testing/references/accessibility-testing.md)
- [TYPO3 Testing Documentation](https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/Testing/)
- [Playwright Documentation](https://playwright.dev/docs/intro)
assets/bootstrap.php
<?php
declare(strict_types=1);
/**
* General Bootstrap for TYPO3 Extension Tests
*
* Place this file at Tests/bootstrap.php
*
* This bootstrap initializes the test environment for all test types.
* It sets up autoloading and basic TYPO3 constants.
*/
// Set timezone to avoid date/time warnings
date_default_timezone_set('UTC');
// Locate composer autoloader
$autoloadLocations = [
// Standard .Build directory (runTests.sh)
dirname(__DIR__) . '/.Build/vendor/autoload.php',
// Composer root installation
dirname(__DIR__, 3) . '/vendor/autoload.php',
// Local vendor directory
dirname(__DIR__) . '/vendor/autoload.php',
];
$autoloadFile = null;
foreach ($autoloadLocations as $location) {
if (file_exists($location)) {
$autoloadFile = $location;
break;
}
}
if ($autoloadFile === null) {
throw new RuntimeException(
'Could not find composer autoload.php. Run "composer install" first.'
);
}
require_once $autoloadFile;
// Define TYPO3 constants if not already defined
// These are needed for some TYPO3 core classes even in unit tests
if (!defined('TYPO3')) {
// TYPO3 v12+ uses this constant
define('TYPO3', true);
}
if (!defined('TYPO3_MODE')) {
// Legacy constant for backwards compatibility
define('TYPO3_MODE', 'BE');
}
if (!defined('TYPO3_REQUESTTYPE')) {
// CLI request type
define('TYPO3_REQUESTTYPE', 2);
}
assets/Build/playwright/.nvmrc
22.18
assets/Build/playwright/package.json
{
"name": "typo3-extension-e2e-tests",
"version": "1.0.0",
"private": true,
"engines": {
"node": ">=22.18.0 <23.0.0",
"npm": ">=11.5.2"
},
"scripts": {
"playwright:install": "playwright install",
"playwright:open": "playwright test --ui --ignore-https-errors",
"playwright:run": "playwright test",
"playwright:codegen": "playwright codegen",
"playwright:report": "playwright show-report"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@axe-core/playwright": "^4.10.0"
}
}
assets/Build/playwright/playwright.config.ts
/**
* Playwright E2E Test Configuration for TYPO3 Extensions
*
* Based on TYPO3 Core configuration:
* @see https://github.com/TYPO3/typo3/blob/main/Build/playwright.config.ts
*/
import { defineConfig } from '@playwright/test';
import config from './tests/playwright/config';
export default defineConfig({
testDir: './tests/playwright',
timeout: 30000,
expect: {
timeout: 10000,
},
fullyParallel: false, // Tests within a file run sequentially (safer for state)
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined, // CI: 4 workers, Local: half of CPUs
reporter: [
['list'],
['html', { outputFolder: '../typo3temp/var/tests/playwright-reports' }],
],
outputDir: '../typo3temp/var/tests/playwright-results',
use: {
baseURL: config.baseUrl,
ignoreHTTPSErrors: true,
trace: 'on-first-retry',
},
projects: [
{
name: 'login setup',
testMatch: /helper\/login\.setup\.ts/,
},
{
name: 'accessibility',
testMatch: /accessibility\/.*\.spec\.ts/,
dependencies: ['login setup'],
use: {
storageState: './.auth/login.json',
},
},
{
name: 'e2e',
testMatch: /e2e\/.*\.spec\.ts/,
dependencies: ['login setup'],
use: {
storageState: './.auth/login.json',
},
},
],
});
assets/Build/playwright/tests/playwright/accessibility/modules.spec.ts
/**
* Accessibility Tests for TYPO3 Backend Modules
*
* Uses axe-core to verify WCAG 2.0/2.1 compliance at levels A and AA.
* Customize the modules array for your extension's routes.
*
* @see https://www.deque.com/axe/
*/
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
/**
* Define modules to test for accessibility
* Replace with your extension's module routes
*/
const modules = [
{ name: 'My Extension Module', route: 'module/web/myextension' },
// Add more modules as needed:
// { name: 'Settings', route: 'module/web/myextension/settings' },
];
for (const module of modules) {
test(`${module.name} has no accessibility violations`, async ({ page }) => {
// Navigate to module
await page.goto(module.route);
await page.waitForLoadState('networkidle');
// Run accessibility scan on the content iframe
const accessibilityScanResults = await new AxeBuilder({ page })
.include('#typo3-contentIframe')
// Disable rules that may produce false positives in TYPO3 backend
.disableRules(['color-contrast'])
.analyze();
// Assert no violations
expect(accessibilityScanResults.violations).toEqual([]);
});
}
test.describe('Accessibility - Additional Checks', () => {
test('module menu has proper ARIA attributes', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Check module menu accessibility
const moduleMenu = page.locator('#modulemenu');
await expect(moduleMenu).toHaveAttribute('role', 'navigation');
});
test('interactive elements are keyboard accessible', async ({ page }) => {
await page.goto('module/web/myextension');
await page.waitForLoadState('networkidle');
const contentFrame = page.frameLocator('#typo3-contentIframe');
// Tab through interactive elements
await page.keyboard.press('Tab');
// Verify focus is visible
const focusedElement = contentFrame.locator(':focus');
await expect(focusedElement).toBeVisible();
});
});
assets/Build/playwright/tests/playwright/config.ts
/**
* TYPO3-specific Playwright configuration
*
* Environment variables:
* - PLAYWRIGHT_BASE_URL: Base URL for the TYPO3 backend (default: http://web:80/typo3/)
* - PLAYWRIGHT_ADMIN_USERNAME: Admin username (default: admin)
* - PLAYWRIGHT_ADMIN_PASSWORD: Admin password (default: password)
*/
export default {
// Base URL with trailing slash for relative navigation
// Example: page.goto('module/web/layout') navigates to {baseUrl}module/web/layout
baseUrl: process.env.PLAYWRIGHT_BASE_URL ?? 'http://web:80/typo3/',
// Backend admin credentials
admin: {
username: process.env.PLAYWRIGHT_ADMIN_USERNAME ?? 'admin',
password: process.env.PLAYWRIGHT_ADMIN_PASSWORD ?? 'password',
},
};
assets/Build/playwright/tests/playwright/e2e/backend-module.spec.ts
/**
* Example E2E Test for TYPO3 Backend Module
*
* Replace 'my_extension' with your extension key and
* customize the tests for your module's functionality.
*/
import { test, expect } from '../fixtures/setup-fixtures';
test.describe('My Extension Backend Module', () => {
test('can access module', async ({ backend }) => {
// Navigate to your extension's module
// Replace 'web_myextension' with your module identifier
await backend.gotoModule('web_myextension');
await backend.moduleLoaded();
// Verify module content is visible
const contentFrame = backend.contentFrame;
await expect(contentFrame.locator('h1')).toBeVisible();
});
test('can perform action in module', async ({ backend, modal }) => {
await backend.gotoModule('web_myextension');
// Example: Click a button that opens a modal
await backend.contentFrame
.getByRole('button', { name: 'Create new record' })
.click();
// Verify modal appears
await expect(modal.container).toBeVisible();
await expect(modal.title).toContainText('Create');
// Close modal
await modal.close();
});
test('can save form data', async ({ backend }) => {
await backend.gotoModule('web_myextension');
const contentFrame = backend.contentFrame;
// Fill form fields
await contentFrame.getByLabel('Title').fill('Test Title');
await contentFrame.getByLabel('Description').fill('Test Description');
// Save the form
await contentFrame.getByRole('button', { name: 'Save' }).click();
// Wait for save response
await backend.waitForModuleResponse(/module\/web\/myextension/);
// Verify success message
await expect(contentFrame.locator('.alert-success')).toBeVisible();
});
});
assets/Build/playwright/tests/playwright/fixtures/setup-fixtures.ts
/**
* Playwright Test Fixtures for TYPO3 Backend Testing
*
* This file provides reusable fixtures (Page Object Models) for
* testing TYPO3 backend functionality.
*
* Usage:
* import { test, expect } from '../fixtures/setup-fixtures';
*
* test('my test', async ({ backend }) => {
* await backend.gotoModule('web_layout');
* });
*/
import { test as base, type Locator, type Page, expect } from '@playwright/test';
/**
* Backend Page Object Model
*/
export class BackendPage {
readonly page: Page;
readonly moduleMenu: Locator;
readonly contentFrame: ReturnType<Page['frameLocator']>;
constructor(page: Page) {
this.page = page;
this.moduleMenu = page.locator('#modulemenu');
this.contentFrame = page.frameLocator('#typo3-contentIframe');
}
/**
* Navigate to a TYPO3 backend module
*/
async gotoModule(identifier: string): Promise<void> {
const moduleLink = this.moduleMenu.locator(
`[data-modulemenu-identifier="${identifier}"]`
);
await moduleLink.click();
await expect(moduleLink).toHaveClass(/modulemenu-action-active/);
}
/**
* Wait for module to finish loading
*/
async moduleLoaded(): Promise<void> {
await this.page.evaluate(() => {
return new Promise<void>((resolve) => {
document.addEventListener('typo3-module-loaded', () => resolve(), {
once: true,
});
});
});
}
/**
* Wait for a specific backend response
*/
async waitForModuleResponse(urlPattern: string | RegExp): Promise<void> {
await this.page.waitForResponse((response) => {
const url = response.url();
const matches =
typeof urlPattern === 'string'
? url.includes(urlPattern)
: urlPattern.test(url);
return matches && response.status() === 200;
});
}
}
/**
* Modal Page Object Model
*/
export class Modal {
readonly page: Page;
readonly container: Locator;
readonly title: Locator;
readonly closeButton: Locator;
constructor(page: Page) {
this.page = page;
this.container = page.locator('.modal');
this.title = this.container.locator('.modal-title');
this.closeButton = this.container.locator('[data-bs-dismiss="modal"]');
}
async close(): Promise<void> {
await this.closeButton.click();
await expect(this.container).not.toBeVisible();
}
}
/**
* Fixture type definitions
*/
type BackendFixtures = {
backend: BackendPage;
modal: Modal;
};
/**
* Extended test with TYPO3 backend fixtures
*/
export const test = base.extend<BackendFixtures>({
backend: async ({ page }, use) => {
await use(new BackendPage(page));
},
modal: async ({ page }, use) => {
await use(new Modal(page));
},
});
export { expect, Locator };
assets/Build/playwright/tests/playwright/helper/login.setup.ts
/**
* TYPO3 Backend Login Setup
*
* This setup file authenticates with the TYPO3 backend and stores
* the session state for reuse across all tests.
*
* @see https://playwright.dev/docs/auth
*/
import { test as setup, expect } from '@playwright/test';
import config from '../config';
setup('login', async ({ page }) => {
// Navigate to TYPO3 backend login
await page.goto('/');
// Fill login form using accessibility labels
await page.getByLabel('Username').fill(config.admin.username);
await page.getByLabel('Password').fill(config.admin.password);
// Submit login
await page.getByRole('button', { name: 'Login' }).click();
// Wait for backend to load
await page.waitForLoadState('networkidle');
// Verify login succeeded by checking for module menu
await expect(page.locator('.t3js-topbar-button-modulemenu')).toBeVisible();
// Save authentication state for reuse
await page.context().storageState({ path: './.auth/login.json' });
});
assets/Build/Scripts/runTests.sh
#!/usr/bin/env bash
#
# TYPO3 Extension Test Runner
# Docker/podman-based test orchestration following TYPO3 core conventions.
#
# Template from: https://github.com/netresearch/typo3-testing-skill
# Reference: https://github.com/netresearch/t3x-nr-vault
#
# CUSTOMIZATION REQUIRED:
# 1. Replace 'my-extension' in NETWORK variable with your extension key
# 2. Set COMPOSER_ROOT_VERSION to your extension version
# 3. Adjust TYPO3_BASE_URL default for E2E tests
# 4. Remove mock OAuth section if not needed
#
trap 'cleanUp;exit 2' SIGINT
waitFor() {
local HOST=${1}
local PORT=${2}
local TESTCOMMAND="
COUNT=0;
while ! nc -z ${HOST} ${PORT}; do
if [ \"\${COUNT}\" -gt 10 ]; then
echo \"Can not connect to ${HOST} port ${PORT}. Aborting.\";
exit 1;
fi;
sleep 1;
COUNT=\$((COUNT + 1));
done;
"
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name wait-for-${SUFFIX} ${XDEBUG_MODE} -e XDEBUG_CONFIG="${XDEBUG_CONFIG}" ${IMAGE_ALPINE} /bin/sh -c "${TESTCOMMAND}"
if [[ $? -gt 0 ]]; then
kill -SIGINT -$$
fi
}
waitForHttp() {
local URL=${1}
local MAX_ATTEMPTS=${2:-30}
local TESTCOMMAND="
COUNT=0;
while ! wget -q --spider ${URL} 2>/dev/null; do
if [ \"\${COUNT}\" -gt ${MAX_ATTEMPTS} ]; then
echo \"HTTP endpoint ${URL} not available after ${MAX_ATTEMPTS} attempts. Aborting.\";
exit 1;
fi;
sleep 1;
COUNT=\$((COUNT + 1));
done;
echo \"HTTP endpoint ${URL} is ready.\";
"
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name wait-for-http-${SUFFIX} ${IMAGE_ALPINE} /bin/sh -c "${TESTCOMMAND}"
if [[ $? -gt 0 ]]; then
kill -SIGINT -$$
fi
}
cleanUp() {
ATTACHED_CONTAINERS=$(${CONTAINER_BIN} ps --filter network=${NETWORK} --format='{{.Names}}' 2>/dev/null)
for ATTACHED_CONTAINER in ${ATTACHED_CONTAINERS}; do
${CONTAINER_BIN} rm -f ${ATTACHED_CONTAINER} >/dev/null 2>&1
done
${CONTAINER_BIN} network rm ${NETWORK} >/dev/null 2>&1
}
cleanCacheFiles() {
echo -n "Clean caches ... "
rm -rf \
.Build/.cache \
.php-cs-fixer.cache \
Tests/Build/.phpunit.cache
echo "done"
}
handleDbmsOptions() {
case ${DBMS} in
mariadb)
[ -z "${DATABASE_DRIVER}" ] && DATABASE_DRIVER="mysqli"
if [ "${DATABASE_DRIVER}" != "mysqli" ] && [ "${DATABASE_DRIVER}" != "pdo_mysql" ]; then
echo "Invalid combination -d ${DBMS} -a ${DATABASE_DRIVER}" >&2
exit 1
fi
[ -z "${DBMS_VERSION}" ] && DBMS_VERSION="11.8"
# MariaDB series still in community support (mariadb.org
# maintenance policy): 10.11 until 2028-02, 11.4 until 2029-05,
# 11.8 until 2028-06, 12.3 until 2029-06. 10.5, 10.6 and 11.0 are
# EOL; 12.0-12.2 are rolling releases, not LTS, and Docker Hub
# stopped rebuilding 12.2 in May 2026.
#
# A version that has left support is mapped onto the LTS of its own
# series rather than refused: `-i 10.5` sits in Makefiles, READMEs
# and muscle memory across every consumer, and failing those calls
# buys nothing. The substitution is announced on every run, because
# a test that silently ran a different engine than it was asked for
# is worse than a broken call. To run the requested version anyway
# — reproducing a customer's bug on their engine — set
# DBMS_VERSION_EXACT=1, which skips both the mapping and the check.
if [ "${DBMS_VERSION_EXACT:-0}" != "1" ]; then
case "${DBMS_VERSION}" in
10.5|10.6) DBMS_VERSION_LTS="10.11" ;;
11.0|11.1|11.2|11.3) DBMS_VERSION_LTS="11.4" ;;
11.5|11.6|11.7) DBMS_VERSION_LTS="11.8" ;;
12.0|12.1|12.2) DBMS_VERSION_LTS="12.3" ;;
*) DBMS_VERSION_LTS="" ;;
esac
if [ -n "${DBMS_VERSION_LTS}" ]; then
echo "WARNING: MariaDB ${DBMS_VERSION} is out of support (EOL, or a rolling release)." >&2
echo " Running ${DBMS_VERSION_LTS} instead — the supported series it belongs to." >&2
echo " Set DBMS_VERSION_EXACT=1 to run ${DBMS_VERSION} anyway." >&2
DBMS_VERSION="${DBMS_VERSION_LTS}"
fi
if ! [[ ${DBMS_VERSION} =~ ^(10.11|11.4|11.8|12.3)$ ]]; then
echo "Invalid combination -d ${DBMS} -i ${DBMS_VERSION}" >&2
echo "Supported: 10.11, 11.4, 11.8, 12.3. Set DBMS_VERSION_EXACT=1 to run an unsupported version." >&2
exit 1
fi
fi
;;
mysql)
[ -z "${DATABASE_DRIVER}" ] && DATABASE_DRIVER="mysqli"
if [ "${DATABASE_DRIVER}" != "mysqli" ] && [ "${DATABASE_DRIVER}" != "pdo_mysql" ]; then
echo "Invalid combination -d ${DBMS} -a ${DATABASE_DRIVER}" >&2
exit 1
fi
[ -z "${DBMS_VERSION}" ] && DBMS_VERSION="8.0"
if ! [[ ${DBMS_VERSION} =~ ^(8.0|8.4|9.0)$ ]]; then
echo "Invalid combination -d ${DBMS} -i ${DBMS_VERSION}" >&2
exit 1
fi
;;
postgres)
if [ -n "${DATABASE_DRIVER}" ]; then
echo "Invalid combination -d ${DBMS} -a ${DATABASE_DRIVER}" >&2
exit 1
fi
[ -z "${DBMS_VERSION}" ] && DBMS_VERSION="16"
if ! [[ ${DBMS_VERSION} =~ ^(12|13|14|15|16|17)$ ]]; then
echo "Invalid combination -d ${DBMS} -i ${DBMS_VERSION}" >&2
exit 1
fi
;;
sqlite)
if [ -n "${DATABASE_DRIVER}" ]; then
echo "Invalid combination -d ${DBMS} -a ${DATABASE_DRIVER}" >&2
exit 1
fi
;;
*)
echo "Invalid option -d ${DBMS}" >&2
exit 1
;;
esac
}
loadHelp() {
read -r -d '' HELP <<EOF
TYPO3 Extension test runner. Execute tests in Docker containers.
Usage: $0 [options] [file]
Options:
-s <...>
Specifies which test suite to run
- cgl: PHP CS Fixer check/fix
- clean: Clean temporary files
- composer: Run composer commands
- composerUpdate: Update dependencies
- e2e: Playwright E2E tests (requires running TYPO3)
- functional: PHP functional tests
- functionalParallel: Parallel functional tests (faster)
- functionalCoverage: Functional tests with coverage
- lint: PHP linting
- phpstan: PHPStan static analysis
- unit: PHP unit tests (default)
- unitCoverage: Unit tests with coverage
- fuzz: Fuzz tests
- mutation: Mutation testing
-d <sqlite|mariadb|mysql|postgres>
Database for functional tests (default: sqlite)
-i version
Database version (mariadb: 11.8, mysql: 8.0, postgres: 16)
-p <8.2|8.3|8.4|8.5>
PHP version (default: 8.5)
-x
Enable Xdebug for debugging
-n
Dry-run mode (for cgl, rector)
-h
Show this help
Examples:
# Run unit tests
./Build/Scripts/runTests.sh -s unit
# Run functional tests with MariaDB
./Build/Scripts/runTests.sh -s functional -d mariadb
# Run E2E tests (uses PHP built-in server + MySQL container)
./Build/Scripts/runTests.sh -s e2e
E2E Tests:
E2E tests use a PHP built-in server + MySQL container.
Usage: ./Build/Scripts/runTests.sh -s e2e
Custom URL: TYPO3_BASE_URL=http://localhost:8080 ./Build/Scripts/runTests.sh -s e2e
EOF
}
# Check container runtime
if ! type "docker" >/dev/null 2>&1 && ! type "podman" >/dev/null 2>&1; then
echo "This script requires docker or podman." >&2
exit 1
fi
# Option defaults
TEST_SUITE="unit"
DATABASE_DRIVER=""
DBMS="sqlite"
DBMS_VERSION=""
PHP_VERSION="8.5"
PHP_XDEBUG_ON=0
PHP_XDEBUG_PORT=9003
CGLCHECK_DRY_RUN=0
CI_PARAMS="${CI_PARAMS:-}"
CONTAINER_BIN=""
CONTAINER_HOST="host.docker.internal"
# Parse options
OPTIND=1
while getopts "a:b:d:i:s:p:xy:nhu" OPT; do
case ${OPT} in
a) DATABASE_DRIVER=${OPTARG} ;;
s) TEST_SUITE=${OPTARG} ;;
b) CONTAINER_BIN=${OPTARG} ;;
d) DBMS=${OPTARG} ;;
i) DBMS_VERSION=${OPTARG} ;;
p) PHP_VERSION=${OPTARG} ;;
x) PHP_XDEBUG_ON=1 ;;
y) PHP_XDEBUG_PORT=${OPTARG} ;;
n) CGLCHECK_DRY_RUN=1 ;;
h) loadHelp; echo "${HELP}"; exit 0 ;;
u) TEST_SUITE=update ;;
\?) exit 1 ;;
esac
done
handleDbmsOptions
# CUSTOMIZE: Set your extension version
COMPOSER_ROOT_VERSION="1.x-dev"
HOST_UID=$(id -u)
USERSET=""
if [ $(uname) != "Darwin" ]; then
USERSET="--user $HOST_UID"
fi
# Navigate to project root
THIS_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)"
cd "$THIS_SCRIPT_DIR" || exit 1
cd ../../ || exit 1
ROOT_DIR="${PWD}"
# Create cache directories
mkdir -p .Build/.cache
mkdir -p .Build/web/typo3temp/var/tests
IMAGE_PREFIX="docker.io/"
TYPO3_IMAGE_PREFIX="ghcr.io/typo3/"
CONTAINER_INTERACTIVE="-it --init"
IS_CORE_CI=0
if [ "${CI}" == "true" ] || ! [ -t 0 ]; then
IS_CORE_CI=1
IMAGE_PREFIX=""
CONTAINER_INTERACTIVE=""
fi
# Determine container binary
if [[ -z "${CONTAINER_BIN}" ]]; then
if type "podman" >/dev/null 2>&1; then
CONTAINER_BIN="podman"
elif type "docker" >/dev/null 2>&1; then
CONTAINER_BIN="docker"
fi
fi
# Container images
IMAGE_PHP="${TYPO3_IMAGE_PREFIX}core-testing-$(echo "php${PHP_VERSION}" | sed -e 's/\.//'):latest"
IMAGE_ALPINE="${IMAGE_PREFIX}alpine:3.8"
IMAGE_MARIADB="docker.io/mariadb:${DBMS_VERSION}"
IMAGE_MYSQL="docker.io/mysql:${DBMS_VERSION}"
IMAGE_POSTGRES="docker.io/postgres:${DBMS_VERSION}-alpine"
IMAGE_PLAYWRIGHT="mcr.microsoft.com/playwright:v1.57.0-noble"
# Optional: Mock OAuth server for OAuth integration tests
# IMAGE_MOCK_OAUTH="ghcr.io/navikt/mock-oauth2-server:3.0.1"
shift $((OPTIND - 1))
# CUSTOMIZE: Replace 'my-extension' with your extension key
SUFFIX=$(echo $RANDOM)
NETWORK="my-extension-${SUFFIX}"
${CONTAINER_BIN} network create ${NETWORK} >/dev/null
if [ ${CONTAINER_BIN} = "docker" ]; then
CONTAINER_COMMON_PARAMS="${CONTAINER_INTERACTIVE} --rm --network ${NETWORK} --add-host "${CONTAINER_HOST}:host-gateway" ${USERSET} -v ${ROOT_DIR}:${ROOT_DIR} -w ${ROOT_DIR}"
else
CONTAINER_HOST="host.containers.internal"
CONTAINER_COMMON_PARAMS="${CONTAINER_INTERACTIVE} ${CI_PARAMS} --rm --network ${NETWORK} -v ${ROOT_DIR}:${ROOT_DIR} -w ${ROOT_DIR}"
fi
if [ ${PHP_XDEBUG_ON} -eq 0 ]; then
XDEBUG_MODE="-e XDEBUG_MODE=off"
XDEBUG_CONFIG=" "
else
XDEBUG_MODE="-e XDEBUG_MODE=debug -e XDEBUG_TRIGGER=foo"
XDEBUG_CONFIG="client_port=${PHP_XDEBUG_PORT} client_host=${CONTAINER_HOST}"
fi
# PHP performance options
PHP_OPCACHE_OPTS="-d opcache.enable_cli=1 -d opcache.jit=1255 -d opcache.jit_buffer_size=128M"
# Functional/e2e-backend suites run WITHOUT the JIT: the tracing JIT in the
# container PHP builds (reproduced on 8.3 and 8.5) can segfault silently
# during suite bootstrap for certain - perfectly valid - source shapes
# (t3x-nr-llm#351: adding a plain property+getter+setter to an entity
# flipped it). Identical runs with opcache.jit=off pass; functionalCoverage
# below already ran without the JIT. Functional tests are IO-bound, so the
# JIT buys nothing here anyway.
PHP_FUNCTIONAL_OPTS="-d opcache.enable_cli=1"
# Suite execution
case ${TEST_SUITE} in
cgl)
if [ "${CGLCHECK_DRY_RUN}" -eq 1 ]; then
COMMAND="php ${PHP_OPCACHE_OPTS} -dxdebug.mode=off .Build/bin/php-cs-fixer fix -v --dry-run --diff"
else
COMMAND="php ${PHP_OPCACHE_OPTS} -dxdebug.mode=off .Build/bin/php-cs-fixer fix -v"
fi
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name cgl-${SUFFIX} -e COMPOSER_CACHE_DIR=.Build/.cache/composer -e COMPOSER_ROOT_VERSION=${COMPOSER_ROOT_VERSION} ${IMAGE_PHP} /bin/sh -c "${COMMAND}"
SUITE_EXIT_CODE=$?
;;
clean)
cleanCacheFiles
;;
composer)
COMMAND=(composer "$@")
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name composer-${SUFFIX} -e COMPOSER_CACHE_DIR=.Build/.cache/composer -e COMPOSER_ROOT_VERSION=${COMPOSER_ROOT_VERSION} ${IMAGE_PHP} "${COMMAND[@]}"
SUITE_EXIT_CODE=$?
;;
composerUpdate)
rm -rf .Build/bin/ .Build/vendor ./composer.lock
COMMAND=(composer install --no-ansi --no-interaction --no-progress)
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name composer-${SUFFIX} -e COMPOSER_CACHE_DIR=.Build/.cache/composer -e COMPOSER_ROOT_VERSION=${COMPOSER_ROOT_VERSION} ${IMAGE_PHP} "${COMMAND[@]}"
SUITE_EXIT_CODE=$?
;;
e2e)
# E2E tests use a PHP built-in server + MySQL container (not DDEV)
TYPO3_BASE_URL="${TYPO3_BASE_URL:-http://localhost:8080}"
echo "E2E tests using TYPO3_BASE_URL: ${TYPO3_BASE_URL}"
mkdir -p .Build/.cache/npm
mkdir -p node_modules
# Check for permission issues (root-owned files from previous container runs)
if [ -d "node_modules" ] && [ "$(find node_modules -maxdepth 1 -user root 2>/dev/null | head -1)" ]; then
echo "Error: node_modules contains root-owned files."
echo "Please remove and retry: sudo rm -rf node_modules"
exit 1
fi
COMMAND="npm ci && npx playwright test $*"
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name e2e-${SUFFIX} \
-e TYPO3_BASE_URL="${TYPO3_BASE_URL}" \
-e CI="${CI:-}" \
-e npm_config_cache="${ROOT_DIR}/.Build/.cache/npm" \
${IMAGE_PLAYWRIGHT} /bin/bash -c "${COMMAND}"
SUITE_EXIT_CODE=$?
;;
functional)
COMMAND=(php ${PHP_FUNCTIONAL_OPTS} -dxdebug.mode=off .Build/bin/phpunit -c Tests/Build/FunctionalTests.xml --exclude-group not-${DBMS} "$@")
case ${DBMS} in
mariadb)
echo "Using driver: ${DATABASE_DRIVER}"
${CONTAINER_BIN} run --rm ${CI_PARAMS} --name mariadb-func-${SUFFIX} --network ${NETWORK} -d -e MYSQL_ROOT_PASSWORD=funcp --tmpfs /var/lib/mysql/:rw,noexec,nosuid ${IMAGE_MARIADB} >/dev/null
waitFor mariadb-func-${SUFFIX} 3306
CONTAINERPARAMS="-e typo3DatabaseDriver=${DATABASE_DRIVER} -e typo3DatabaseName=func_test -e typo3DatabaseUsername=root -e typo3DatabaseHost=mariadb-func-${SUFFIX} -e typo3DatabasePassword=funcp"
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name functional-${SUFFIX} ${XDEBUG_MODE} -e XDEBUG_CONFIG="${XDEBUG_CONFIG}" ${CONTAINERPARAMS} ${IMAGE_PHP} "${COMMAND[@]}"
SUITE_EXIT_CODE=$?
;;
mysql)
echo "Using driver: ${DATABASE_DRIVER}"
${CONTAINER_BIN} run --rm ${CI_PARAMS} --name mysql-func-${SUFFIX} --network ${NETWORK} -d -e MYSQL_ROOT_PASSWORD=funcp --tmpfs /var/lib/mysql/:rw,noexec,nosuid ${IMAGE_MYSQL} >/dev/null
waitFor mysql-func-${SUFFIX} 3306
CONTAINERPARAMS="-e typo3DatabaseDriver=${DATABASE_DRIVER} -e typo3DatabaseName=func_test -e typo3DatabaseUsername=root -e typo3DatabaseHost=mysql-func-${SUFFIX} -e typo3DatabasePassword=funcp"
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name functional-${SUFFIX} ${XDEBUG_MODE} -e XDEBUG_CONFIG="${XDEBUG_CONFIG}" ${CONTAINERPARAMS} ${IMAGE_PHP} "${COMMAND[@]}"
SUITE_EXIT_CODE=$?
;;
postgres)
${CONTAINER_BIN} run --rm ${CI_PARAMS} --name postgres-func-${SUFFIX} --network ${NETWORK} -d -e POSTGRES_PASSWORD=funcp -e POSTGRES_USER=funcu --tmpfs /var/lib/postgresql/data:rw,noexec,nosuid ${IMAGE_POSTGRES} >/dev/null
waitFor postgres-func-${SUFFIX} 5432
CONTAINERPARAMS="-e typo3DatabaseDriver=pdo_pgsql -e typo3DatabaseName=bamboo -e typo3DatabaseUsername=funcu -e typo3DatabaseHost=postgres-func-${SUFFIX} -e typo3DatabasePassword=funcp"
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name functional-${SUFFIX} ${XDEBUG_MODE} -e XDEBUG_CONFIG="${XDEBUG_CONFIG}" ${CONTAINERPARAMS} ${IMAGE_PHP} "${COMMAND[@]}"
SUITE_EXIT_CODE=$?
;;
sqlite)
mkdir -p "${ROOT_DIR}/.Build/web/typo3temp/var/tests/functional-sqlite-dbs/"
CONTAINERPARAMS="-e typo3DatabaseDriver=pdo_sqlite --tmpfs ${ROOT_DIR}/.Build/web/typo3temp/var/tests/functional-sqlite-dbs/:rw,noexec,nosuid"
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name functional-${SUFFIX} ${XDEBUG_MODE} -e XDEBUG_CONFIG="${XDEBUG_CONFIG}" ${CONTAINERPARAMS} ${IMAGE_PHP} "${COMMAND[@]}"
SUITE_EXIT_CODE=$?
;;
esac
;;
functionalParallel)
# Parallel functional tests using xargs
# Each test file runs in isolation with its own SQLite database
mkdir -p "${ROOT_DIR}/.Build/web/typo3temp/var/tests/functional-sqlite-dbs/"
# CI: fixed jobs for predictable resource usage
# Local: half of available CPUs
if [ "${CI}" == "true" ]; then
PARALLEL_JOBS=4
else
PARALLEL_JOBS="\$(((\$(nproc) + 1) / 2))"
fi
COMMAND="find Tests/Functional -name '*Test.php' | xargs -P${PARALLEL_JOBS} -I{} php ${PHP_FUNCTIONAL_OPTS} -dxdebug.mode=off .Build/bin/phpunit -c Tests/Build/FunctionalTests.xml {}"
CONTAINERPARAMS="-e typo3DatabaseDriver=pdo_sqlite --tmpfs ${ROOT_DIR}/.Build/web/typo3temp/var/tests/functional-sqlite-dbs/:rw,noexec,nosuid"
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name functional-parallel-${SUFFIX} ${XDEBUG_MODE} -e XDEBUG_CONFIG="${XDEBUG_CONFIG}" ${CONTAINERPARAMS} ${IMAGE_PHP} /bin/sh -c "${COMMAND}"
SUITE_EXIT_CODE=$?
;;
functionalCoverage)
mkdir -p .Build/coverage
COMMAND=(php -d opcache.enable_cli=1 .Build/bin/phpunit -c Tests/Build/FunctionalTests.xml --coverage-clover=.Build/coverage/functional.xml --coverage-html=.Build/coverage/html-functional --coverage-text "$@")
mkdir -p "${ROOT_DIR}/.Build/web/typo3temp/var/tests/functional-sqlite-dbs/"
CONTAINERPARAMS="-e typo3DatabaseDriver=pdo_sqlite --tmpfs ${ROOT_DIR}/.Build/web/typo3temp/var/tests/functional-sqlite-dbs/:rw,noexec,nosuid"
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name functional-coverage-${SUFFIX} -e XDEBUG_MODE=coverage ${CONTAINERPARAMS} ${IMAGE_PHP} "${COMMAND[@]}"
SUITE_EXIT_CODE=$?
;;
lint)
COMMAND="find . -name \\*.php ! -path \"./.Build/\\*\" -print0 | xargs -0 -n1 -P\$(nproc) php ${PHP_OPCACHE_OPTS} -dxdebug.mode=off -l >/dev/null"
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name lint-${SUFFIX} ${IMAGE_PHP} /bin/sh -c "${COMMAND}"
SUITE_EXIT_CODE=$?
;;
phpstan)
COMMAND="php ${PHP_OPCACHE_OPTS} -dxdebug.mode=off .Build/bin/phpstan analyse"
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name phpstan-${SUFFIX} -e COMPOSER_ROOT_VERSION=${COMPOSER_ROOT_VERSION} ${IMAGE_PHP} /bin/sh -c "${COMMAND}"
SUITE_EXIT_CODE=$?
;;
unit)
COMMAND=(php ${PHP_OPCACHE_OPTS} -dxdebug.mode=off .Build/bin/phpunit -c Tests/Build/phpunit.xml --testsuite Unit "$@")
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name unit-${SUFFIX} ${XDEBUG_MODE} -e XDEBUG_CONFIG="${XDEBUG_CONFIG}" ${IMAGE_PHP} "${COMMAND[@]}"
SUITE_EXIT_CODE=$?
;;
unitCoverage)
mkdir -p .Build/coverage
COMMAND=(php -d opcache.enable_cli=1 .Build/bin/phpunit -c Tests/Build/phpunit.xml --testsuite Unit --coverage-clover=.Build/coverage/unit.xml --coverage-html=.Build/coverage/html-unit --coverage-text "$@")
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name unit-coverage-${SUFFIX} -e XDEBUG_MODE=coverage ${IMAGE_PHP} "${COMMAND[@]}"
SUITE_EXIT_CODE=$?
;;
fuzz)
COMMAND=(php ${PHP_OPCACHE_OPTS} -dxdebug.mode=off .Build/bin/phpunit -c Tests/Build/phpunit.xml --testsuite Fuzz "$@")
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name fuzz-${SUFFIX} ${XDEBUG_MODE} -e XDEBUG_CONFIG="${XDEBUG_CONFIG}" ${IMAGE_PHP} "${COMMAND[@]}"
SUITE_EXIT_CODE=$?
;;
mutation)
COMMAND=(php -d opcache.enable_cli=1 .Build/bin/infection --configuration=infection.json5 --threads=4 "$@")
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name mutation-${SUFFIX} -e XDEBUG_MODE=coverage ${IMAGE_PHP} "${COMMAND[@]}"
SUITE_EXIT_CODE=$?
;;
update)
echo "> Updating ${TYPO3_IMAGE_PREFIX}core-testing-* images..."
${CONTAINER_BIN} images "${TYPO3_IMAGE_PREFIX}core-testing-*" --format "{{.Repository}}:{{.Tag}}" | xargs -I {} ${CONTAINER_BIN} pull {}
;;
*)
loadHelp
echo "Invalid -s option: ${TEST_SUITE}" >&2
echo "${HELP}" >&2
exit 1
;;
esac
cleanUp
# Print summary
echo "" >&2
echo "###########################################################################" >&2
echo "Result of ${TEST_SUITE}" >&2
echo "Container runtime: ${CONTAINER_BIN}" >&2
if [[ ${IS_CORE_CI} -eq 1 ]]; then
echo "Environment: CI" >&2
else
echo "Environment: local" >&2
fi
echo "PHP: ${PHP_VERSION}" >&2
if [[ ${TEST_SUITE} =~ ^functional ]]; then
echo "DBMS: ${DBMS}" >&2
fi
if [[ ${SUITE_EXIT_CODE} -eq 0 ]]; then
echo "SUCCESS" >&2
else
echo "FAILURE" >&2
fi
echo "###########################################################################" >&2
echo "" >&2
exit $SUITE_EXIT_CODE
assets/codecov.yml
# Codecov Configuration for TYPO3 Extensions
#
# Place this file in repository root as 'codecov.yml'
# @see https://docs.codecov.com/docs/codecov-yaml
coverage:
# Precision of coverage percentage (decimal places)
precision: 2
# Rounding method: down, up, nearest
round: down
# Coverage range for color coding (red...green)
range: "60...100"
status:
# Project-level coverage status
project:
default:
# Target coverage (auto = maintain current level)
target: auto
# Acceptable drop from target
threshold: 5%
# Only check files changed in the PR
# only_pulls: true
# Patch-level coverage (new/changed code)
patch:
default:
# New code should have higher coverage
target: 80%
threshold: 5%
# PR comment configuration
comment:
# Layout components: reach, diff, flags, files, footer
layout: "reach,diff,flags,files"
# Comment behavior: default, once, new, spammed
behavior: default
# Only comment if coverage changes
require_changes: true
# Show critical files section
# show_critical_paths: true
# Coverage flags for separating test types
flags:
unittests:
paths:
- Classes/
carryforward: true
functionaltests:
paths:
- Classes/
carryforward: true
# Files to ignore in coverage reports
ignore:
- "Tests/**/*"
- ".Build/**/*"
- ".ddev/**/*"
- "Build/**/*"
- "Documentation/**/*"
- "Resources/**/*"
- "ext_emconf.php"
- "ext_localconf.php"
- "ext_tables.php"
# Require CI to pass before posting status
# ci:
# - "Tests / Unit Tests"
# - "Tests / Functional Tests"
assets/docker/codeception.yml
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
suites:
acceptance:
actor: AcceptanceTester
path: .
modules:
enabled:
- WebDriver:
url: http://web:8000
browser: chrome
host: selenium
port: 4444
wait: 2
window_size: 1920x1080
capabilities:
chromeOptions:
args: ["--no-sandbox", "--disable-dev-shm-usage"]
- \\Helper\\Acceptance
config:
WebDriver:
browser: '%BROWSER%'
settings:
shuffle: false
lint: true
colors: true
memory_limit: 1024M
assets/docker/docker-compose.yml
services:
web:
image: php:8.4-apache
container_name: typo3-test-web
volumes:
- ../../../:/var/www/html
ports:
- "8000:80"
environment:
- TYPO3_CONTEXT=Testing
- typo3DatabaseDriver=mysqli
- typo3DatabaseHost=db
- typo3DatabaseName=typo3_test
- typo3DatabaseUsername=typo3
- typo3DatabasePassword=typo3
depends_on:
db:
condition: service_healthy
networks:
- typo3-test
db:
image: mysql:8.0
container_name: typo3-test-db
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: typo3_test
MYSQL_USER: typo3
MYSQL_PASSWORD: typo3
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
networks:
- typo3-test
# Playwright container for E2E testing
# Alternative: Run Playwright locally with `npm run playwright:run`
playwright:
image: mcr.microsoft.com/playwright:v1.56.1-noble
container_name: typo3-test-playwright
volumes:
- ../../../:/var/www/html
working_dir: /var/www/html/Build
environment:
- PLAYWRIGHT_BASE_URL=http://web:80/typo3/
- PLAYWRIGHT_ADMIN_USERNAME=admin
- PLAYWRIGHT_ADMIN_PASSWORD=password
depends_on:
- web
networks:
- typo3-test
networks:
typo3-test:
driver: bridge
assets/example-tests/ExampleAcceptanceCest.php
<?php
declare(strict_types=1);
namespace Vendor\Extension\Tests\Acceptance;
use Vendor\Extension\Tests\Acceptance\AcceptanceTester;
/**
* Example acceptance test demonstrating TYPO3 testing patterns
*
* Acceptance tests use a real browser to test complete user workflows.
* They verify frontend functionality and user interactions.
*/
final class LoginCest
{
public function _before(AcceptanceTester $I): void
{
// Runs before each test method
// Setup: Import fixtures, reset state, etc.
}
public function loginAsBackendUser(AcceptanceTester $I): void
{
// Navigate to login page
$I->amOnPage('/typo3');
// Fill login form
$I->fillField('username', 'admin');
$I->fillField('password', 'password');
// Submit form
$I->click('Login');
// Verify successful login
$I->see('Dashboard');
$I->seeInCurrentUrl('/typo3/module/dashboard');
}
public function loginFailsWithInvalidCredentials(AcceptanceTester $I): void
{
$I->amOnPage('/typo3');
$I->fillField('username', 'admin');
$I->fillField('password', 'wrong_password');
$I->click('Login');
// Verify login failed
$I->see('Login error');
$I->seeInCurrentUrl('/typo3');
}
public function searchesForProducts(AcceptanceTester $I): void
{
// Navigate to product listing
$I->amOnPage('/products');
// Wait for page to load
$I->waitForElement('.product-list', 5);
// Use search
$I->fillField('#search', 'laptop');
$I->click('Search');
// Wait for results
$I->waitForElement('.search-results', 5);
// Verify search results
$I->see('laptop', '.product-title');
$I->seeNumberOfElements('.product-item', [1, 10]);
}
public function addsProductToCart(AcceptanceTester $I): void
{
$I->amOnPage('/products/1');
// Click add to cart button
$I->click('#add-to-cart');
// Wait for AJAX response
$I->waitForElement('.cart-badge', 3);
// Verify cart updated
$I->see('1', '.cart-badge');
$I->see('Product added to cart');
}
}
assets/example-tests/ExampleFunctionalTest.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;
/**
* Example functional test demonstrating TYPO3 testing patterns
*
* Functional tests use a real database and full TYPO3 instance.
* They test repositories, controllers, and integration scenarios.
*/
final class ProductRepositoryTest extends FunctionalTestCase
{
protected ProductRepository $subject;
/**
* Extensions to load for this test
*/
protected array $testExtensionsToLoad = [
'typo3conf/ext/my_extension',
];
protected function setUp(): void
{
parent::setUp();
// Get repository from dependency injection container
$this->subject = $this->get(ProductRepository::class);
}
/**
* @test
*/
public function findsProductsByCategory(): void
{
// Import test data from CSV fixture
$this->importCSVDataSet(__DIR__ . '/../Fixtures/Products.csv');
// Execute repository method
$products = $this->subject->findByCategory(1);
// Assert results
self::assertCount(3, $products);
self::assertInstanceOf(Product::class, $products[0]);
}
/**
* @test
*/
public function findsVisibleProductsOnly(): void
{
$this->importCSVDataSet(__DIR__ . '/../Fixtures/ProductsWithHidden.csv');
$products = $this->subject->findAll();
// Only visible products should be returned
self::assertCount(2, $products);
foreach ($products as $product) {
self::assertFalse($product->isHidden());
}
}
/**
* @test
*/
public function persistsNewProduct(): void
{
$this->importCSVDataSet(__DIR__ . '/../Fixtures/Pages.csv');
$product = new Product();
$product->setTitle('New Product');
$product->setPrice(19.99);
$product->setPid(1);
$this->subject->add($product);
// Persist to database
$this->persistenceManager->persistAll();
// Verify product was saved
$savedProducts = $this->subject->findAll();
self::assertCount(1, $savedProducts);
self::assertSame('New Product', $savedProducts[0]->getTitle());
}
}
assets/example-tests/ExampleUnitTest.php
<?php
declare(strict_types=1);
namespace Vendor\Extension\Tests\Unit\Domain\Validator;
use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
use Vendor\Extension\Domain\Validator\EmailValidator;
/**
* Example unit test demonstrating TYPO3 testing patterns
*
* Unit tests are fast, isolated tests without external dependencies.
* They test individual components (validators, utilities, domain logic).
*/
final class EmailValidatorTest extends UnitTestCase
{
protected 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());
}
/**
* @test
* @dataProvider invalidEmailProvider
*/
public function rejectsInvalidEmails(string $email): void
{
$result = $this->subject->validate($email);
self::assertTrue($result->hasErrors(), "Email '$email' should be invalid");
}
public static function invalidEmailProvider(): array
{
return [
'missing @' => ['userexample.com'],
'missing domain' => ['user@'],
'empty string' => [''],
'spaces' => ['user @example.com'],
];
}
}
assets/fixtures/be_users.csv
"uid","pid","username","password","admin","tstamp","crdate","deleted","disable"
1,0,"admin","$argon2i$v=19$m=65536,t=16,p=1$WE5KdXN3Vmw4U0lMSGVMWA$Y8+SBi+43VzKMFVVdoH5lyoNIOk05q8j9Q1NxnpBFVU",1,1700000000,1700000000,0,0
2,0,"editor","$argon2i$v=19$m=65536,t=16,p=1$WE5KdXN3Vmw4U0lMSGVMWA$Y8+SBi+43VzKMFVVdoH5lyoNIOk05q8j9Q1NxnpBFVU",0,1700000000,1700000000,0,0
assets/fixtures/pages.csv
"uid","pid","title","slug","doktype","hidden","deleted","sorting","tstamp","crdate"
1,0,"Root Page","/",1,0,0,256,1700000000,1700000000
2,1,"Test Page","/test-page",1,0,0,256,1700000000,1700000000
3,1,"Hidden Page","/hidden",1,1,0,512,1700000000,1700000000
assets/fixtures/README.md
# CSV Fixture Templates
Example CSV fixtures for TYPO3 functional tests.
## Usage
Place CSV fixtures in your test directory:
```
Tests/Functional/
├── Fixtures/
│ ├── be_users.csv
│ ├── pages.csv
│ └── tt_content.csv
└── Repository/
└── MyRepositoryTest.php
```
Import fixtures in your test:
```php
protected function setUp(): void
{
parent::setUp();
$this->importCSVDataSet(__DIR__ . '/Fixtures/be_users.csv');
$this->importCSVDataSet(__DIR__ . '/Fixtures/pages.csv');
}
```
## CSV Format Rules
1. **Header row is required** - Column names must match database field names
2. **Quote all values** - Use double quotes around all values
3. **Include required fields** - `uid`, `pid`, timestamps (`tstamp`, `crdate`)
4. **Use consistent timestamps** - `1700000000` is Nov 14, 2023 (arbitrary but consistent)
## Common Fixtures
| File | Description |
|------|-------------|
| `be_users.csv` | Backend users (admin, editor) |
| `pages.csv` | Page tree structure |
| `tt_content.csv` | Content elements |
| `sys_category.csv` | Categories with hierarchy |
## Password Hashes
The default password hash in `be_users.csv` is for the password `password`.
To generate a new hash:
```php
$hashFactory = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory::class);
$hash = $hashFactory->getDefaultHashInstance('BE')->getHashedPassword('your-password');
```
## Tips
- **Minimal data** - Only include fields needed for your test
- **Explicit UIDs** - Always set explicit UIDs for reliable references
- **Isolation** - Each test class should have its own fixture set
- **Reset** - Functional tests reset the database between tests automatically
## Extension-Specific Fixtures
For custom tables, create CSV matching your table structure:
```csv
"uid","pid","title","custom_field","tstamp","crdate"
1,0,"Record 1","value1",1700000000,1700000000
2,0,"Record 2","value2",1700000000,1700000000
```
Ensure the table is imported in your extension's `ext_tables.sql`.
assets/fixtures/sys_category.csv
"uid","pid","title","parent","sorting","hidden","deleted","tstamp","crdate"
1,0,"Category 1",0,256,0,0,1700000000,1700000000
2,0,"Category 2",0,512,0,0,1700000000,1700000000
3,0,"Subcategory 1.1",1,256,0,0,1700000000,1700000000
assets/fixtures/tt_content.csv
"uid","pid","CType","header","bodytext","colPos","sorting","hidden","deleted","tstamp","crdate"
1,2,"text","Test Header","<p>Test content paragraph</p>",0,256,0,0,1700000000,1700000000
2,2,"textmedia","Media Header","<p>Content with media</p>",0,512,0,0,1700000000,1700000000
3,2,"list","Plugin Header","",0,768,0,0,1700000000,1700000000
assets/FunctionalTests.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../vendor/phpunit/phpunit.xsd"
bootstrap="FunctionalTestsBootstrap.php"
cacheResult="false"
beStrictAboutTestsThatDoNotTestAnything="true"
beStrictAboutOutputDuringTests="true"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true"
failOnRisky="true"
colors="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" force="true"/>
<env name="typo3DatabaseHost" value="localhost" force="true"/>
<env name="typo3DatabasePort" value="3306" force="true"/>
<env name="typo3DatabaseName" value="typo3_test" force="true"/>
<env name="typo3DatabaseUsername" value="root" force="true"/>
<env name="typo3DatabasePassword" value="" force="true"/>
</php>
<coverage>
<report>
<clover outputFile="../../var/log/coverage/clover.xml"/>
<html outputDirectory="../../var/log/coverage/html"/>
<text outputFile="php://stdout" showOnlySummary="true"/>
</report>
</coverage>
</phpunit>
assets/FunctionalTestsBootstrap.php
<?php
declare(strict_types=1);
/**
* Bootstrap for TYPO3 Extension Functional Tests
*
* Place this file at Tests/Functional/Bootstrap.php
* Reference in Build/phpunit/FunctionalTests.xml bootstrap attribute.
*
* This bootstrap initializes the TYPO3 testing framework for functional tests.
* It creates necessary directories and prepares the test environment.
*/
call_user_func(static function (): void {
// Locate TYPO3 testing framework
$testbaseClass = 'TYPO3\\TestingFramework\\Core\\Testbase';
if (!class_exists($testbaseClass)) {
// Try to load via composer autoload
$autoloadLocations = [
dirname(__DIR__, 2) . '/.Build/vendor/autoload.php',
dirname(__DIR__, 4) . '/vendor/autoload.php',
];
foreach ($autoloadLocations as $location) {
if (file_exists($location)) {
require_once $location;
break;
}
}
}
if (!class_exists($testbaseClass)) {
throw new RuntimeException(
'TYPO3 TestingFramework not found. Run "composer require --dev typo3/testing-framework".'
);
}
$testbase = new \TYPO3\TestingFramework\Core\Testbase();
// Define original root path (extension root)
$testbase->defineOriginalRootPath();
// Create necessary directories for test execution
$testbase->createDirectory(ORIGINAL_ROOT . 'typo3temp/var/tests');
$testbase->createDirectory(ORIGINAL_ROOT . 'typo3temp/var/transient');
// Optional: Set default timezone
date_default_timezone_set('UTC');
});
assets/github-actions-e2e.yml
# GitHub Actions E2E Workflow for TYPO3 Extensions
#
# Place this file in .github/workflows/e2e.yml
#
# This workflow uses GitHub Services (MariaDB) + PHP built-in server
# for fast, reliable E2E testing with Playwright.
#
# IMPORTANT: Do NOT use DDEV in CI - it's too slow and complex.
# DDEV is for LOCAL development only.
#
# PREREQUISITES:
# - Playwright tests in Tests/E2E/Playwright/ or Build/tests/playwright/
# - package.json with Playwright dependencies
# - composer.json with TYPO3 dependencies
name: E2E Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
# Manual trigger for expensive E2E tests
workflow_dispatch:
# Weekly scheduled run (optional)
schedule:
- cron: '0 2 * * 0'
# Prevent concurrent E2E runs on same branch
concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
e2e:
name: E2E Tests (Playwright)
runs-on: ubuntu-latest
timeout-minutes: 20
# ==========================================================================
# GitHub Services: Use MariaDB instead of DDEV
# This is faster, simpler, and more reliable than DDEV in CI
# ==========================================================================
services:
db:
image: mariadb:11.4
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: typo3
MYSQL_CHARSET: utf8mb4
MYSQL_COLLATION: utf8mb4_unicode_ci
ports:
- 3306:3306
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2
with:
# Match your composer.json PHP requirement
php-version: '8.4'
extensions: mysqli, pdo_mysql, gd, intl, curl, zip
coverage: none
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}
restore-keys: ${{ runner.os }}-composer-
- name: Install Composer dependencies
run: composer install --prefer-dist --no-progress
# ========================================================================
# TYPO3 Setup: Create config and bootstrap database
# ========================================================================
- name: Setup TYPO3
run: |
# Create necessary directories
mkdir -p .Build/Web/typo3conf
mkdir -p .Build/Web/typo3temp/var/cache
mkdir -p .Build/Web/typo3temp/var/log
mkdir -p .Build/Web/fileadmin
# Create LocalConfiguration.php with MySQL connection
cat > .Build/Web/typo3conf/LocalConfiguration.php << 'EOF'
<?php
return [
'BE' => [
'debug' => true,
// Password: 'password' (test-only, for CI debugging)
'installToolPassword' => '$argon2i$v=19$m=65536,t=16,p=1$M3QuMy5OdGlXTkxmTy56Zg$3A4Exo3BxTgTjLSaR4xaoIgd3gfWBPjXfYu7NdnVmzU',
'passwordHashing' => [
'className' => \TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2iPasswordHash::class,
'options' => [],
],
],
'DB' => [
'Connections' => [
'Default' => [
'charset' => 'utf8mb4',
'driver' => 'mysqli',
'host' => '127.0.0.1',
'port' => 3306,
'dbname' => 'typo3',
'user' => 'root',
'password' => 'root',
],
],
],
'FE' => [
'debug' => true,
],
'SYS' => [
'devIPmask' => '*',
'displayErrors' => 1,
// Test encryption key (64 hex chars, not a real secret)
'encryptionKey' => '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
'exceptionalErrors' => 4096,
'sitename' => 'E2E Tests',
'trustedHostsPattern' => 'localhost|127\\.0\\.0\\.1',
],
];
EOF
# Wait for database to be ready (extra safety beyond health check)
for i in {1..30}; do
if mysqladmin ping -h127.0.0.1 -uroot -proot --silent 2>/dev/null; then
echo "Database is ready."
break
fi
echo "Waiting for database... (attempt $i/30)"
sleep 2
done
# Setup database schema
.Build/bin/typo3 extension:setup --no-interaction
# Create admin user (password: 'Joh316!!' - test-only)
.Build/bin/typo3 backend:user:create --username=admin --password='Joh316!!' --admin --no-interaction
# Flush caches
.Build/bin/typo3 cache:flush
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '20' # Use LTS version
cache: 'npm'
- name: Install npm dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
# ========================================================================
# Start PHP built-in server (NOT DDEV)
# ========================================================================
- name: Start PHP server
run: |
php -S 0.0.0.0:8080 -t .Build/Web > /tmp/php-server.log 2>&1 &
echo $! > /tmp/php-server.pid
# Wait for server to become ready (up to 30 seconds)
for i in $(seq 1 30); do
if curl -sf http://localhost:8080/typo3/ > /dev/null 2>&1; then
echo "PHP server is up (after ${i}s)."
break
fi
sleep 1
done
# Verify server is running
if ! curl -sf http://localhost:8080/typo3/ > /dev/null 2>&1; then
echo "PHP server failed to start. Log:"
cat /tmp/php-server.log
exit 1
fi
- name: Run Playwright tests
env:
# CI uses localhost, NOT DDEV URL
TYPO3_BASE_URL: http://localhost:8080
run: npm run test:e2e
- name: Upload test results
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
name: playwright-report
path: |
Tests/E2E/Playwright/reports/
Tests/E2E/Playwright/test-results/
retention-days: 7
- name: Upload PHP server logs
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: failure()
with:
name: php-server-logs
path: /tmp/php-server.log
retention-days: 3
- name: Stop PHP server
if: always()
run: |
if [ -f /tmp/php-server.pid ]; then
kill $(cat /tmp/php-server.pid) 2>/dev/null || true
fi
# =============================================================================
# WHY NOT DDEV IN CI?
# =============================================================================
#
# DDEV should NOT be used in CI for these reasons:
#
# 1. SLOW STARTUP (2-3+ minutes)
# - Docker image pulls
# - Container orchestration
# - Network setup
# - Service health checks
#
# 2. COMPLEXITY
# - Docker-in-Docker or privileged mode required
# - Networking between host and containers
# - Volume mounting overhead
#
# 3. RESOURCE HEAVY
# - Multiple containers (web, db, router)
# - Not suited for GitHub Actions runners
#
# 4. FRAGILE
# - Many moving parts that can fail
# - Port conflicts, DNS issues, certificate problems
#
# 5. NON-STANDARD
# - TYPO3 Core and community use direct PHP or testing containers
# - Not how the TYPO3 community does CI
#
# DDEV is excellent for LOCAL DEVELOPMENT:
# - Consistent environment across team
# - Easy multi-version testing
# - Full-stack with services (Redis, Elasticsearch, etc.)
#
# But for CI, use GitHub Services + PHP built-in server.
# =============================================================================
assets/github-actions-tests.yml
# GitHub Actions Workflow for TYPO3 Extension Testing
#
# Place this file in .github/workflows/tests.yml
#
# CUSTOMIZATION REQUIRED:
# - Update PHP version matrix based on your minimum requirements
# - Update TYPO3 version matrix based on supported versions
# - Adjust codecov token secret name if needed
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
# ==========================================================================
# Code Quality Jobs
# ==========================================================================
lint:
name: PHP Lint
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4']
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2
with:
php-version: ${{ matrix.php }}
tools: composer:v2
- name: Run PHP linting
run: Build/Scripts/runTests.sh -p ${{ matrix.php }} -s lint
code-style:
name: Code Style (PHP-CS-Fixer)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2
with:
php-version: '8.3'
tools: composer:v2
- name: Check code style
run: Build/Scripts/runTests.sh -s cgl -n
phpstan:
name: PHPStan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2
with:
php-version: '8.3'
tools: composer:v2
- name: Run PHPStan
run: Build/Scripts/runTests.sh -s phpstan
rector:
name: Rector (dry-run)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2
with:
php-version: '8.3'
tools: composer:v2
- name: Run Rector dry-run
run: Build/Scripts/runTests.sh -s rector -n
# ==========================================================================
# Unit Tests
# ==========================================================================
unit-tests:
name: Unit Tests (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4']
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2
with:
php-version: ${{ matrix.php }}
coverage: xdebug
tools: composer:v2
- name: Run unit tests with coverage
run: Build/Scripts/runTests.sh -p ${{ matrix.php }} -s unit -x
- name: Upload coverage to Codecov
if: matrix.php == '8.3'
uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: .Build/coverage/clover.xml
flags: unittests
fail_ci_if_error: false
verbose: true
# ==========================================================================
# Functional Tests
# ==========================================================================
functional-tests:
name: Functional (PHP ${{ matrix.php }}, TYPO3 ${{ matrix.typo3 }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# TYPO3 v12 LTS
- php: '8.2'
typo3: '12'
- php: '8.3'
typo3: '12'
# TYPO3 v13 LTS
- php: '8.3'
typo3: '13'
- php: '8.4'
typo3: '13'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2
with:
php-version: ${{ matrix.php }}
extensions: pdo_sqlite
tools: composer:v2
- name: Run functional tests (SQLite)
run: Build/Scripts/runTests.sh -p ${{ matrix.php }} -s functional -d sqlite
# ==========================================================================
# Architecture Tests (Optional)
# ==========================================================================
# architecture-tests:
# name: Architecture Tests (PHPat)
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
#
# - name: Setup PHP
# uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2
# with:
# php-version: '8.3'
# tools: composer:v2
#
# - name: Run architecture tests
# run: Build/Scripts/runTests.sh -s architecture
# ==========================================================================
# Mutation Testing (Optional - runs on schedule or manual trigger)
# ==========================================================================
# mutation-tests:
# name: Mutation Testing
# runs-on: ubuntu-latest
# # Only run on main branch or manual trigger
# if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
# steps:
# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
#
# - name: Setup PHP
# uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2
# with:
# php-version: '8.3'
# coverage: pcov
# tools: composer:v2
#
# - name: Run mutation tests
# run: Build/Scripts/runTests.sh -s mutation
#
# - name: Upload mutation report
# uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
# with:
# name: mutation-report
# path: .Build/infection/
# retention-days: 7
assets/infection.json5
{
// Infection PHP Configuration for TYPO3 Extensions
//
// Run: Build/Scripts/runTests.sh -s mutation
//
// CUSTOMIZATION REQUIRED:
// - Adjust 'excludes' for your extension structure
// - Update 'minMsi' and 'minCoveredMsi' thresholds as needed
"$schema": "https://raw.githubusercontent.com/infection/infection/0.29.0/resources/schema.json",
// Source code directories
"source": {
"directories": [
"Classes"
],
"excludes": [
// Commonly excluded directories
"Exception",
"DependencyInjection",
// Add extension-specific exclusions:
// "Controller", // If controllers are thin wrappers
// "ViewHelpers", // If viewhelpers are simple
]
},
// Output logs
"logs": {
"text": ".Build/infection/infection.log",
"html": ".Build/infection/infection.html",
"json": ".Build/infection/infection.json",
// GitHub Actions annotation format (optional)
// "github": true,
// Badge generation (optional)
// "badge": { "branch": "main" }
},
// Temporary directory for mutation testing
"tmpDir": ".Build/infection/tmp",
// PHPUnit configuration
"phpUnit": {
"configDir": "Build/phpunit",
"customPath": ".Build/bin/phpunit"
},
// Test framework settings
"testFramework": "phpunit",
"testFrameworkOptions": "--testsuite=Unit",
// Mutators configuration
// @see https://infection.github.io/guide/mutators.html
"mutators": {
"@default": true,
// Disable problematic mutators
"CastString": false, // Often produces equivalent mutations
"UnwrapArrayMerge": false, // Can break array operations
"UnwrapArrayReplace": false, // Can break array operations
// Consider disabling for specific patterns:
// "MethodCallRemoval": false, // If you have logging calls
// "LogicalAnd": false, // If complex boolean logic
},
// Mutation Score Indicator thresholds
// MSI = Killed / Total mutations
// Covered MSI = Killed / Mutations covered by tests
"minMsi": 70,
"minCoveredMsi": 80,
// Performance settings
"timeout": 10,
"threads": 4
}
assets/Makefile
# TYPO3 Extension Makefile
# Docker-based testing following TYPO3 core conventions
# Use runTests.sh for CI-compatible containerized test execution
#
# CUSTOMIZATION: Update extension name in help text
.PHONY: help all check test unit functional e2e fuzz mutation lint phpstan cs fix rector docs clean update
.DEFAULT_GOAL := help
RUNTESTS = Build/Scripts/runTests.sh
help:
@echo "TYPO3 Extension Development Commands"
@echo ""
@echo " Quick Start:"
@echo " make all Run EVERYTHING (checks + tests + mutation)"
@echo " make check Run ALL quality checks (lint, cs, phpstan)"
@echo " make test Run ALL tests (unit, functional, e2e, fuzz)"
@echo ""
@echo " Individual Tests:"
@echo " make unit Run unit tests"
@echo " make functional Run functional tests (SQLite)"
@echo " make e2e Run E2E tests (Playwright, requires DDEV)"
@echo " make fuzz Run fuzz tests"
@echo " make mutation Run mutation tests (slow)"
@echo ""
@echo " Individual Checks:"
@echo " make lint Check PHP syntax"
@echo " make phpstan Run static analysis"
@echo " make cs Check code style"
@echo ""
@echo " Fixes:"
@echo " make fix Fix code style"
@echo " make rector Apply Rector rules"
@echo ""
@echo " Other:"
@echo " make docs Render documentation"
@echo " make clean Remove build artifacts"
@echo " make update Update Docker images"
# === Main Targets ===
all: check test mutation
@echo ""
@echo "=== ALL CHECKS, TESTS AND MUTATION PASSED ==="
check: lint cs phpstan
@echo ""
@echo "=== ALL QUALITY CHECKS PASSED ==="
test: unit functional fuzz
@echo ""
@echo "=== ALL TESTS PASSED ==="
# === Individual Tests ===
unit:
$(RUNTESTS) -s unit
functional:
$(RUNTESTS) -s functional
# For faster parallel functional tests (SQLite only)
functional-fast:
$(RUNTESTS) -s functionalParallel
e2e:
$(RUNTESTS) -s e2e
fuzz:
$(RUNTESTS) -s fuzz
mutation:
$(RUNTESTS) -s mutation
# === Individual Checks ===
lint:
$(RUNTESTS) -s lint
phpstan:
$(RUNTESTS) -s phpstan
cs:
$(RUNTESTS) -s cgl -n
# === Fixes ===
fix:
$(RUNTESTS) -s cgl
rector:
$(RUNTESTS) -s rector
# === Documentation ===
docs:
$(RUNTESTS) -s renderDocumentation
# === Maintenance ===
clean:
$(RUNTESTS) -s clean
update:
$(RUNTESTS) -u
assets/phpat.neon
# PHPat Architecture Rules Configuration
#
# Links the ArchitectureTest class to PHPStan.
# The actual rules are defined in Tests/Architecture/ArchitectureTest.php
#
# CUSTOMIZATION REQUIRED:
# - Update the namespace to match your extension
services:
-
class: Vendor\ExtensionName\Tests\Architecture\ArchitectureTest
assets/phpat.php
<?php
declare(strict_types=1);
/*
* PHPat Architecture Test Rules Template
*
* This file defines architecture rules enforced via PHPStan.
* Run with: Build/Scripts/runTests.sh -s phpstan
*
* CUSTOMIZATION REQUIRED:
* - Replace 'Vendor\ExtensionName' with your actual namespace
* - Adjust layer rules based on your extension's architecture
* - Add/remove rules based on your security requirements
*/
namespace Vendor\ExtensionName\Tests\Architecture;
use PHPat\Selector\Selector;
use PHPat\Test\Builder\BuildStep;
use PHPat\Test\PHPat;
/**
* Architecture tests for TYPO3 extension.
*
* Enforces clean architecture boundaries and security patterns.
*
* Layer dependency rules (allowed dependencies flow downward):
*
* Controller/Command (presentation)
* ↓
* Service (application)
* ↓
* Domain/Repository (core)
* ↓
* Exception/Event (shared kernel)
*/
final class ArchitectureTest
{
// =========================================================================
// IMMUTABILITY RULES - Security-critical classes must be immutable
// =========================================================================
/**
* Events must be readonly for immutability.
*
* PSR-14 events should never be modified after creation.
*/
public function testEventsMustBeReadonly(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('Vendor\ExtensionName\Event'))
->shouldBeReadonly()
->because('events must be immutable for security and predictability');
}
/**
* DTOs must be readonly.
*
* Data Transfer Objects should be immutable value objects.
*/
public function testDtosMustBeReadonly(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('Vendor\ExtensionName\Domain\Dto'))
->shouldBeReadonly()
->because('DTOs must be immutable value objects');
}
// =========================================================================
// FINALITY RULES - Security classes must not be extended
// =========================================================================
/**
* Exceptions must be final.
*
* Prevents exception hierarchy manipulation attacks.
*/
public function testExceptionsMustBeFinal(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('Vendor\ExtensionName\Exception'))
->shouldBeFinal()
->because('exceptions should not be extended for security');
}
// =========================================================================
// INTERFACE RULES - Ensure proper abstractions
// =========================================================================
/**
* Services must implement an interface.
*
* Enables dependency injection and testing.
*/
public function testServicesMustImplementInterface(): BuildStep
{
return PHPat::rule()
->classes(
Selector::classname('/^Vendor\\\\ExtensionName\\\\Service\\\\.*Service$/', true),
)
->excluding(
Selector::classname('/.*Interface$/', true),
Selector::classname('/.*Factory$/', true),
)
->shouldImplement()
->classes(Selector::classname('/.*Interface$/', true))
->because('services should be injected via interfaces for testability');
}
// =========================================================================
// LAYER DEPENDENCY RULES - Enforce clean architecture
// =========================================================================
/**
* Services must not depend on Controllers.
*
* Services are application layer, controllers are presentation.
*/
public function testServicesDoNotDependOnControllers(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('Vendor\ExtensionName\Service'))
->shouldNotDependOn()
->classes(Selector::inNamespace('Vendor\ExtensionName\Controller'))
->because('services should be independent of the presentation layer');
}
/**
* Services must not depend on Commands.
*
* CLI commands are presentation layer.
*/
public function testServicesDoNotDependOnCommands(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('Vendor\ExtensionName\Service'))
->shouldNotDependOn()
->classes(Selector::inNamespace('Vendor\ExtensionName\Command'))
->because('services should be independent of CLI commands');
}
/**
* Domain layer must not depend on infrastructure.
*
* Domain models should be pure and framework-independent.
*/
public function testDomainDoesNotDependOnInfrastructure(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('Vendor\ExtensionName\Domain'))
->shouldNotDependOn()
->classes(
Selector::inNamespace('Vendor\ExtensionName\Controller'),
Selector::inNamespace('Vendor\ExtensionName\Command'),
Selector::inNamespace('Vendor\ExtensionName\Hook'),
Selector::inNamespace('Vendor\ExtensionName\Form'),
Selector::inNamespace('Vendor\ExtensionName\Task'),
)
->because('domain layer must be isolated from infrastructure concerns');
}
/**
* Hooks must not depend on Controllers.
*
* TYPO3 hooks should call services, not controllers.
*/
public function testHooksDoNotDependOnControllers(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('Vendor\ExtensionName\Hook'))
->shouldNotDependOn()
->classes(Selector::inNamespace('Vendor\ExtensionName\Controller'))
->because('hooks should use services, not controllers');
}
/**
* Commands must not depend on Controllers.
*
* CLI and web are separate presentation channels.
*/
public function testCommandsDoNotDependOnControllers(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('Vendor\ExtensionName\Command'))
->shouldNotDependOn()
->classes(Selector::inNamespace('Vendor\ExtensionName\Controller'))
->because('CLI commands should not use web controllers');
}
/**
* Configuration must not depend on Services.
*
* Configuration is low-level infrastructure.
*/
public function testConfigurationDoesNotDependOnServices(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('Vendor\ExtensionName\Configuration'))
->shouldNotDependOn()
->classes(
Selector::inNamespace('Vendor\ExtensionName\Service'),
Selector::inNamespace('Vendor\ExtensionName\Controller'),
Selector::inNamespace('Vendor\ExtensionName\Command'),
)
->because('configuration should be low-level infrastructure');
}
/**
* EventListeners must not depend on Controllers or Commands.
*
* Event handlers should only use services.
*/
public function testEventListenersDoNotDependOnPresentation(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('Vendor\ExtensionName\EventListener'))
->shouldNotDependOn()
->classes(
Selector::inNamespace('Vendor\ExtensionName\Controller'),
Selector::inNamespace('Vendor\ExtensionName\Command'),
)
->because('event listeners should use services, not presentation layer');
}
/**
* Utilities must not depend on Services.
*
* Utilities should be stateless helper functions.
*/
public function testUtilitiesDoNotDependOnServices(): BuildStep
{
return PHPat::rule()
->classes(Selector::inNamespace('Vendor\ExtensionName\Utility'))
->shouldNotDependOn()
->classes(
Selector::inNamespace('Vendor\ExtensionName\Controller'),
Selector::inNamespace('Vendor\ExtensionName\Command'),
Selector::inNamespace('Vendor\ExtensionName\Hook'),
)
->because('utilities should be stateless helpers');
}
}
assets/phpstan-baseline.neon
# PHPStan Baseline
#
# Contains temporarily ignored errors during migration to higher levels.
# Goal: Keep this file empty (no ignored errors).
#
# To regenerate baseline after fixing errors:
# vendor/bin/phpstan analyze --generate-baseline
#
# Or via runTests.sh:
# Build/Scripts/runTests.sh -s phpstan -- --generate-baseline
parameters:
ignoreErrors: []
assets/phpstan.neon
# PHPStan Configuration for TYPO3 Extensions
#
# Run with: Build/Scripts/runTests.sh -s phpstan
#
# CUSTOMIZATION REQUIRED:
# - Adjust paths if your directory structure differs
# - Update phpVersion to match your minimum PHP requirement
# - Add type aliases for your domain-specific types
includes:
- .Build/vendor/phpstan/phpstan/conf/bleedingEdge.neon
# Architecture testing (requires carlosas/phpat)
- .Build/vendor/phpat/phpat/extension.neon
- phpstan-baseline.neon
- phpat.neon
parameters:
level: 10
paths:
- Classes
- Tests/Architecture
excludePaths:
analyseAndScan:
# Add files to exclude from analysis
# - Classes/Legacy/OldClass.php
reportUnmatchedIgnoredErrors: false
# PHP version targeting (80200 = PHP 8.2, 80300 = PHP 8.3, etc.)
phpVersion: 80200
# Strict rules for maximum type safety
checkTooWideReturnTypesInProtectedAndPublicMethods: true
checkUninitializedProperties: true
# TYPO3-specific settings
# PHPDoc types can be unreliable in TYPO3 extensions
treatPhpDocTypesAsCertain: false
# Type aliases for domain-specific types
# Uncomment and customize for your extension
# typeAliases:
# MyOptions: 'array{enabled?: bool, limit?: int, items?: list<string>}'
# MyResult: 'array{success: bool, data: mixed, errors: list<string>}'
assets/rector.php
<?php
declare(strict_types=1);
/*
* Rector Configuration for TYPO3 Extensions
*
* Run check: Build/Scripts/runTests.sh -s rector -n
* Run fix: Build/Scripts/runTests.sh -s rector
*
* CUSTOMIZATION REQUIRED:
* - Adjust paths for your extension structure
* - Update phpVersion() to match your minimum PHP requirement
* - Update TYPO3 level set to match your minimum TYPO3 version
*/
use Rector\CodingStyle\Rector\Catch_\CatchExceptionNameMatchingTypeRector;
use Rector\Config\RectorConfig;
use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedPrivateMethodParameterRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveUselessParamTagRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveUselessReturnTagRector;
use Rector\DeadCode\Rector\Property\RemoveUselessVarTagRector;
use Rector\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromotionRector;
use Rector\Set\ValueObject\LevelSetList;
use Rector\Set\ValueObject\SetList;
use Ssch\TYPO3Rector\Set\Typo3LevelSetList;
return static function (RectorConfig $rectorConfig): void {
// Paths to process
$rectorConfig->paths([
__DIR__ . '/Classes',
__DIR__ . '/Configuration',
__DIR__ . '/Tests',
]);
// Paths to skip
$rectorConfig->skip([
__DIR__ . '/ext_emconf.php',
__DIR__ . '/.Build',
]);
// PHPStan configuration for better type inference
// $rectorConfig->phpstanConfig(__DIR__ . '/phpstan.neon');
// Target PHP version (80200 = PHP 8.2, 80300 = PHP 8.3, etc.)
$rectorConfig->phpVersion(80200);
// Import and organize use statements
$rectorConfig->importNames();
$rectorConfig->removeUnusedImports();
// Define rule sets to apply
$rectorConfig->sets([
// Code quality improvements
SetList::CODE_QUALITY,
SetList::CODING_STYLE,
SetList::DEAD_CODE,
SetList::EARLY_RETURN,
SetList::INSTANCEOF,
SetList::PRIVATIZATION,
SetList::STRICT_BOOLEANS,
SetList::TYPE_DECLARATION,
// PHP version migration (adjust to your minimum PHP version)
LevelSetList::UP_TO_PHP_82,
// TYPO3 version migration (adjust to your minimum TYPO3 version)
// Options: UP_TO_TYPO3_12, UP_TO_TYPO3_13
Typo3LevelSetList::UP_TO_TYPO3_13,
]);
// Skip rules that may cause issues or conflicts with coding style
$rectorConfig->skip([
// Exception naming can be intentional
CatchExceptionNameMatchingTypeRector::class,
// Constructor promotion can reduce readability for complex classes
ClassPropertyAssignToConstructorPromotionRector::class,
// PHPDoc tags may be needed for IDE support or documentation
RemoveUselessParamTagRector::class,
RemoveUselessReturnTagRector::class,
RemoveUselessVarTagRector::class,
// Private method parameters may be intentionally unused for interface compatibility
RemoveUnusedPrivateMethodParameterRector::class,
]);
};
assets/UnitTests.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="../../vendor/autoload.php"
cacheResult="false"
beStrictAboutTestsThatDoNotTestAnything="true"
beStrictAboutOutputDuringTests="true"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true"
failOnRisky="true"
colors="true">
<testsuites>
<testsuite name="Unit tests">
<directory>../../Tests/Unit/</directory>
</testsuite>
</testsuites>
<coverage>
<report>
<clover outputFile="../../var/log/coverage/clover.xml"/>
<html outputDirectory="../../var/log/coverage/html"/>
<text outputFile="php://stdout" showOnlySummary="true"/>
</report>
</coverage>
</phpunit>
assets/UnitTestsBootstrap.php
<?php
declare(strict_types=1);
/**
* Bootstrap for TYPO3 Extension Unit Tests
*
* Place this file at Tests/Unit/Bootstrap.php
*
* This bootstrap is specifically for unit tests that may need
* TYPO3 class stubs when testing in isolation from the framework.
*
* OPTIONAL: Custom autoloader for TYPO3 stubs
* Use when unit tests need minimal TYPO3 class implementations
* without loading the full framework.
*/
// Set timezone
date_default_timezone_set('UTC');
// Locate composer autoloader
$autoloadLocations = [
dirname(__DIR__, 2) . '/.Build/vendor/autoload.php',
dirname(__DIR__, 4) . '/vendor/autoload.php',
dirname(__DIR__, 2) . '/vendor/autoload.php',
];
$autoloadFile = null;
foreach ($autoloadLocations as $location) {
if (file_exists($location)) {
$autoloadFile = $location;
break;
}
}
if ($autoloadFile === null) {
throw new RuntimeException(
'Could not find composer autoload.php. Run "composer install" first.'
);
}
require_once $autoloadFile;
/*
* OPTIONAL: Register custom autoloader for TYPO3 class stubs
*
* This allows unit tests to use minimal TYPO3 class implementations
* without requiring the full TYPO3 testing framework.
*
* Create stub classes in Tests/Unit/Fixtures/TYPO3/CMS/...
* mirroring the TYPO3 namespace structure.
*
* Example stub: Tests/Unit/Fixtures/TYPO3/CMS/Core/Cache/CacheManager.php
*
* Uncomment the following block to enable stub autoloading:
*/
// spl_autoload_register(static function (string $class): void {
// // Only handle TYPO3 classes
// if (!str_starts_with($class, 'TYPO3\\CMS\\')) {
// return;
// }
//
// // Convert namespace to file path
// $relativePath = str_replace('\\', '/', $class);
// $filePath = __DIR__ . '/Fixtures/' . $relativePath . '.php';
//
// if (file_exists($filePath)) {
// require_once $filePath;
// }
// });
// Define TYPO3 constants
if (!defined('TYPO3')) {
define('TYPO3', true);
}
if (!defined('TYPO3_MODE')) {
define('TYPO3_MODE', 'BE');
}
if (!defined('TYPO3_REQUESTTYPE')) {
define('TYPO3_REQUESTTYPE', 2);
}
checkpoints.yaml
# Checkpoints for typo3-testing skill
# Focuses on PHPUnit, test structure, CI, PHPStan, and code coverage
#
# Supports two CI patterns:
# 1. Traditional: local Build/UnitTests.xml + runTests.sh + direct action usage
# 2. Shared workflow: netresearch/typo3-ci-workflows (reusable CI, handles
# PHPUnit config naming, SHA pinning, coverage, and PHPStan internally)
version: 1
skill_id: typo3-testing
mechanical:
# === SHARED CI WORKFLOW DETECTION ===
# Check first — many later checks can be relaxed when a shared CI workflow
# handles PHPUnit config, SHA pinning, coverage, and PHPStan.
- id: TT-00
type: regex
target: .github/workflows/*.yml
pattern: 'uses:\s*netresearch/typo3-ci-workflows'
severity: info
desc: "Detects netresearch/typo3-ci-workflows reusable workflow (relaxes TT-01..05, TT-21, TT-24)"
# === PHPUNIT CONFIGURATION ===
# TYPO3 testing-framework convention: Build/UnitTests.xml + Build/FunctionalTests.xml
# netresearch/typo3-ci-workflows also accepts: Build/phpunit.xml + Build/phpunit.functional.xml
# Extensions that keep every PHPUnit config in one subdirectory use
# Build/phpunit/UnitTests.xml + Build/phpunit/FunctionalTests.xml.
# All three layouts are valid; TT-01/02/04/05/06 accept each of them.
# When TT-00 passes, either naming convention is valid.
- id: TT-01
type: file_exists
target: "{Build/UnitTests.xml,Build/phpunit.xml,Build/phpunit/UnitTests.xml}"
severity: error
desc: "PHPUnit unit test config must exist (Build/UnitTests.xml, Build/phpunit.xml or Build/phpunit/UnitTests.xml)"
- id: TT-02
type: file_exists
target: "{Build/FunctionalTests.xml,Build/phpunit.functional.xml,Build/phpunit/FunctionalTests.xml}"
severity: error
desc: "PHPUnit functional test config must exist (Build/FunctionalTests.xml, Build/phpunit.functional.xml or Build/phpunit/FunctionalTests.xml)"
- id: TT-03
type: file_not_exists
target: phpunit.xml
severity: warning
desc: "phpunit.xml in project root is discouraged; use Build/ directory instead"
- id: TT-04
type: regex
target: "{Build/UnitTests.xml,Build/phpunit.xml,Build/phpunit/UnitTests.xml}"
pattern: '<testsuite.*name="unit"'
severity: error
desc: "PHPUnit config must define unit test suite"
- id: TT-05
type: regex
target: "{Build/FunctionalTests.xml,Build/phpunit.functional.xml,Build/phpunit/FunctionalTests.xml}"
pattern: '<testsuite.*name="functional"'
severity: error
desc: "PHPUnit config must define functional test suite"
# === PHPUNIT DEPRECATED ATTRIBUTES ===
- id: TT-06
type: regex_not
target: "{Build/FunctionalTests.xml,Build/phpunit.xml,Build/UnitTests.xml,Build/phpunit.functional.xml,Build/phpunit/UnitTests.xml,Build/phpunit/FunctionalTests.xml}"
pattern: 'displayDetailsOnIncompleteTests|displayDetailsOnSkippedTests'
severity: warning
desc: "PHPUnit configs must not use displayDetailsOnIncompleteTests or displayDetailsOnSkippedTests (removed in PHPUnit 12)"
# === TEST DIRECTORY STRUCTURE ===
- id: TT-10
type: file_exists
target: Tests/Unit/
severity: error
desc: "Tests/Unit/ directory must exist"
- id: TT-11
type: file_exists
target: Tests/Functional/
severity: warning
desc: "Tests/Functional/ directory should exist"
- id: TT-12
type: regex
target: Tests/Unit/**/*Test.php
pattern: 'class.*Test.*extends.*TestCase'
severity: error
desc: "Unit tests must extend TestCase"
- id: TT-13
type: regex
target: Tests/Functional/**/*Test.php
pattern: 'class.*Test.*extends.*FunctionalTestCase'
severity: warning
desc: "Functional tests should extend FunctionalTestCase"
# === CI WORKFLOW WITH TEST EXECUTION ===
- id: TT-20
type: file_exists
target: .github/workflows/ci.yml
severity: warning
desc: "CI workflow should exist (ci.yml, ci.yaml, or tests.yml)"
- id: TT-21
type: regex
target: .github/workflows/*.yml
pattern: 'phpunit|composer.*test|runTests|typo3-ci-workflows'
severity: error
desc: "CI workflow must execute tests (directly or via reusable workflow)"
- id: TT-22
type: regex
target: .github/workflows/*.yml
pattern: 'php-version.*\[.*8\.[2-9]|php-versions'
severity: warning
desc: "CI should test multiple PHP versions (directly or via php-versions input)"
- id: TT-23
type: regex
target: .github/workflows/*.yml
pattern: 'typo3.*\[.*1[3-4]|typo3-versions'
severity: warning
desc: "CI should test multiple TYPO3 versions"
- id: TT-24
type: regex
target: .github/workflows/*.yml
pattern: 'uses:.*@[a-f0-9]{40}|uses:\s*netresearch/typo3-ci-workflows'
severity: error
desc: "GitHub Actions must be pinned to SHA (or use trusted reusable workflow)"
# === PHPSTAN CONFIGURATION ===
- id: TT-30
type: file_exists
target: "{phpstan.neon,Build/phpstan.neon,Build/phpstan/phpstan.neon}"
severity: warning
desc: "phpstan.neon configuration should exist"
- id: TT-31
type: file_exists
target: "{phpstan-baseline.neon,Build/phpstan-baseline.neon,Build/phpstan/phpstan-baseline.neon}"
severity: info
desc: "phpstan-baseline.neon for gradual adoption (root or Build/ subdir)"
- id: TT-32
type: regex
target: "{phpstan.neon,Build/phpstan.neon,Build/phpstan/phpstan.neon}"
pattern: 'level:\s*(10|max)'
severity: warning
desc: "PHPStan must use level 10 (max) — this is the strict check for projects using typo3-ci-workflows"
- id: TT-33
type: regex
target: "{phpstan.neon,Build/phpstan.neon,Build/phpstan/phpstan.neon}"
pattern: "typo3|TYPO3"
severity: info
desc: "PHPStan should include TYPO3-specific configuration"
- id: TT-34
type: regex
target: .github/workflows/*.yml
pattern: 'phpstan|composer.*stan|composer.*analyze|typo3-ci-workflows'
severity: warning
desc: "CI should run PHPStan analysis (directly or via reusable workflow)"
# Was a single-line `command:` chaining four alternatives with `||`, which the
# runner rejects outright (command-chaining metacharacter), so it never ran and
# reported a failure on every project. It also relied on an `expected:` field the
# runner does not parse — the verdict has to be the exit status. Both are fixed
# here by moving the logic into a screened script body.
#
# The level is read with `grep -oE` plus a bash suffix strip, not `grep -oP
# …\K`: where PCRE is missing (BSD grep), the PCRE spelling captured nothing
# from every candidate file and the loop then reported "no PHPStan
# configuration declaring a level" — a false failure against a conformant
# project on every such machine.
#
# Every candidate configuration is inspected before the verdict. A project
# with Build/phpstan.neon at level 9 and phpstan.neon at level 10 satisfies
# the rule; exiting at the first file below 10 reported it as non-conformant.
- id: TT-35
type: script
command: |
bad=""
for f in Build/phpstan.neon Build/phpstan/phpstan.neon phpstan.neon phpstan.neon.dist; do
[ -f "$f" ] || continue
lvl=$(grep -m1 -oE 'level:[[:space:]]*([0-9]+|max)' "$f" 2>/dev/null)
lvl="${lvl##*[[:space:]:]}"
[ -z "$lvl" ] && continue
if [ "$lvl" = "max" ] || [ "$lvl" = "10" ]; then
exit 0
fi
bad="$bad $f=$lvl"
done
if [ -n "$bad" ]; then
echo "FAIL: no PHPStan configuration declares level 10 or max; found:$bad"
exit 1
fi
echo "FAIL: no PHPStan configuration declaring a level was found"
exit 1
severity: warning
desc: "PHPStan must use level 10 (max) — this is the strict check for projects using typo3-ci-workflows"
# === CODE COVERAGE ===
- id: TT-40
type: file_exists
target: codecov.yml
severity: info
desc: "codecov.yml configuration for coverage settings"
- id: TT-41
type: regex
target: .github/workflows/*.yml
pattern: 'codecov|upload-coverage|typo3-ci-workflows'
severity: warning
desc: "CI should upload coverage (directly or via reusable workflow with upload-coverage: true)"
- id: TT-42
type: regex
target: .github/workflows/{ci,tests}.{yml,yaml}
pattern: 'coverage.*clover|XDEBUG_MODE.*coverage|--coverage|upload-coverage:\s*true'
severity: warning
desc: "CI should generate code coverage"
- id: TT-43
type: contains
target: README.md
pattern: "codecov.io"
severity: info
desc: "README should display Codecov badge"
# === RUNTESTS.SH SCRIPT (mandatory for all Netresearch TYPO3 extensions) ===
- id: TT-50
type: file_exists
target: Build/Scripts/runTests.sh
severity: error
desc: "Build/Scripts/runTests.sh must exist as the unified test runner for all TYPO3 extensions"
- id: TT-51
type: regex
target: Build/Scripts/runTests.sh
pattern: 'TYPO3_VERSION|typo3/core'
severity: warning
desc: "runTests.sh should support TYPO3 version selection"
- id: TT-52
type: regex
target: Build/Scripts/runTests.sh
pattern: 'PHP_VERSION|php.*version'
severity: warning
desc: "runTests.sh should support PHP version selection (-p flag)"
- id: TT-53
type: regex
target: Build/Scripts/runTests.sh
pattern: 'suite|SUITE'
severity: warning
desc: "runTests.sh should support test suite selection (-s flag)"
- id: TT-54
type: command
command: 'test -x Build/Scripts/runTests.sh'
severity: error
desc: "Build/Scripts/runTests.sh must be executable"
# Two valid entry-point layouts, and this check accepts both:
# 1. composer scripts call Build/Scripts/runTests.sh directly;
# 2. the Netresearch convention — composer scripts (ci:test:php:unit,
# ci:test:php:functional, ...) ARE the entry point, and runTests.sh is
# reserved for Docker-based multi-version runs.
# Requiring shape 1 alone reported an error-free repo as non-conformant.
# A composer.json with neither shape still fails: it defines no test entry
# point at all.
#
# Read structurally out of `.scripts`, not as a regex over the whole file:
# `scripts-descriptions` is a real composer field and NR extensions document
# exactly these script names in it, so the text match passed a composer.json
# whose `scripts` object defines no test entry point.
- id: TT-55
type: json_path
target: composer.json
pattern: '(.scripts // {}) | (has("ci:test:php:unit") or has("ci:test:php:functional") or (tostring | contains("Build/Scripts/runTests.sh")))'
severity: warning
desc: "composer.json should define a test entry point — either a script invoking Build/Scripts/runTests.sh, or ci:test:php:unit / ci:test:php:functional scripts"
- id: TT-56
type: regex
target: Makefile
pattern: 'typo3-ci-workflows/Makefile.include'
severity: info
desc: "Makefile should include shared targets from typo3-ci-workflows"
# === RUNTESTS.SH QUALITY ===
- id: TT-57
type: regex
target: Build/Scripts/runTests.sh
pattern: 'ghcr\.io/typo3/core-testing|CONTAINER_BIN|docker\s+run'
severity: warning
desc: "runTests.sh should use Docker containers (TYPO3 core-testing images) for consistent test environments"
# Fires only on the real bug: a make RECIPE that invokes runTests.sh without
# -s. It no longer demands that the Makefile drive runTests.sh at all, which
# reported an error against every repo where the Makefile is a thin wrapper
# over composer scripts (ci:test:php:unit, ...) and runTests.sh is reserved
# for Docker-based multi-version runs.
#
# Anchored on a literal TAB: make recipes must be tab-indented, assignments
# never are, so `RUNTESTS := Build/Scripts/runTests.sh` — with or without the
# leading spaces it gets inside an ifeq block — is not mistaken for a call.
# The runner must also be the recipe's COMMAND WORD, after make's optional
# `@`/`-`/`+` prefixes: a `help:` target whose `@echo` merely names
# runTests.sh is a mention, not a call, and matching it anywhere on the line
# reported an error against a correct Makefile. `[)}]` accepts both
# $(RUNTESTS) and ${RUNTESTS}; `\S*runTests\.sh` accepts the direct path.
# The lookahead keeps this a single grep: a `grep … | grep -q …` pipeline
# reports the opposite verdict under `set -o pipefail`, because grep -q
# closes the pipe early and the SIGPIPE on the left-hand grep becomes the
# pipeline's status.
# Known limitations: a call split across a backslash line-continuation (also
# true of the regex this replaced), and a call that is not in command
# position (`cd Build && $(RUNTESTS) unit`), are not seen. Needs grep -P:
# where PCRE is missing, `grep -qP` exits 2, the leading `!` inverts that and
# the check passes — it fails open instead of reporting every Makefile as
# broken. TT-35 and TT-105 do not rely on that; they were switched to a
# PCRE-free extraction, because their PCRE spelling failed CLOSED.
- id: TT-58
type: command
pattern: "! grep -qP '^\\t[@+-]*([$][({]RUNTESTS[)}]|\\S*runTests\\.sh)(?!.* -s[ =])' Makefile 2>/dev/null"
severity: error
desc: "Makefile recipes that invoke runTests.sh must pass the -s flag (e.g. $(RUNTESTS) -s unit, not $(RUNTESTS) unit). A Makefile that wraps composer scripts instead, or no Makefile at all, satisfies this check."
- id: TT-59
type: regex
target: Build/Scripts/runTests.sh
pattern: 'NETWORK=.*\$\{?SUFFIX'
severity: warning
desc: "Docker network name should include per-run suffix to avoid conflicts with concurrent runs"
# === COMPOSER.JSON TEST DEPENDENCIES ===
# TT-60 removed: phpunit/phpunit is pulled transitively via typo3/testing-framework.
# Requiring it directly breaks the PHP-8.2 matrix cell because phpunit 12.5.8+
# requires PHP ≥ 8.3. Do NOT add a direct phpunit/phpunit require-dev entry.
# `||`-chained on one line, so the runner rejected it unread and it failed on
# every project, including ones that do have PHPStan.
#
# The composer.json branch reads declared dependency KEYS. `grep -q phpstan
# composer.json` also passed on a `description` or `scripts-descriptions`
# entry that merely names PHPStan, so the warning was suppressed for projects
# that never install it. netresearch/typo3-ci-workflows counts as a key,
# because it is the shared CI package this check means by "transitively".
- id: TT-61
type: script
command: |
[ -f .Build/bin/phpstan ] && exit 0
[ -f vendor/bin/phpstan ] && exit 0
jq -e '((.require // {}) + (."require-dev" // {})) | keys | any(test("phpstan") or . == "netresearch/typo3-ci-workflows")' composer.json >/dev/null 2>&1 && exit 0
grep -rq phpstan .Build/vendor/composer/installed.json 2>/dev/null && exit 0
grep -rq phpstan vendor/composer/installed.json 2>/dev/null && exit 0
echo "FAIL: PHPStan is not available directly or transitively"
exit 1
severity: warning
desc: "PHPStan should be available (directly or transitively via a shared CI package)"
- id: TT-62
type: json_path
target: composer.json
pattern: '.scripts.test // .scripts["ci:test"] // .scripts["test:unit"] // .scripts["ci:test:php:unit"]'
severity: warning
desc: "composer.json should define test script (test, ci:test, test:unit, or ci:test:php:unit)"
# === ARCHITECTURE TESTING ===
- id: TT-14
type: file_exists
target: Tests/Architecture/
severity: info
desc: "Tests/Architecture/ directory should exist for phpat architecture tests"
- id: TT-15
type: json_path
target: composer.json
pattern: '.["require-dev"]["phpat/phpat"] // .["require-dev"]["netresearch/typo3-ci-workflows"]'
severity: warning
desc: "composer.json should require phpat/phpat for architecture testing (package was renamed from carlosas/phpat in 2023; also satisfied when netresearch/typo3-ci-workflows meta-package is present, as phpat is transitive)"
# === E2E TESTING (Playwright) ===
- id: TT-16
type: file_exists
target: "{playwright.config.ts,playwright.config.js,Build/playwright.config.ts,Tests/E2E/playwright.config.ts}"
severity: info
desc: "Playwright config should exist for E2E testing (root, Build/, or Tests/E2E/)"
- id: TT-17
type: file_exists
target: "{package.json,Build/package.json,Tests/E2E/package.json,Tests/E2E/Playwright/package.json}"
severity: info
desc: "package.json should exist for Playwright/JS test dependencies (root or Build/ or Tests/E2E/ subpath)"
- id: TT-17a
type: command
command: |
cfg=""
for f in Build/playwright.config.ts playwright.config.ts; do
[ -f "$f" ] && cfg="$f" && break
done
[ -z "$cfg" ] && exit 0
grep -q 'chromium' "$cfg" && grep -q 'firefox' "$cfg" && exit 0
echo "FAIL: $cfg should define at least Chromium and Firefox projects"
exit 1
severity: warning
desc: "If playwright.config.ts exists, it should define at least Chromium and Firefox projects for cross-browser E2E coverage"
# === MUTATION TESTING ===
- id: TT-18
type: file_exists
target: "{infection.json5,infection.json.dist,infection.json,Build/infection.json5,Build/infection.json.dist}"
severity: info
desc: "Infection config should exist for mutation testing (accepts .json5, .json.dist, .json)"
- id: TT-18a
type: command
command: |
test -f infection.json5 || exit 0
grep -rql 'infection\|mutation\|ci:test:php:mutation' .github/workflows/*.yml 2>/dev/null && exit 0
echo "FAIL: infection.json5 exists but mutation testing is not configured in CI"
exit 1
severity: warning
desc: "If infection.json5 exists, mutation testing should be configured in CI (at least as a scheduled job)"
# === ADDITIONAL DEV DEPENDENCIES ===
# Accept either a direct require-dev declaration OR transitive availability
# via the netresearch/typo3-ci-workflows meta-package, which bundles
# php-cs-fixer, typo3/coding-standards, rector, phpstan, infection, etc.
# Same pattern as TT-15 (phpat) — see references/ci-workflows-meta-package.md
- id: TT-63
type: json_path
target: composer.json
pattern: '.["require-dev"]["friendsofphp/php-cs-fixer"] // .["require-dev"]["typo3/coding-standards"] // .["require-dev"]["netresearch/typo3-ci-workflows"]'
severity: warning
desc: "composer.json should require php-cs-fixer or typo3/coding-standards in require-dev (also satisfied transitively via netresearch/typo3-ci-workflows)"
- id: TT-64
type: json_path
target: composer.json
pattern: '.["require-dev"]["rector/rector"] // .["require-dev"]["netresearch/typo3-ci-workflows"]'
severity: info
desc: "composer.json should require rector/rector in require-dev (also satisfied transitively via netresearch/typo3-ci-workflows)"
# === CI MATRIX BEST PRACTICES ===
- id: TT-25
type: regex
target: .github/workflows/*.yml
pattern: 'fail-fast:\s*false'
severity: info
desc: "CI matrix should use fail-fast: false to run all combinations"
# === TEST ENVIRONMENT GUARDS ===
- id: TT-75
type: regex
target: Tests/**/*Test.php
pattern: 'tearDown\s*\(\s*\)\s*:\s*void'
severity: warning
desc: "Test classes creating filesystem artifacts (tempDir, tempFile) must implement tearDown() for cleanup"
note: "Check manually: only flag when setUp() creates files/dirs but tearDown() is missing"
- id: TT-76
type: regex
target: Tests/**/*Test.php
pattern: 'AllowMockObjectsWithoutExpectations'
severity: error
desc: "#[AllowMockObjectsWithoutExpectations] is PHPUnit 12 only — causes fatal error on PHPUnit 11 (PHP 8.2 CI). Use createStub() instead."
# === NEGATIVE CHECKS (should NOT exist) ===
- id: TT-90
type: regex_not
target: .github/workflows/*.yml
pattern: 'ddev start|ddev exec|ddev-github-action|setup-ddev'
severity: error
desc: "DDEV must not be used in CI/CD. Use PHP built-in server or Docker containers."
tags: [ci, ddev, e2e]
# === GIT HOOKS ===
# Netresearch convention: keep testing/CI config under Build/ so the repo
# root stays focused on end-user files (README, LICENSE, composer.json,
# ext_emconf.php). captainhook/hook-installer reads the config path from
# composer.json "extra.captainhook.config", so Build/captainhook.json is
# the enforced default.
- id: TT-71
type: file_exists
target: "{Build/captainhook.json,captainhook.json,.captainhook/captainhook.json}"
severity: error
desc: >-
captainhook config must exist for local git hook automation (pre-commit,
commit-msg, pre-push) — mandatory for Netresearch extensions.
Netresearch default: Build/captainhook.json (declared in composer.json
"extra.captainhook.config"). Root captainhook.json or
.captainhook/captainhook.json are accepted for backward compatibility
but root location is flagged by TT-71a.
- id: TT-71a
type: file_not_exists
target: captainhook.json
severity: warning
desc: >-
captainhook.json should live under Build/captainhook.json (Netresearch
convention — keeps repo root clean). Move to Build/ and declare
"extra": {"captainhook": {"config": "Build/captainhook.json"}} in
composer.json so captainhook/hook-installer picks it up.
- id: TT-71b
type: json_path
target: composer.json
pattern: '.extra.captainhook.config == "Build/captainhook.json"'
severity: warning
desc: >-
composer.json should declare extra.captainhook.config:
"Build/captainhook.json" so the captainhook/hook-installer plugin
installs hooks using the Build/ subdir config on every composer install.
# === GITIGNORE CHECKS ===
- id: TT-70
type: contains
target: .gitignore
pattern: "composer.lock"
severity: error
desc: "composer.lock should be in .gitignore (TYPO3 extensions should not commit it)"
# === ADDITIONAL TEST INFRASTRUCTURE ===
- id: TT-103
type: json_path
target: composer.json
pattern: '.scripts["ci:test:php:functional"]'
severity: warning
desc: "composer.json should define ci:test:php:functional script for running functional tests via composer"
- id: TT-104
type: json_path
target: composer.json
pattern: '.scripts["ci:test:php:mutation"]'
severity: info
desc: "composer.json should define ci:test:php:mutation script for running mutation tests via composer"
# === MUTATION TESTING QUALITY ===
# `||`-chained and relying on an `expected:` field the runner never reads, so it
# was rejected unread and failed even where infection.json5 sets a high minMsi.
# Not applicable when the project does not do mutation testing at all — TT-104
# already reports the missing configuration.
#
# PCRE-free extraction for the same reason as TT-35: with `grep -oP …\K` on a
# grep without PCRE the capture is empty and the check reported "declares no
# minMsi" against a config that declares one.
- id: TT-105
type: script
command: |
[ -f infection.json5 ] || exit 0
msi=$(grep -m1 -oE '"minMsi":[[:space:]]*[0-9]+' infection.json5 2>/dev/null)
msi="${msi##*[[:space:]:]}"
if [ -z "$msi" ]; then
echo "FAIL: infection.json5 declares no minMsi"
exit 1
fi
[ "$msi" -ge 90 ] && exit 0
echo "FAIL: infection minMsi is $msi; expected >= 90"
exit 1
severity: warning
desc: "Infection minMsi should be >= 90% for mature test suites"
- id: TT-106
type: command
command: |
MSI1=$(grep -oP '"minMsi":\s*\K[0-9]+' infection.json5 2>/dev/null || echo 0)
MSI2=$(grep -oP '"minMsi":\s*\K[0-9]+' infection-full.json5 2>/dev/null || echo $MSI1)
[ "$MSI1" = "$MSI2" ] && echo "consistent" || echo "mismatch: $MSI1 vs $MSI2"
expected: "^consistent$"
severity: warning
desc: "All infection config files must use consistent MSI thresholds"
# === TEST QUALITY PATTERNS ===
- id: TT-110
type: regex_not
target: Tests/**/*Test.php
pattern: 'assertEquals\s*\(\s*null\s*,'
severity: warning
desc: "Use assertNull() instead of assertEquals(null, ...) for clearer intent and better failure messages"
- id: TT-111
type: file_exists
target: Tests/Functional/Fixtures/
severity: info
desc: "Tests/Functional/Fixtures/ directory should exist for CSV fixture data used by functional tests"
# === TEST ANTI-PATTERNS ===
- id: TT-130
type: regex_not
target: Tests/**/*Test.php
pattern: "markTestSkipped\\(['\"].*(?:GD|gd|Imagick|imagick).*extension"
severity: error
desc: "Tests must not skip for missing image extensions — run tests in Docker via runTests.sh where GD/Imagick are available"
- id: TT-131
type: regex_not
target: Tests/**/*Test.php
pattern: 'createMock\(.*ImageManager::class\)'
severity: warning
desc: "Do not mock final classes (e.g., Intervention ImageManager) — use real instances or interface mocks"
# === TEST COVERAGE COMPLETENESS ===
# `-not -path` excludes a dependency tree vendored underneath the search
# root. The exclusions are spelled `*/vendor/*` rather than `./vendor/*`:
# find starts at Classes/Service, so it emits paths beginning `Classes/`,
# and a `./`-anchored pattern could never match one.
#
# The counter used to be incremented inside `find … | while …`, which bash
# runs in a subshell: `missing` came back 0 no matter how many classes were
# reported, so the check printed its findings and still exited 0 — it could
# not fail. Process substitution keeps the loop in the current shell.
#
# Spelled `type: script` + `command: |` — the only multi-line shape both the
# runner and validate-checkpoints.sh accept (see TD-05 in typo3-docs).
- id: TT-120
type: script
command: |
missing=0
while IFS= read -r -d '' f; do
base=$(basename "$f" .php)
rel=${f#Classes/}
dir=$(dirname "$rel")
if [ ! -f "Tests/Unit/${dir}/${base}Test.php" ] && [ ! -f "Tests/Functional/${dir}/${base}Test.php" ]; then
echo "Missing test for $f"
missing=$((missing+1))
fi
done < <(find Classes/Service -name '*.php' -not -path '*/.Build/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' -print0 2>/dev/null)
[ "$missing" -eq 0 ]
severity: warning
desc: "Every class in Classes/Service/ (including subdirectories) should have a corresponding test"
- id: TT-121
type: script
command: |
missing=0
while IFS= read -r -d '' f; do
base=$(basename "$f" .php)
rel=${f#Classes/}
dir=$(dirname "$rel")
if [ ! -f "Tests/Unit/${dir}/${base}Test.php" ] && [ ! -f "Tests/Functional/${dir}/${base}Test.php" ]; then
echo "Missing test for $f"
missing=$((missing+1))
fi
done < <(find Classes/ViewHelpers -name '*ViewHelper.php' -not -path '*/.Build/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' -print0 2>/dev/null)
[ "$missing" -eq 0 ]
severity: warning
desc: "Every ViewHelper in Classes/ViewHelpers/ (including subdirectories) should have a corresponding test"
# === ADDITIONAL CHECKPOINTS (t3x-nr-vault v0.5.0 session) ===
- id: TT-132
type: regex_not
target: Tests/E2E/**/*.spec.ts
pattern: "(['\"])\\s*/(?:home|Users)/[^'\"/]+/[^'\"]*\\1|(['\"])C:\\\\\\\\"
severity: error
desc: "E2E specs must not contain hardcoded absolute local paths (/home/username/..., /Users/username/..., C:\\...) — breaks CI and every other developer's machine. Use process.cwd() or paths relative to the test file instead."
note: "Real bug caught in t3x-nr-vault review: hardcoded /home/cybot/projects/.../main in an execFileSync cwd."
- id: TT-135
type: regex_not
target: Tests/Functional/**/*Test.php
pattern: 'function setUp\(\)[^}]*(?:(?:self|static)::markTestSkipped|\$this->markTestSkipped)[^}]*parent::setUp\(\)'
multiline: true
severity: error
desc: >-
markTestSkipped() must not be called before parent::setUp() in a FunctionalTestCase::setUp().
Typed properties like $this->instancePath are uninitialised until parent::setUp() runs;
tearDown() then crashes with "accessed before initialization".
Correct order: parent::setUp() first, then check the skip condition.
llm_reviews:
# === MOVED FROM mechanical: — type llm_review is not a mechanical type,
# so the runner skipped these three as 'Unknown checkpoint type' and they
# never ran anywhere. ===
- id: TT-133
type: llm_review
domain: test-quality
target: "Tests/Unit/**/*Test.php"
severity: warning
desc: "#[CoversClass] target must not appear in phpunit.xml <source><exclude>. PHPUnit 12 emits 'not a valid target for code coverage' when a class is both covered and excluded; failOnWarning=true promotes this to a failure. Use #[CoversNothing] for tests of excluded classes, or remove the class from the exclude list."
- id: TT-134
type: llm_review
domain: test-quality
target: "Tests/**/*.php"
severity: error
desc: "Tests that call vfsStream::url() must call vfsStream::setup() for the same root in setUp(). On a fresh CI run the virtual-filesystem root does not exist; file_put_contents('vfs://keys/...') fails with 'No such file or directory'. Works locally only when a previous test in the same process already registered the root."
# Same candidate paths as TT-30/TT-32/TT-35: a project whose PHPStan config
# lives in Build/ was skipped by this review entirely.
- id: TT-136
type: llm_review
domain: static-analysis
target: "{phpstan.neon,Build/phpstan.neon,Build/phpstan/phpstan.neon}"
severity: error
desc: "phpstan.neon must not combine phpstan/extension-installer auto-registration with explicit includes: of the same extension .neon files. When extension-installer is active (via netresearch/typo3-ci-workflows), phpstan-phpunit, phpstan-strict-rules, phpstan-deprecation-rules, phpat, and saschaegerer/phpstan-typo3 are already registered — adding explicit includes: causes 'These files are included multiple times' and PHPStan exits 1. Remove duplicate includes or use Build/phpstan.no-plugins.neon for local runs without extension-installer."
# === DEPENDENCY FRESHNESS ===
- id: TT-19
type: llm_review
severity: info
domain: dependencies
prompt: |
If package.json exists, check that test framework dependencies
(vitest, playwright, jest, jsdom) are not more than 2 major
versions behind the latest release. Outdated test tools can
cause false positives/negatives and miss new testing features.
desc: "npm test dependencies not excessively outdated"
tags: [npm, dependencies, testing]
# === TEST QUALITY REVIEWS ===
- id: TT-85
domain: code-quality
prompt: |
Review test files for incorrect #[CoversClass] attribute usage:
- Each #[CoversClass(Foo::class)] must reference a class that the test
actually exercises (creates, calls methods on, or asserts behavior of).
- Flag any test that declares #[CoversClass] on a class it does not
instantiate, mock, or invoke methods on within any test method.
- Common mistake: copy-pasting CoversClass from another test file.
Examine Tests/Unit/**/*Test.php and Tests/Functional/**/*Test.php.
severity: warning
desc: "Verify #[CoversClass] attributes reference classes the test actually exercises"
- id: TT-80
domain: code-quality
prompt: |
Review the test directory structure and naming conventions:
- Tests should be in Tests/Unit/ and Tests/Functional/
- Test class names should end with "Test"
- Test methods should start with "test" or use #[Test] attribute
- Namespace should mirror Classes/ structure
Check Tests/ directory and report compliance.
severity: warning
desc: "Verify test structure follows TYPO3 conventions"
- id: TT-81
domain: code-quality
prompt: |
Review PHPUnit configuration for best practices:
- Bootstrap file should be configured
- Test suites should be properly defined
- Code coverage filter should exclude Tests/ and vendor/
- Fail on warnings/risky should be enabled
Examine phpunit.xml, Build/UnitTests.xml, Build/phpunit.xml or
Build/phpunit/UnitTests.xml and report findings.
severity: info
desc: "Verify PHPUnit configuration follows best practices"
- id: TT-82
domain: code-quality
prompt: |
Review CI test matrix configuration:
- Should test against supported PHP versions (8.2, 8.3, 8.4, 8.5)
- Should test against supported TYPO3 versions (v13, v14)
- If using netresearch/typo3-ci-workflows, check that php-versions
and typo3-versions inputs are properly configured
- Should have reasonable timeout limits
Examine .github/workflows/*.yml and report matrix coverage.
severity: warning
desc: "Verify CI matrix covers required PHP and TYPO3 versions"
- id: TT-83
domain: code-quality
prompt: |
Review PHPStan configuration for TYPO3 extension development:
- Should include saschaegerer/phpstan-typo3 extension
- Level should be 6 or higher (ideally 9/max, or 10 with PHPStan 2.x)
- Should scan Classes/ directory
- Baseline file is acceptable for legacy code
Examine phpstan.neon (root or Build/ directory) and report quality.
severity: info
desc: "Verify PHPStan is properly configured for TYPO3"
- id: TT-84
domain: code-quality
prompt: |
Review test coverage and quality indicators:
- Are there tests for core functionality?
- Do tests cover edge cases and error conditions?
- Are mocks used appropriately?
- Is test isolation maintained?
Sample Tests/Unit/ and Tests/Functional/ directories and assess quality.
severity: info
desc: "Assess overall test coverage and quality"
# === TEST INFRASTRUCTURE ANTI-PATTERNS ===
- id: TT-91
domain: test-quality
prompt: |
Review test code for markTestSkipped guards that hide missing Docker infrastructure:
1. Find all uses of markTestSkipped() or $this->markTestSkipped() in test files
2. Check if any skip guards test for PHP extensions (GD, Imagick, etc.) or
external tools that should be available in the Docker test environment
3. Tests for image/file/network operations should always run in containerized
environments via runTests.sh, not be skipped on bare-metal
4. Flag any markTestSkipped() that tests for extensions or binaries that the
Docker container should provide
severity: warning
desc: "Review test code for markTestSkipped guards that hide missing Docker infrastructure — tests for image/file/network operations should always run in containerized environments"
- id: TT-92
domain: test-quality
prompt: |
Review test code for mocking of final or readonly classes:
1. Find all createMock() and getMockBuilder() calls in test files
2. Resolve the target class and check if it is declared final or readonly
(e.g., Intervention\Image\ImageManager is final in v3+)
3. Mocking a final class causes a runtime error in PHPUnit
4. Suggest alternatives: use real instances, mock interfaces instead,
or use adapter patterns that wrap the final class
5. Flag any createMock(FinalClass::class) usage
severity: warning
desc: "Review for mocking of final or readonly classes — use real instances, interface mocks, or adapter patterns instead of createMock() on final classes"
# === TEST COVERAGE COMPLETENESS ===
- id: TT-87
domain: code-quality
prompt: |
Check every class in Classes/Service/ has a corresponding test in Tests/Unit/Service/ or Tests/Functional/Service/:
1. List all PHP classes in Classes/Service/
2. For each class, check if a matching test file exists in Tests/Unit/Service/ or Tests/Functional/Service/
3. Flag any service class without a corresponding test
severity: warning
desc: "Every service class in Classes/Service/ should have a test in Tests/Unit/Service/ or Tests/Functional/Service/"
- id: TT-88
domain: code-quality
prompt: |
Check every ViewHelper in Classes/ViewHelpers/ has a corresponding test:
1. List all ViewHelper classes in Classes/ViewHelpers/ (recursively)
2. For each ViewHelper, check if a matching test file exists in Tests/Unit/ViewHelpers/ or Tests/Functional/ViewHelpers/
3. Flag any ViewHelper without a corresponding test
severity: warning
desc: "Every ViewHelper in Classes/ViewHelpers/ should have a corresponding test"
# === EVENT DISPATCH AND ERROR HANDLING ===
- id: TT-77
domain: testing
prompt: |
Review test coverage for event dispatch error handling:
1. Find all try/catch blocks that wrap EventDispatcherInterface::dispatch() calls
2. Verify each catch block has a dedicated test that triggers the exception path
3. Check that tests mock the EventDispatcher to throw exceptions and verify the catch behavior
4. Flag any event dispatch catch block without corresponding test coverage
severity: warning
desc: "Event dispatch catch blocks must have dedicated tests that verify exception handling behavior"
- id: TT-78
domain: testing
prompt: |
Review test coverage for PHP error suppression and trigger_error:
1. Find all uses of @ error suppression operator in production code
2. Verify each @-suppressed call has tests covering both success and failure paths
3. Find functions that are known to trigger PHP warnings on failure (e.g., getimagesize, file_get_contents, mkdir, unlink, fopen)
4. Verify tests exist that trigger the warning path (e.g., invalid file input)
5. Check that tests don't just suppress warnings but explicitly test error handling
6. Find all trigger_error() calls in production code and the code paths that rely on them
7. Verify tests assert how each triggered error is handled or propagated (e.g., custom error handlers, exceptions, logging)
severity: warning
desc: "Functions with @ suppression, trigger_error(), or that trigger PHP warnings must have tests covering error paths"
# === CONTROLLER FUNCTIONAL TEST COVERAGE ===
- id: TT-86
domain: testing
prompt: |
Review backend controller test coverage:
1. Find all classes in Classes/Controller/ (especially AJAX controllers returning JsonResponse)
2. For each controller, check that functional tests exist in Tests/Functional/Controller/
3. Verify that functional tests check:
a. HTTP status codes (200 for success, 403 for unauthorized, etc.)
b. Access control (calling without proper BE user session returns 403/401)
c. CSRF token validation (requests without valid token are rejected)
4. Flag any backend controller without functional tests covering these three aspects
severity: warning
desc: "Every backend controller (especially AJAX controllers returning JSON) should have functional tests verifying HTTP status codes, access control, and CSRF"
# === CI TEST MATRIX COMPLETENESS ===
- id: TT-27
domain: ci
prompt: |
Verify that the CI test matrix covers all supported version combinations:
1. Read the PHP version constraint from composer.json require.php (e.g., "^8.2")
2. Read the TYPO3 core constraint from composer.json require["typo3/cms-core"] (e.g., "^13.4 || ^14.0")
3. Determine which PHP minor versions fall in the constraint range (e.g., 8.2, 8.3, 8.4)
4. Determine which TYPO3 major versions fall in the constraint range (e.g., 13, 14)
5. Check the CI workflow (.github/workflows/*.yml) matrix or reusable workflow inputs
6. Flag any PHP version in the supported range that is missing from the CI matrix
7. Flag any TYPO3 version in the supported range that is missing from the CI matrix
8. If using netresearch/typo3-ci-workflows, check php-versions and typo3-versions inputs
severity: warning
desc: "CI should test all PHP versions in composer.json require.php range x all TYPO3 versions in require[\"typo3/cms-core\"] range"
# === E2E MULTI-BROWSER COVERAGE ===
- id: TT-17b
domain: testing
prompt: |
If a Playwright config exists (Build/playwright.config.ts or playwright.config.ts):
1. Read the projects array in the Playwright config
2. Verify it defines at least two browser projects: Chromium and Firefox
3. Optionally check for WebKit as a third browser
4. Flag if only a single browser is configured, as cross-browser testing catches
rendering and API differences that single-browser testing misses
severity: warning
desc: "If playwright.config.ts exists, it should define at least Chromium and Firefox projects for cross-browser E2E coverage"
# === PATCH COVERAGE ===
- id: TT-89
domain: testing
prompt: |
Review whether new code paths introduced in recent changes have corresponding tests:
1. Identify new public methods in Classes/ that were added or significantly changed
2. For each new public method, check whether a test exists that calls or exercises it
3. Focus on Classes/Service/, Classes/Controller/, and Classes/ViewHelpers/ directories
4. Flag any new public method that has no corresponding test coverage
5. Ignore getters/setters, constructors, and simple delegation methods
severity: warning
desc: "New code paths (especially new public methods) should have corresponding unit or functional tests"
# === TEST UID SETUP ===
- id: TT-112
domain: testing
prompt: |
Review test helpers and factories that create entity or model mocks:
1. Find helper methods that create domain model instances for testing
(e.g., createSecretEntity, createMockRecord, buildFixture)
2. For each helper, check whether the production code that consumes
these entities guards on UID being set (e.g., `if ($uid !== null)`,
`if ($entity->getUid() > 0)`, `$uid ?? throw`)
3. If the production code has a UID null-check, verify the test helper
sets a UID via `_setProperty('uid', ...)` or `setUid(...)` or reflection
4. Flag any test helper that creates entities without setting UID when
the consuming production code guards on UID presence
severity: warning
desc: "Test helpers creating entity mocks must set UID when production code guards with null/zero UID checks"
# === MOCK CONSTRUCTOR SYNC ===
- id: TT-113
domain: testing
prompt: |
Review whether test mocks stay in sync with class constructors:
1. Find classes in Classes/ whose constructors have changed recently
or have 3+ parameters (likely to drift)
2. For each class, find all tests that instantiate it directly
(new ClassName(...)) or via createMock/getMockBuilder
3. Check that direct instantiations pass the correct number and types
of constructor arguments
4. For createMock/getMockBuilder, check if disableOriginalConstructor()
is used — if not, the mock will call the real constructor and must
match its signature
5. Flag any test where constructor argument count or types are mismatched
with the current class constructor
severity: warning
desc: "When a class constructor gains new parameters, all tests that instantiate it must be updated to match"
# === MOCK VALIDITY FOR MULTI-VERSION DEPENDENCIES ===
- id: TT-114
domain: testing
prompt: |
Review test mocks for multi-version dependency compatibility:
1. Read composer.json require and require-dev for dependencies with
multi-major-version constraints (e.g., "^3 || ^4", "^2 || ^3")
2. For each such dependency, find all test files that mock its interfaces
(grep for createMock/createStub with the dependency's namespace)
3. For each mocked method, verify the method exists on the **interface**
(not just the concrete class) in ALL supported major versions
4. Flag any mock that calls ->method('foo') where foo only exists on
a concrete implementation or only in one major version
5. Suggest using an adapter interface pattern when version-specific
mocking is detected
severity: warning
desc: "Test mocks on dependency interfaces must reference methods that exist in ALL supported versions of the dependency"
# === MOCK CALLBACK SIGNATURE VERIFICATION ===
- id: TT-115
domain: testing
prompt: |
Review willReturnCallback() usages for signature mismatches:
1. Find all willReturnCallback() calls in test files
2. For each callback, identify the method being mocked
3. Compare the callback's parameter list with the actual method signature
in the production class or interface
4. Flag callbacks whose parameter count or types do not match the
actual method signature
5. Flag callbacks that are missing parameters that the production code
now passes (e.g., production added a $quality parameter but the
callback only accepts $path)
6. Suggest using variadic signatures (mixed ...$options) when the
method signature may evolve across dependency versions
severity: warning
desc: "willReturnCallback() signatures must match the actual method signature; stale callbacks silently drop arguments"
# === TEST ASSERTION SPECIFICITY AFTER REFACTORING ===
- id: TT-116
domain: testing
prompt: |
Review whether test assertions maintain equivalent specificity after
production code refactoring:
1. Find test methods that assert on method calls (expects/method/with)
2. Compare these assertions with the current production code paths
3. Flag tests where a refactoring changed the production API surface
but the test assertion lost specificity. Examples:
- Old: expects(never())->method('toWebp') → New: expects(once())->method('save')
(lost the "not WebP" assertion)
- Old: expects(once())->method('resize')->with(800, 600) → New: just expects(once())->method('process')
(lost dimension verification)
4. Verify that expects(exactly(N)) with callback assertions include
argument-level checks (e.g., asserting path extensions, dimensions)
5. Flag generic willReturnCallback() that only provides return values
without asserting anything about the arguments
severity: warning
desc: "After refactoring, test assertions must maintain equivalent specificity — don't trade specific method assertions for generic ones"
# === ADAPTER PATTERN TESTING ===
- id: TT-117
domain: testing
prompt: |
Review whether tests mock third-party library internals directly
when an adapter pattern would be more appropriate:
1. Find test files that create mocks of third-party library interfaces
or classes (namespaces outside the extension's own namespace)
2. Check if the mocked third-party class has version-specific APIs
(compare composer.json constraints for multi-major-version support)
3. Flag tests that create complex mock setups for third-party internals
(e.g., mocking DriverInterface + constructing real ImageManager)
4. Suggest introducing an adapter interface owned by the extension
and mocking that instead, which eliminates version-specific mock setup
5. Exception: simple, stable interfaces (like PSR interfaces) are fine
to mock directly
severity: warning
desc: "Prefer mocking your own adapter interfaces over third-party library internals to avoid version-specific mock breakage"
references/accessibility-testing.md
# Accessibility Testing with axe-core
TYPO3 extensions should test for WCAG 2.0/2.1 compliance at levels A and AA using **axe-core** integrated with Playwright.
**Reference:** [axe-core Documentation](https://www.deque.com/axe/)
## Requirements
```json
// package.json
{
"devDependencies": {
"@playwright/test": "^1.56.1",
"@axe-core/playwright": "^4.9.0"
}
}
```
## Directory Structure
```
Build/
└── tests/
└── playwright/
└── accessibility/
├── modules.spec.ts # Backend module accessibility
├── forms.spec.ts # Form accessibility
└── navigation.spec.ts # Navigation accessibility
```
## Basic Accessibility Test
```typescript
// Build/tests/playwright/accessibility/modules.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const modules = [
{ name: 'My Extension Module', route: 'module/web/myextension' },
{ name: 'Settings', route: 'module/web/myextension/settings' },
];
for (const module of modules) {
test(`${module.name} has no accessibility violations`, async ({ page }) => {
await page.goto(module.route);
await page.waitForLoadState('networkidle');
const accessibilityScanResults = await new AxeBuilder({ page })
.include('#typo3-contentIframe')
.disableRules(['color-contrast']) // Reduce false positives
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
}
```
## Comprehensive Accessibility Tests
```typescript
// Build/tests/playwright/accessibility/comprehensive.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Accessibility - Comprehensive Checks', () => {
test('module menu has proper ARIA attributes', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const moduleMenu = page.locator('#modulemenu');
await expect(moduleMenu).toHaveAttribute('role', 'navigation');
});
test('interactive elements are keyboard accessible', async ({ page }) => {
await page.goto('module/web/myextension');
await page.waitForLoadState('networkidle');
const contentFrame = page.frameLocator('#typo3-contentIframe');
// Tab through interactive elements
await page.keyboard.press('Tab');
// Verify focus is visible
const focusedElement = contentFrame.locator(':focus');
await expect(focusedElement).toBeVisible();
});
test('forms have proper labels', async ({ page }) => {
await page.goto('module/web/myextension/edit');
await page.waitForLoadState('networkidle');
const contentFrame = page.frameLocator('#typo3-contentIframe');
// All inputs should have associated labels
const inputs = contentFrame.locator('input:not([type="hidden"])');
const count = await inputs.count();
for (let i = 0; i < count; i++) {
const input = inputs.nth(i);
const id = await input.getAttribute('id');
if (id) {
const label = contentFrame.locator(`label[for="${id}"]`);
await expect(label).toBeVisible();
}
}
});
test('images have alt text', async ({ page }) => {
await page.goto('module/web/myextension');
await page.waitForLoadState('networkidle');
const contentFrame = page.frameLocator('#typo3-contentIframe');
const images = contentFrame.locator('img');
const count = await images.count();
for (let i = 0; i < count; i++) {
const img = images.nth(i);
const alt = await img.getAttribute('alt');
expect(alt).not.toBeNull();
}
});
test('color contrast is sufficient', async ({ page }) => {
await page.goto('module/web/myextension');
await page.waitForLoadState('networkidle');
const accessibilityScanResults = await new AxeBuilder({ page })
.include('#typo3-contentIframe')
.withRules(['color-contrast'])
.analyze();
// Log violations for debugging but don't fail
// (TYPO3 backend may have known contrast issues)
if (accessibilityScanResults.violations.length > 0) {
console.log('Color contrast issues:', accessibilityScanResults.violations);
}
});
});
```
## axe-core Configuration
### Include/Exclude Elements
```typescript
const results = await new AxeBuilder({ page })
.include('#main-content') // Only scan this element
.exclude('.third-party-widget') // Skip this element
.analyze();
```
### Specific Rules
```typescript
// Run only specific rules
const results = await new AxeBuilder({ page })
.withRules(['color-contrast', 'label'])
.analyze();
// Disable specific rules
const results = await new AxeBuilder({ page })
.disableRules(['color-contrast'])
.analyze();
```
### Tags (WCAG Levels)
```typescript
// Test WCAG 2.1 Level AA
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
// Test only critical issues
const results = await new AxeBuilder({ page })
.withTags(['critical'])
.analyze();
```
## Handling Violations
```typescript
test('handles violations gracefully', async ({ page }) => {
await page.goto('module/web/myextension');
const results = await new AxeBuilder({ page })
.include('#typo3-contentIframe')
.analyze();
// Log violations with details
for (const violation of results.violations) {
console.log(`Rule: ${violation.id}`);
console.log(`Impact: ${violation.impact}`);
console.log(`Description: ${violation.description}`);
for (const node of violation.nodes) {
console.log(` Element: ${node.html}`);
console.log(` Fix: ${node.failureSummary}`);
}
}
// Assert no violations
expect(results.violations).toHaveLength(0);
});
```
## TYPO3 Backend Considerations
### Known TYPO3 Backend Issues
Some accessibility rules may produce false positives in TYPO3 backend:
```typescript
const results = await new AxeBuilder({ page })
.include('#typo3-contentIframe')
// Disable rules that conflict with TYPO3 backend design
.disableRules([
'color-contrast', // TYPO3 uses theme colors
'landmark-one-main', // Backend uses iframe structure
'region', // Content in iframes
])
.analyze();
```
### Testing Your Extension Only
Focus on elements your extension controls:
```typescript
const results = await new AxeBuilder({ page })
// Target your extension's content
.include('[data-extension="my_extension"]')
.analyze();
```
## Best Practices
**Do:**
- Test all backend modules your extension provides
- Test forms for proper labels and ARIA attributes
- Test keyboard navigation through interactive elements
- Test with screen reader users in mind
- Document known accessibility limitations
**Don't:**
- Disable all rules to make tests pass
- Skip accessibility testing entirely
- Assume TYPO3 backend handles all accessibility
- Ignore violations without documenting reason
## Checklist
- [ ] All modules tested with axe-core
- [ ] Forms have proper labels
- [ ] Interactive elements are keyboard accessible
- [ ] Images have alt text
- [ ] ARIA attributes are correct
- [ ] Focus states are visible
- [ ] Color is not the only means of conveying information
## Resources
- [axe-core Playwright Integration](https://github.com/dequelabs/axe-core-npm/tree/develop/packages/playwright)
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
- [axe-core Rules](https://dequeuniversity.com/rules/axe/)
- [TYPO3 Accessibility Guidelines](https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/Accessibility/)
references/architecture-testing.md
# Architecture Testing with phpat
PHP Architecture Tester (phpat) enforces architectural rules through automated tests.
## Installation
```bash
composer require --dev carlosas/phpat
```
## Configuration
Create `phpat.php` in project root:
```php
<?php
declare(strict_types=1);
use PhpAT\Rule\Rule;
use PhpAT\Selector\Selector;
use PhpAT\Test\ArchitectureTest;
final class ArchitectureTests extends ArchitectureTest
{
public function testServicesDoNotDependOnControllers(): Rule
{
return $this->newRule
->classesThat(Selector::haveClassName('*Service'))
->mustNotDependOn()
->classesThat(Selector::haveClassName('*Controller'))
->build();
}
public function testDomainDoesNotDependOnInfrastructure(): Rule
{
return $this->newRule
->classesThat(Selector::havePath('Domain/*'))
->mustNotDependOn()
->classesThat(Selector::havePath('Infrastructure/*'))
->build();
}
public function testEventsAreReadonly(): Rule
{
return $this->newRule
->classesThat(Selector::havePath('Event/*'))
->mustBeReadonly()
->build();
}
}
```
## TYPO3 Extension Rules
### Layer Constraints
```php
public function testCleanArchitecture(): Rule
{
return $this->newRule
->classesThat(Selector::havePath('Classes/Domain/*'))
->mustNotDependOn()
->classesThat(Selector::havePath('Classes/Controller/*'))
->andClassesThat(Selector::havePath('Classes/Command/*'))
->build();
}
```
### Service Layer Rules
```php
public function testServicesHaveInterface(): Rule
{
return $this->newRule
->classesThat(Selector::haveClassName('*Service'))
->excludingClassesThat(Selector::haveClassName('*Interface'))
->mustImplement()
->classesThat(Selector::haveClassName('*Interface'))
->build();
}
```
## Running Tests
```bash
# Via PHPUnit
vendor/bin/phpunit --testsuite Architecture
# Via runTests.sh
Build/Scripts/runTests.sh -s architecture
```
## PHPUnit Configuration
Add to `phpunit.xml`:
```xml
<testsuite name="Architecture">
<file>phpat.php</file>
</testsuite>
```
## Common Rules
| Rule | Purpose |
|------|---------|
| `mustNotDependOn` | Prevent unwanted dependencies |
| `mustImplement` | Enforce interface usage |
| `mustBeReadonly` | Enforce immutability (PHP 8.2+) |
| `mustBeFinal` | Prevent inheritance |
| `mustNotConstruct` | Enforce DI |
## Security-Critical Extensions
For security-critical code, enforce:
1. Events are readonly
2. Services don't construct other services (use DI)
3. Domain layer is isolated
4. No circular dependencies
## Hand-Rolled Reflective Sweeps on a Dual-Version Matrix
Beyond phpat, extensions write their own tests that walk `Classes/` with
reflection — API-surface snapshots, naming sweeps, "every X implements Y"
checks. On a CI matrix spanning two TYPO3 majors these hit a trap:
**`class_exists()` does not return false for a class whose parent or interface
is missing on this matrix leg — the autoload attempt THROWS an `Error`.**
Observed on a `^13.4 || ^14.3` matrix: an upgrade wizard implementing
`TYPO3\CMS\Core\Upgrades\UpgradeWizardInterface` (that name exists only on
14.x) made `class_exists()` fatal the whole sweep on every 13.4 leg, while all
14.x legs — including the local default — stayed green. The failure surfaces
one CI round trip after the test was written.
Guard the existence check and keep the two failure modes apart:
```php
try {
$loadable = class_exists($fqcn) || interface_exists($fqcn)
|| trait_exists($fqcn) || enum_exists($fqcn);
} catch (\Throwable) {
// THREW: a parent/interface comes from a package this matrix leg does
// not ship — the class cannot be part of this leg's sweep. Skip it.
continue;
}
// Clean FALSE (no throw): the PSR-4 name resolves to nothing anywhere.
// That is a broken discovery rule, not a matrix difference — keep it fatal.
self::assertTrue($loadable, sprintf('%s does not autoload', $fqcn));
```
Two consequences worth stating in the test: a snapshot-style sweep turns a
version-split class into a visible diff on the leg that lacks it (instead of a
fatal), and the `catch (\Throwable)` also swallows a `ParseError` in a swept
file — say explicitly that the lint job owns that case.
references/asset-templates-guide.md
# Asset Templates Guide
Templates and configuration files for setting up TYPO3 extension testing infrastructure.
## Infrastructure Setup
To set up Docker-based test orchestration, copy `assets/Build/Scripts/runTests.sh` to your extension. This is the **required** foundation for all test execution.
To initialize test bootstrapping, use these templates:
- `assets/bootstrap.php` - General test bootstrap with autoloader detection
- `assets/UnitTestsBootstrap.php` - Unit test bootstrap with optional TYPO3 stub autoloader
- `assets/FunctionalTestsBootstrap.php` - Functional test bootstrap for TYPO3 testing framework
## PHPUnit Configuration
To configure PHPUnit, copy and customize:
- `assets/UnitTests.xml` - Unit test suite configuration
- `assets/FunctionalTests.xml` - Functional test suite configuration
## Code Quality Tools
To set up static analysis and code style, use:
- `assets/phpstan.neon` - PHPStan level 10 configuration
- `assets/phpstan-baseline.neon` - Baseline template for legacy code migration
- `assets/phpat.php` - Architecture test rules for layer enforcement
- `assets/phpat.neon` - PHPat PHPStan extension configuration
- `assets/.php-cs-fixer.dist.php` - PHP-CS-Fixer code style rules
- `assets/rector.php` - Rector automated refactoring configuration
**CGL Enforcement:** TYPO3 CGL is strict about alignment (e.g., `binary_operator_spaces` in `setUp()` methods). Always run `composer ci:cgl` or the project's CS fixer before committing. Do not rely on manual formatting.
## Mutation Testing & Coverage
To configure mutation testing, copy `assets/infection.json5` and adjust mutator settings and MSI thresholds.
To configure coverage reporting, copy `assets/codecov.yml` for Codecov integration.
## CI/CD Workflows
To set up GitHub Actions, use:
- `assets/github-actions-tests.yml` - Main CI workflow (lint, phpstan, unit, functional tests)
- `assets/github-actions-e2e.yml` - E2E workflow with **GitHub Services + PHP built-in server** (NOT DDEV)
## E2E Testing Setup
To set up Playwright E2E testing, copy the `assets/Build/playwright/` directory containing:
- `package.json` - Node.js dependencies
- `playwright.config.ts` - Playwright configuration
- `tests/playwright/` - Test structure with login setup, fixtures, and example specs
## Development Shortcuts
To add common command shortcuts, copy `assets/Makefile` for make-based task execution.
## Docker Services
To configure additional Docker services for testing, use templates from `assets/docker/`:
- `docker-compose.yml` - Base Docker Compose configuration
- `codeception.yml` - Codeception-specific Docker setup
## Example Tests
To see test patterns in action, review examples in `assets/example-tests/`:
- `ExampleUnitTest.php` - Unit test structure and assertions
- `ExampleFunctionalTest.php` - Functional test with fixtures
- `ExampleAcceptanceCest.php` - Codeception acceptance test
## Database Fixtures
To set up test data, use CSV fixtures from `assets/fixtures/`:
- `be_users.csv` - Backend user fixture with password hashes
- `pages.csv` - Page tree structure
- `tt_content.csv` - Content elements
- `sys_category.csv` - Category hierarchy
Consult `assets/fixtures/README.md` for fixture format documentation.
## AI Agent Documentation
To document AI agent behavior for your extension, use `assets/AGENTS.md` as a template.
references/backend-module-render-verification.md
# Backend Module Render Verification
> Fluid templates escape every static gate — render the actual module before calling it done.
## Why this matters
`cgl`, `phpstan` (even level 10), and unit tests do **not** parse Fluid. A backend
module can have green CI across the board and still throw an HTTP 500 the moment a
human opens it, because the only thing that exercises the template is an actual
render. "All checks pass" is **not** evidence that a backend module renders.
Two real failure modes that no static gate catches:
| Trap | Symptom | Cause |
|------|---------|-------|
| Wrong ViewHelper namespace | **Whole module 500s** (parse-time, before any output) | e.g. `<be:infobox>` instead of `<f:be.infobox>` — an unregistered namespace prefix is a template **parse** error, not a runtime one, so it takes down the entire view |
| Unbounded chart/canvas | Page balloons (a `<canvas>` grew to 6543px tall) | Chart.js (or similar) with `maintainAspectRatio: false` inside a container that has no fixed height — the canvas keeps growing every reflow |
## Verify the render — three complementary layers
### 1. StandaloneView (functional, no browser)
For ViewHelper-level correctness, render the template in a functional test (see
`functional-testing.md`). This catches namespace registration, argument, and output
errors **without** a browser and runs in CI.
`StandaloneView` is the v13 way and is **gone on 14.3 and main** — resolve the view
through `ViewFactoryInterface` / `ViewFactoryData` instead, which exist on 13.4,
14.3 and main and therefore survive the whole CI matrix.
Limitation: it does **not** reproduce the `ModuleTemplate` / backend doc-header
context, asset inclusion (CSS/JS), or browser layout — so it cannot catch the
canvas-height trap or a CSS/JS load-order problem.
### 2. Render-action functional test (automated, CI-able)
Between StandaloneView and a live browser sits a layer that renders the WHOLE
action — controller, `ModuleTemplateFactory`, Fluid template, doc-header
buttons — inside a functional test. Construct the controller from real
container services and set only the Extbase request by reflection:
```php
$controller = new TaskListController(
$this->get(ModuleTemplateFactory::class),
$this->get(IconFactory::class),
$this->get(TaskRepository::class),
$this->get(BackendUriBuilder::class),
$this->get(UsageAnalyticsServiceInterface::class),
);
// Backend request: applicationType BE + backend Route (packageName resolves
// the template root paths) + extbase params + normalizedParams; assign it to
// $GLOBALS['TYPO3_REQUEST'] and reflection-set the controller's $request.
$this->setPrivateProperty($controller, 'request', $this->createBackendRequest());
$response = $controller->listAction();
self::assertSame(200, $response->getStatusCode());
self::assertStringContainsString('Test Manual Task', (string)$response->getBody());
```
Also set up a backend admin (`setUpBackendUser(1)`) and `$GLOBALS['LANG']` via
`LanguageServiceFactory::createFromUserPreferences()` — `LocalizationUtility`
and flash queues need them. Unset `BE_USER`/`TYPO3_REQUEST`/`LANG` in `tearDown()`.
Traps this layer has hit in practice:
| Trap | Symptom | Rule |
|------|---------|------|
| Asserting the route *identifier* | `record_edit` never appears in markup | Backend URLs render the route **path** — assert `record/edit` |
| Guard-path redirects via Extbase `uriFor()` | `location` header is `''` in the harness | The module router isn't fully wired; assert `RedirectResponse` + a non-empty flash-message queue instead of the URL string |
| Service that degrades instead of throwing | Error-path test gets a 200 preview | Read the service first — e.g. a wizard `generateTask()` that falls back to a canned result never reaches the controller's catch; test the fallback render |
| `final` constructor dependency | `createMock()` impossible in a unit test | That class is functional-test territory by construction — don't fight it with reflection hacks |
| PHPUnit ≥ 12 mock notices | "No expectations were configured…" per test | Use `self::createStub()` when only return values matter |
### 3. Live render (browser)
For anything with layout, charts, JS modules, or `ModuleTemplate` chrome, open the
module in a running backend (a live render is a browser/manual step, **not** an
automated test — run schema/CLI against the same binaries CI uses, not through DDEV):
```bash
# 1. Apply the schema (v14: extension:setup — NOT database:updateschema, which was removed)
vendor/bin/typo3 extension:setup
# (or: php vendor/bin/typo3 extension:setup)
# 2. Open the module in a running backend (the typo3-ddev skill covers spinning one
# up locally + the URL scheme), then check:
# - HTTP 200, not 500 (a 500 here is almost always a Fluid parse error)
# - no console errors / no "Chart.js not available" (classic-script vs ES-module load order)
# - canvases/charts have a sane bounded height
```
Take the verification screenshot at **≥1440px** viewport (narrow viewports hide
sidebar/column overflow). The screenshot doubles as documentation evidence.
## Ad-hoc: render a template from the CLI, no test and no page tree
To settle a "does this attribute actually reach the markup" question without
writing a test, boot TYPO3 in a CLI script and hand the source straight to a
rendering context. This runs against the project's own `vendor/`, so it answers
for the version that is actually installed:
```php
<?php
// usage: php render.php <template-file> [<section>]
$templateFile = $argv[1] ?? throw new InvalidArgumentException('template file expected');
$section = $argv[2] ?? null;
$classLoader = require '/var/www/html/vendor/autoload.php';
SystemEnvironmentBuilder::run(0, SystemEnvironmentBuilder::REQUESTTYPE_FE);
$container = Bootstrap::init($classLoader);
$serverRequest = (new ServerRequest('https://example.local/', 'POST'))
// int bitmask, NOT ApplicationType::FRONTEND - see functional-testing.md
->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_FE)
->withAttribute('extbase', new ExtbaseRequestParameters());
$GLOBALS['TYPO3_REQUEST'] = $serverRequest;
$context = $container->get(RenderingContextFactory::class)->create();
$context->setRequest(new ExtbaseRequest($serverRequest));
$context->getTemplatePaths()->setTemplateSource(file_get_contents($templateFile));
$view = new TemplateView($context);
echo $section === null
? $view->render()
: $view->renderSection($section, ['someVariable' => 'value'], true);
```
Three things cost time when this is written from scratch:
- **`RenderingContextFactory::create()` reads `$GLOBALS['TYPO3_REQUEST']` itself**
(through `ViewHelperResolver`), so the global must be set *before* the factory
call — not only on the context. A wrong or missing `applicationType` throws
`RuntimeException` 1606222812 from inside `create()`, before any view helper
runs, so a `try/catch` around `render()` never sees it.
- **A template starting with `<f:layout>` fails** with *The Fluid template files ""
could not be loaded* unless the layout paths are set. Pass the section name as
the second argument above and the script calls `renderSection()` instead.
- **`f:form` still needs a real frontend.** It builds its action URI through
routing, which needs a site and a page tree. Extract the field view helpers into
a snippet and render those; everything except the `<form>` element is reachable
this way.
## Checklist before declaring a backend module "done"
- [ ] Module opens with **HTTP 200** in a real backend (not just green CI)
- [ ] Every ViewHelper namespace used in the template is registered (`<f:…>`, or a declared custom namespace) — a typo'd prefix is a whole-template 500
- [ ] Charts/canvases sit in a **fixed-height** wrapper; no unbounded growth
- [ ] Browser console is clean (no asset load-order / missing-global errors)
- [ ] (Optional but cheap) a `StandaloneView` functional test renders each custom template/partial
references/backend-user-access-testing.md
# Backend-User Access in Functional Tests (non-admin, page & file mounts)
> **Source**: netresearch/t3x-nr-llm — testing per-user access enforcement on tools that egress data to an external LLM (2026-07). Verified on TYPO3 v13.4 / v14.3.
Testing that a **non-admin** backend user is correctly *confined* (to pages, languages, file mounts) is a common security requirement — and the framework setup is unobvious. These recipes let a functional test drive a real non-admin who genuinely passes or fails the core access checks, instead of a check that passes for the wrong reason.
## `groupData` overrides apply live after `setUpBackendUser()`
`setUpBackendUser($uid)` authenticates the user (so `fetchGroupData()` has run). You can then override the resolved permission data directly on `$GLOBALS['BE_USER']->groupData` and the core access methods honour it immediately — no re-auth needed:
```php
$this->setUpBackendUser(2); // a non-admin from BeUsers.csv
$beUser = $GLOBALS['BE_USER'];
self::assertInstanceOf(BackendUserAuthentication::class, $beUser); // narrows mixed for PHPStan
$beUser->groupData['webmounts'] = '5'; // getWebmounts() reads this
$beUser->groupData['tables_select'] = 'tt_content'; // check('tables_select', …) reads this
$beUser->groupData['allowed_languages'] = '0'; // checkLanguageAccess() reads this
```
## A non-admin reading a page: `readPageAccess` needs a web mount over the rootline
`BackendUtility::readPageAccess($uid, $permsClause)` returns `false` unless **both**:
1. the page matches the perms clause (`getPagePermsClause(Permission::PAGE_SHOW)`), **and**
2. `isInWebMount($uid, …)` is true — the page's **rootline** intersects `getWebmounts()`.
The web-mount step is the one that bites: giving a page `perms_everybody` is *not enough*. The web mount must cover the page's rootline. The simplest isolation is a **root page** (`pid = 0`, so its rootline is just itself) with the web mount pointing at it:
```php
$conn = $this->get(ConnectionPool::class)->getConnectionForTable('pages');
$conn->insert('pages', [
'uid' => 5, 'pid' => 0, 'title' => 'Public', 'doktype' => 1,
'sorting' => 5, 'perms_everybody' => Permission::PAGE_SHOW,
]);
$this->setUpBackendUser(2);
$GLOBALS['BE_USER']->groupData['webmounts'] = '5'; // rootline of page 5 is [5]
```
**Why it matters for a security test:** if the non-admin cannot reach *any* page, a "denied" assertion passes trivially (denied by page access, not by the thing you meant to test — e.g. a language gate). Make the user genuinely able to read the page, so the gate under test is the only variable.
## A non-admin confined to a file mount (real FAL enforcement)
To test that a tool only surfaces files inside the user's file mount, the `ResourceStorage` object must be built **while the non-admin is logged in inside a backend request** — only then does the core `StoragePermissionsAspect` (on `AfterResourceStorageInitializationEvent`) attach that user's file mounts and permissions to the storage. So insert the storage as a **DB row**, never instantiate it in `setUp`, and set a BE request in the test:
```php
private const STORAGE_CONFIGURATION = '<?xml version="1.0" ...>
<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>
</language></sheet></data></T3FlexForms>';
// setUp: real files on disk + rows only (no ResourceStorage object yet)
GeneralUtility::mkdir_deep($this->instancePath . '/fileadmin/docs');
file_put_contents($this->instancePath . '/fileadmin/docs/manual.txt', 'in');
file_put_contents($this->instancePath . '/fileadmin/top-secret.txt', 'out');
$conn->insert('sys_file_storage', ['uid' => 1, 'pid' => 0, 'name' => 'Main', 'driver' => 'Local',
'configuration' => self::STORAGE_CONFIGURATION, 'is_online' => 1, 'is_browsable' => 1, 'is_public' => 1]);
$conn->insert('sys_filemounts', ['uid' => 1, 'pid' => 0, 'title' => 'Docs', 'identifier' => '1:/docs/']);
$conn->insert('be_groups', ['uid' => 9, 'pid' => 0, 'title' => 'Docs', 'file_mountpoints' => '1',
'file_permissions' => 'readFolder,readFile']);
$conn->update('be_users', ['usergroup' => '9', 'options' => 3], ['uid' => 2]); // options=3: inherit db+file mounts
// sys_file index rows — the *_hash columns must be the real sha1 identifier hash
// that core computes, or getFile() will not resolve the row:
$conn->insert('sys_file', ['uid' => 10, 'pid' => 0, 'storage' => 1, 'identifier' => '/docs/manual.txt',
'identifier_hash' => sha1('/docs/manual.txt'), 'folder_hash' => sha1('/docs'),
'name' => 'manual.txt', 'extension' => 'txt', 'mime_type' => 'text/plain', 'size' => 2, 'missing' => 0]);
// in the test: fake the backend request so StoragePermissionsAspect fires on first storage build
$this->setUpBackendUser(2);
$GLOBALS['TYPO3_REQUEST'] = (new ServerRequest('https://typo3-testing.local/typo3/'))
->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE);
// tearDown(): unset($GLOBALS['TYPO3_REQUEST']);
```
A single indexed file's `sys_file` row needs no file on disk for `getFile()` to *resolve* it (the driver stats disk only for content reads), but the `identifier_hash` **must** be `sha1($identifier)`.
## FAL permission API: `getFile()` does NOT assert — the mock-validity trap
The most dangerous mistake here is testing enforcement against a **fabricated** mock. `ResourceStorage::getFile($identifier)` only *resolves* the index row; it never calls `assureFileReadPermission()` / `isWithinFileMountBoundaries()` and so **`setEvaluatePermissions(true)` has no effect on it**. Stubbing `getFile()->willThrowException()` for "out of mount" invents a behaviour core never exhibits — the test goes green while the production gate enforces nothing (security-theater).
The methods that *do* assert (honour `evaluatePermissions` + the attached file mounts):
- `checkFileActionPermission('read', $file)` — returns `bool` (no throw), the cleanest per-file gate;
- `isWithinFileMountBoundaries($file)` — returns `bool`;
- `getFolder($id)` / `getFilesInFolder()` — **throw** outside the mount (this is why `getFolder`-based browsing enforces while `getFile` does not).
So: verify FAL mount enforcement with the **real storage + file-mount functional recipe above**, not a `getFile` stub. See also `mock-validity.md`.
references/captainhook-setup.md
# CaptainHook Setup for TYPO3 Extensions
CaptainHook is the standard git hook framework for TYPO3/PHP projects.
It auto-installs via a Composer plugin on `composer install`.
## Netresearch Default: `Build/captainhook.json`
Keep testing/CI config under `Build/` so the repo root stays focused on
end-user files (README, LICENSE, composer.json, ext_emconf.php).
`captainhook/hook-installer` reads the config path from composer.json, so
`Build/captainhook.json` is a fully supported, equivalent location.
## How It Works
1. `Build/captainhook.json` defines hooks (pre-commit, commit-msg, pre-push).
2. `composer.json` declares the path via `extra.captainhook.config`:
```json
{
"config": {
"allow-plugins": {
"captainhook/hook-installer": true
}
},
"extra": {
"captainhook": {
"config": "Build/captainhook.json"
}
}
}
```
3. `captainhook/hook-installer` (transitive via `captainhook/captainhook` or
`netresearch/typo3-ci-workflows`) auto-installs hooks on every
`composer install`, reading the Build/ path automatically.
4. Hooks run standard CI commands locally before commit/push.
## Typical Build/captainhook.json for TYPO3
```json
{
"pre-commit": {
"actions": [
{"action": "composer ci:test:php:cgl"},
{"action": "composer ci:test:php:phpstan"}
]
},
"commit-msg": {
"actions": [
{
"action": "\\CaptainHook\\App\\Hook\\Message\\Action\\Rules",
"options": {
"rules": ["\\CaptainHook\\App\\Hook\\Message\\Rule\\MsgNotEmpty"]
}
}
]
},
"pre-push": {
"actions": [
{"action": "composer ci:test:php:unit"}
]
}
}
```
## Setup
```bash
# CaptainHook installs automatically with Composer
composer install
# Verify hooks are installed
ls -la .git/hooks/pre-commit
```
## Migrating from root `captainhook.json`
```bash
git mv captainhook.json Build/captainhook.json
# then add to composer.json:
# "extra": {"captainhook": {"config": "Build/captainhook.json"}}
composer install # reinstalls hooks from the new path
```
## Troubleshooting
- If hooks don't install: `vendor/bin/captainhook install --force --configuration=Build/captainhook.json`
- Git worktrees: create the hooks dir first: `mkdir -p $(git rev-parse --git-dir)/hooks`
- See `typo3-ci-workflows` README for the worktree workaround.
references/ci-cd.md
# 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
```
### Xdebug vs PCOV for Coverage
**Xdebug is recommended for CI/CD.** PCOV is faster but gives up enough diagnostic fidelity and local/CI parity that the tradeoff rarely pays off in practice.
| Aspect | Xdebug | PCOV |
|--------|--------|------|
| **Local/CI parity** | ✅ Local coverage runs typically set `XDEBUG_MODE=coverage`; keeping CI on xdebug means local and CI behave identically | ❌ Mismatches local; leaks `beStrictAboutCoverageMetadata`-style drift |
| **Branch coverage** | ✅ Branch + path coverage | ❌ Line-only |
| **Purpose** | Debugger + Profiler + Coverage | Coverage only |
| **Speed** | Slower (debugger overhead) | 2-5× faster |
| **Memory** | Higher (full debugger loaded) | Lower footprint |
**Why Xdebug is the better default:**
- **Strict coverage metadata**: PHPUnit's `beStrictAboutCoverageMetadata="true"` marks tests as "risky" when they execute code outside their declared `#[CoversClass]` / `#[UsesClass]` attributes. The check only runs under active coverage. Mixing local Xdebug with CI PCOV produced "green locally, red in CI" surprises — switching both to Xdebug eliminates that drift. Observed concretely in [t3x-nr-image-optimize#93](https://github.com/netresearch/t3x-nr-image-optimize/pull/93).
- **Branch + path coverage**: Xdebug sees `if/else` branches and early returns. PCOV reports only which lines executed, losing the "did we actually test the else-branch?" signal. Matters for Codecov trend reports and mutation testing preparation.
- **Cost**: ~2-3 min extra CI runtime across a typical 8-job matrix. Acceptable for the diagnostic gain.
**When PCOV is still the right call:**
- CI matrix has so many jobs (e.g. 20+ combinations) that the 2-5× coverage speed-up genuinely matters.
- Coverage runs are gated (only on the default branch, not every PR).
- You're comfortable maintaining `[CoversClass]` / `[UsesClass]` declarations that pass locally and in CI — see the "Strict coverage metadata" note below.
**Via `netresearch/typo3-ci-workflows`:**
As of [netresearch/typo3-ci-workflows#72](https://github.com/netresearch/typo3-ci-workflows/pull/72), the default `coverage-tool` is `xdebug`. Consumers can still override explicitly:
```yaml
jobs:
ci:
uses: netresearch/typo3-ci-workflows/.github/workflows/ci.yml@main
with:
upload-coverage: true
# coverage-tool defaults to xdebug (recommended)
# coverage-tool: 'pcov' # opt in to pcov for faster runs
```
**Standalone GitHub Actions setup:**
```yaml
- name: Setup PHP with Xdebug (recommended)
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: xdebug
# Or opt into PCOV when CI runtime matters more than coverage fidelity:
- name: Setup PHP with PCOV
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: pcov
```
**Local execution via `Build/Scripts/runTests.sh`:**
The canonical TYPO3 core-testing pattern runs the suite inside the
`ghcr.io/typo3/core-testing-*` Docker images, which already have
xdebug available. Pass `XDEBUG_MODE=coverage` into the container so
PHPUnit picks xdebug (not pcov, when both are installed):
```bash
# Recommended: xdebug coverage via runTests.sh
XDEBUG_MODE=coverage Build/Scripts/runTests.sh -s unit -- \
--coverage-clover=coverage.xml
# Functional suite with coverage
XDEBUG_MODE=coverage Build/Scripts/runTests.sh -s functional -- \
--coverage-clover=coverage-functional.xml
```
**Direct PHPUnit (when runTests.sh is not in use):**
```bash
# xdebug (matches CI default)
php -d xdebug.mode=coverage vendor/bin/phpunit \
--coverage-clover coverage.xml
# pcov (opt-in, speed over fidelity)
php -d pcov.enabled=1 -d xdebug.mode=off vendor/bin/phpunit \
--coverage-clover coverage.xml
```
#### Strict coverage metadata
If `Build/UnitTests.xml` / `Build/FunctionalTests.xml` sets `beStrictAboutCoverageMetadata="true"` together with `failOnRisky="true"`, every test must declare every class it executes via `#[CoversClass]` or `#[UsesClass]` — otherwise the coverage-driven check flags the test as risky and fails the run.
- Unit tests: strict metadata is natural — a unit test touches exactly one class.
- Functional / integration tests: expect to declare the full transitive dependency chain via `#[UsesClass]`. Example:
```php
#[CoversClass(ProcessingMiddleware::class)]
#[UsesClass(Processor::class)]
#[UsesClass(ImageManagerAdapter::class)]
#[UsesClass(ImageManagerFactory::class)]
#[UsesClass(VariantServedEvent::class)]
final class ProcessingMiddlewareTest extends FunctionalTestCase
```
If maintaining those lists across DI refactorings costs too much, relax to `beStrictAboutCoverageMetadata="false"` *on functional tests only* — keep unit tests strict.
> **Note:** PHPUnit auto-detects available coverage drivers. If both are
> present (some Docker images install both), PHPUnit prefers PCOV — set
> `XDEBUG_MODE=coverage` or pass `-d pcov.enabled=0` to force Xdebug.
## E2E Testing in CI
> **IMPORTANT: Do NOT use DDEV in CI!**
>
> DDEV is for local development only. Use GitHub Services + PHP built-in server for E2E tests in CI.
### Why NOT DDEV in CI?
| Issue | Impact |
|-------|--------|
| **Slow startup** | 2-3+ minutes for Docker orchestration |
| **Complexity** | Docker-in-Docker, networking, volumes |
| **Resource heavy** | Multiple containers exceed runner limits |
| **Fragile** | Port conflicts, DNS issues, cert problems |
| **Non-standard** | TYPO3 Core uses direct PHP, not DDEV |
### Correct E2E CI Pattern: GitHub Services
```yaml
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20
# GitHub Services - database container
services:
db:
image: mariadb:11.4
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: typo3
MYSQL_CHARSET: utf8mb4
MYSQL_COLLATION: utf8mb4_unicode_ci
ports:
- 3306:3306
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: mysqli, pdo_mysql, gd, intl
- name: Install Composer dependencies
run: composer install --prefer-dist --no-progress
- name: Setup TYPO3
run: |
mkdir -p .Build/Web/typo3conf
cat > .Build/Web/typo3conf/LocalConfiguration.php << 'EOF'
<?php
return [
'DB' => ['Connections' => ['Default' => [
'driver' => 'mysqli',
'host' => '127.0.0.1',
'dbname' => 'typo3',
'user' => 'root',
'password' => 'root',
]]],
'SYS' => [
'encryptionKey' => '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
'trustedHostsPattern' => 'localhost|127\\.0\\.0\\.1',
],
];
EOF
# Wait for database
for i in {1..30}; do
mysqladmin ping -h127.0.0.1 -uroot -proot --silent 2>/dev/null && break
sleep 2
done
.Build/bin/typo3 extension:setup --no-interaction
.Build/bin/typo3 backend:user:create --username=admin --password='Joh316!!' --admin --no-interaction
.Build/bin/typo3 cache:flush
- uses: actions/setup-node@v4
with:
node-version: '20' # Use LTS
- name: Install Playwright
run: |
npm ci
npx playwright install --with-deps chromium
# PHP built-in server (NOT DDEV)
- name: Start PHP server
run: |
php -S 0.0.0.0:8080 -t .Build/Web > /tmp/php-server.log 2>&1 &
for i in $(seq 1 30); do
curl -sf http://localhost:8080/typo3/ > /dev/null 2>&1 && break
sleep 1
done
- name: Run Playwright tests
env:
TYPO3_BASE_URL: http://localhost:8080
run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: Tests/E2E/Playwright/reports/
```
### Dual-Mode Playwright Configuration
Support both local (DDEV) and CI (localhost) environments:
```typescript
// playwright.config.ts
export default defineConfig({
use: {
// DDEV for local, CI sets TYPO3_BASE_URL=http://localhost:8080
baseURL: process.env.TYPO3_BASE_URL || 'https://my-extension.ddev.site',
ignoreHTTPSErrors: true, // For DDEV self-signed certs
},
});
```
### runTests.sh Integration
The `runTests.sh` script should support both modes:
```bash
run_playwright_tests() {
local typo3_base_url="${TYPO3_BASE_URL:-https://my-extension.ddev.site}"
# Check if TYPO3 is accessible (use -k for https/DDEV)
local curl_opts="-s"
[[ "${typo3_base_url}" == https://* ]] && curl_opts="-sk"
if ! curl ${curl_opts} "${typo3_base_url}/typo3/" > /dev/null 2>&1; then
if [[ "${PLAYWRIGHT_FORCE:-0}" != "1" ]]; then
echo "Error: TYPO3 not responding at ${typo3_base_url}"
echo "Set PLAYWRIGHT_FORCE=1 to override."
exit 1
fi
fi
export TYPO3_BASE_URL="${typo3_base_url}"
npm run test:e2e
}
```
## 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 Driver Issues
When PHPUnit config files include `<coverage>` sections, tests will fail if no coverage driver (xdebug/pcov) is available. Add `--no-coverage` flag when coverage is disabled:
```yaml
- name: Run unit tests
run: |
if [ "$COVERAGE_ENABLED" = "true" ]; then
vendor/bin/phpunit -c Build/phpunit/UnitTests.xml
else
vendor/bin/phpunit -c Build/phpunit/UnitTests.xml --no-coverage
fi
```
**Common error**: `PHPUnit\Framework\InvalidArgumentException: No code coverage driver available`
**Solution**: Either install a coverage driver (xdebug, pcov) or pass `--no-coverage`:
```bash
# Install pcov for faster coverage
pecl install pcov
# Or disable coverage in CI
vendor/bin/phpunit --no-coverage
```
### 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
```
### Debugging "green before, red now" on unchanged code
TYPO3 extensions are libraries and **do not commit `composer.lock`** — so every
CI run does a fresh `composer update` and resolves dependencies (including the
dev toolchain: PHPStan, Rector, php-cs-fixer, testing-framework) to the latest
versions allowed by `composer.json`. A green pipeline can therefore turn red with
**no change to your code**, simply because an upstream package published a new
release between runs.
When a check fails on a commit (or PR) that previously passed with identical
code, suspect a fresh upstream release before touching your own code:
```bash
# Which version did the failing run install vs. a previous green run?
# composer logs full package names, e.g. "Installing phpstan/phpstan (2.2.2)".
gh run view <run-id> --log | grep -iE "Installing (phpstan|rector|friendsofphp|typo3/testing-framework)/"
# Cross-check release dates on Packagist. The /p2/ endpoint returns versions
# newest-first, so the first entries are the most recent releases.
curl -s https://repo.packagist.org/p2/phpstan/phpstan.json \
| php -r '$d = json_decode(file_get_contents("php://stdin"), true); foreach (array_slice($d["packages"]["phpstan/phpstan"], 0, 6) as $v) { echo $v["version"] . " " . $v["time"] . PHP_EOL; }'
```
Reproduce deterministically by pinning the suspect version locally
(`composer require --dev "phpstan/phpstan:X.Y.Z"`), confirm it fails, then revert
to the floating constraint after the fix. Note that a newer analyzer release is
often a **true positive** surfacing a latent bug — fix the code, don't pin to
escape it. Pin only as a temporary, documented escape hatch when the release is
genuinely broken — and exclude just the broken release *in addition to* your
normal range (e.g. `^1.12,!=1.12.3`), never a bare `!=1.12.3` (which would also
permit unexpected major upgrades), so future fixes still flow in.
## 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']
```
## Netresearch CI Integration
Netresearch TYPO3 extensions use reusable workflows from `netresearch/typo3-ci-workflows` instead of defining CI steps directly in project repositories.
### Core Principle
**NEVER add direct GitHub Actions steps (checkout, setup-php, composer install, etc.) to project workflows.** All CI logic is centralized in reusable workflows provided by `netresearch/typo3-ci-workflows`. Projects only configure inputs and call the shared workflows.
### Single Dev Dependency
```bash
composer require --dev netresearch/typo3-ci-workflows:^1.1
```
This single package transitively provides all quality tools (PHPStan, php-cs-fixer, Rector, PHPUnit, Infection, phpat, captainhook, etc.), shared configurations, and reusable GitHub Actions workflows.
### Workflow Configuration (.github/workflows/ci.yml)
```yaml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
ci:
uses: netresearch/typo3-ci-workflows/.github/workflows/ci.yml@main
with:
php-versions: '["8.2", "8.3", "8.4", "8.5"]'
typo3-versions: '["^13.4"]'
```
### Push Trigger: Restrict to main
The `push` trigger MUST be restricted to `branches: [main]` to avoid duplicate CI runs. Without this restriction, pushing to a branch with an open PR triggers both a `push` event and a `pull_request` event, resulting in duplicate workflow runs:
```yaml
# Correct: push only on main, PR on all branches
on:
push:
branches: [main]
pull_request:
# Wrong: push on all branches causes duplicate runs with PRs
on:
push:
pull_request:
```
### Test Matrix
The reusable workflow handles the full test matrix based on inputs:
| Dimension | Values | Notes |
|-----------|--------|-------|
| PHP | 8.2, 8.3, 8.4, 8.5 | All versions TYPO3 v13 supports |
| TYPO3 | ^13.4 | Current LTS |
| Database | pdo_sqlite (default) | Configurable per project |
### PHPStan Extensions Auto-Discovery
When using `netresearch/typo3-ci-workflows`, PHPStan extensions (phpstan-strict-rules, phpstan-typo3, phpstan-phpunit, etc.) are auto-discovered by `phpstan/extension-installer`. Do NOT manually include extension neon files in your `phpstan.neon`:
```neon
# Correct: only include shared config and baseline
includes:
- %currentWorkingDirectory%/.Build/vendor/netresearch/typo3-ci-workflows/config/phpstan/phpstan.neon
- phpstan-baseline.neon
parameters:
paths:
- ../Classes
- ../Tests/Architecture
```
```neon
# Wrong: manual includes cause duplicate registration errors
includes:
- %currentWorkingDirectory%/.Build/vendor/phpstan/phpstan-strict-rules/rules.neon
- %currentWorkingDirectory%/.Build/vendor/saschaegerer/phpstan-typo3/extension.neon
```
**Exception:** When using git worktrees with `composer install --no-plugins`, use the explicit includes file `includes-no-extension-installer.neon` instead (see quality-tools.md for details).
### What the Reusable Workflow Runs
The CI workflow executes these checks (projects do not need to define them):
1. **Linting** -- PHP syntax validation
2. **Code style** -- php-cs-fixer dry-run
3. **Static analysis** -- PHPStan at level 10
4. **Unit tests** -- across PHP version matrix
5. **Functional tests** -- across PHP version matrix
6. **Mutation testing** -- Infection PHP
7. **Architecture tests** -- phpat rules
8. **Security audit** -- `composer audit`
## Coverage Reporting Pitfalls
### A "coverage drop" can be an expired flag, not missing tests
Codecov merges per-flag sessions (`unit`, `functional`, …) into the project
number. Flags with `carryforward: true` reuse the last uploaded session — and
when that session **expires** (no fresh upload within the retention window),
the flag silently contributes 0/0 lines and the project number collapses to
the remaining flags. The repo then *looks* like it lost coverage with no code
change.
Before writing tests to close a coverage gap, verify each flag is alive:
```bash
# 0/0 lines => the flag expired; the gap is a reporting artifact
curl -s "https://api.codecov.io/api/v2/github/<org>/repos/<repo>/report/?flag=functional" \
| jq '.totals | {coverage, hits, lines}'
```
Restore an expired flag by re-running the upload path — in the standard
Netresearch split (PR runs = parallel, no coverage; `schedule`/
`workflow_dispatch` = serial + Xdebug + upload) that is one dispatch:
```bash
gh workflow run ci.yml --ref main # ~15 min; matrix cells upload the flag
```
Real case: a "coverage >= 80%" goal opened against a repo reading 68.6% —
the functional flag had expired; the true full-suite number was 86%. The
dispatch met the goal; the phantom gap would have cost days of test-writing.
Corollary: measure locally only for per-file gap analysis — a full functional
suite under Xdebug on WSL2 projects to hours; the CI dispatch is the fast path.
### On a PR, the same drop is usually an upload still in flight
The expired-flag case above is the *repo-level* variant. On a pull request the
identical symptom — `codecov/project` red, coverage down several points — far
more often means one flag's upload for **this head commit** has not landed yet
at the moment Codecov computed the status. Nothing is wrong and nothing needs
doing; the status recomputes when the upload arrives.
Three signals identify it, and all three must hold:
- **`codecov/patch` is green** and says *"Coverage not affected"*. A real
regression from the PR's own diff would show up here first.
- **The head number equals one flag's standalone coverage.** The project total
has collapsed to the flags that *did* upload. Compare against
`api.codecov.io/.../report/?flag=<flag>` per flag — if head% matches `unit`
to two decimals, only `unit` is in the report.
- **The missing flag's own report is healthy** — non-zero `lines`, not `0/0`.
`0/0` means expired (previous section); healthy-but-absent means in flight.
```bash
# head total vs each flag standalone — the match names the flag that uploaded
for f in unit functional acceptance; do
printf '%s: ' "$f"
curl -s "https://api.codecov.io/api/v2/github/<org>/repos/<repo>/report/?flag=$f" \
| jq -c '.totals | {coverage, lines}'
done
```
Observed: `codecov/project` 89.60% (−4.65%) with `codecov/patch` reporting
"Coverage not affected" on a release PR that changed only a version literal and
Markdown. 89.60% was `unit` (89.54%) alone; `functional` was healthy at 39.05%
over the same 1444 lines but its matrix cells had not finished. The status went
green on its own once they did.
The window exists only when the repo's `codecov.yml` has drifted from the
shipped `assets/codecov.yml`, which sets `carryforward: true` on **every**
uploading flag. A flag without it contributes nothing until its own upload for
that commit lands; a flag with it falls back to the previous session and the
project number stays stable. So check the repo's `flags:` block against the
asset — the incident above was a repo carrying `carryforward` on `unit` only.
Until the config is aligned, treat a project-only drop on a PR as pending, not
as a finding — do not re-run CI, adjust the target, or start writing tests for
it.
### Stale coverage cache fails the suite with zero failing tests
PHPUnit's static-analysis cache (`.Build/cache/phpunit/code-coverage/`) is
written by the container user of whoever ran coverage last. A later run under
a different uid gets `file_put_contents(): Permission denied` **warnings** —
and with `failOnWarning="true"` the suite exits 1 with 0 failures/errors.
Remove the cache dir before coverage runs when containers/users alternate:
```bash
rm -rf .Build/cache/phpunit/code-coverage
```
## 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)
- [netresearch/typo3-ci-workflows](https://github.com/netresearch/typo3-ci-workflows)
## No-lock libraries resolve per PHP matrix leg — verify on each
A library without `composer.lock` resolves dependencies fresh per CI run AND per PHP version — each matrix leg can install a different dependency set, and the local default PHP's resolution is not representative. A PHPStan baseline generated on local PHP 8.5 (which resolved Symfony 8) would not have matched the 8.1/8.2 CI legs (Symfony 6.4/7.4). Before claiming a no-lock library green, re-resolve per CI PHP version: `composer config platform.php 8.1.99 && composer update --with-all-dependencies`, run the checks, repeat per leg.
references/ci-debugging.md
# Debugging CI Test Failures
## Multi-Version Error Analysis
When tests fail in CI across multiple TYPO3 versions, **always check error messages from ALL matrix combinations** (v13 AND v14, all PHP versions). Different TYPO3 versions often fail with completely different errors for the same root cause.
### Common Error Pairs
| v13 Error | v14 Error | Root Cause |
|-----------|-----------|------------|
| `parseFunc without any configuration` | `No valid attribute "applicationType"` | Missing TSFE bootstrap |
| Method signature mismatch | Missing interface method | API change between versions |
| Deprecated function warning | Fatal: undefined method | Removed API |
| Test passes | `RuntimeException` in DI container | Singleton resolution order changed |
## Debugging Checklist
1. **Get error counts per matrix:**
```bash
gh run view <RUN_ID> --log-failed 2>&1 | grep "There were"
```
2. **Compare v13 vs v14 errors** — different errors often mean different root causes
3. **Check regression scope:**
- Only your new tests fail → your test setup is incomplete
- Existing tests also fail → your change has side effects (e.g., `$GLOBALS` pollution)
4. **Get detailed errors per version:**
```bash
gh run view <RUN_ID> --log-failed 2>&1 | grep "^build.*13.4.*8.5.*Functional.*) " | head -20
gh run view <RUN_ID> --log-failed 2>&1 | grep "^build.*14.0.*8.5.*Functional.*) " | head -20
```
## Common Pitfalls
### `$GLOBALS['TYPO3_REQUEST']` Pollution
Setting `$GLOBALS['TYPO3_REQUEST']` in `setUp()` affects ALL tests in the class:
- **v14:** Requires `applicationType` attribute — missing it causes `RuntimeException` in PageRenderer/DI container resolution (63+ errors)
- **v13:** Enables additional processing paths — existing test assertions may no longer match (7+ failures)
- **Both:** the attribute value is the int bitmask `SystemEnvironmentBuilder::REQUESTTYPE_FE`/`_BE`, not the `ApplicationType` enum case — `ApplicationType::fromRequest()` guards with `is_int()` and throws `RuntimeException` 1606222812 otherwise
**Fix:** Set the global only in specific test methods that need it, with `try/finally` cleanup:
```php
$GLOBALS['TYPO3_REQUEST'] = $this->request
->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_FE);
try {
// test code
} finally {
unset($GLOBALS['TYPO3_REQUEST']);
}
```
### Functional Tests Cannot Call `parseFunc()` with TypoScript References
`ContentObjectRenderer::parseFunc($html, null, '< lib.parseFunc_RTE')` requires:
- TypoScript configuration loaded (v13: `LogicException`)
- Full request with `applicationType` (v14: `RuntimeException`)
- `$GLOBALS['TYPO3_REQUEST']` for child cObj instances
**Solution:** Use unit tests (mock `parseFunc`) + E2E tests (real frontend). See `functional-testing.md` for details.
### Test Isolation Between Matrix Entries
Each matrix entry (PHP version × TYPO3 version) runs independently. A test passing on `8.2 + v13` but failing on `8.5 + v14` indicates version-specific behavior, not flakiness.
## Run each gate in the image its own CI job uses
A pipeline rarely runs every job in one image, and the differences are not
cosmetic. In one repository `test:php`, `test:rector` and `test:phpstan` run in
`ghcr.io/devgine/composer-php:v2-php8.4-alpine` while `test:unit` runs in plain
`php:8.4` and installs what it needs first:
```yaml
before_script:
- apt-get install git unzip zlib1g-dev libzip-dev -yqq
- docker-php-ext-install zip
```
The alpine image has **no `ext-zip`**. Reproducing the suite there makes every
zip-touching test error out locally while CI is green, and the failure names the
test, not the image — so it reads as a broken test. `composer install
--ignore-platform-reqs`, which these pipelines use, removes the one signal that
would have said otherwise.
Read `.gitlab-ci.yml` / the workflow file for the `image:` **and** the
`before_script:` of the specific job before reproducing it, and mirror both. A
throwaway Dockerfile that copies the job's `before_script` is worth it as soon as
you run the suite more than twice:
```dockerfile
FROM php:8.4
RUN apt-get update -yqq \
&& apt-get install -yqq git unzip zip zlib1g-dev libzip-dev \
&& docker-php-ext-install zip
```
Also mirror the flags: the phpstan job runs `php -d memory_limit=2G`, and without
it PHPStan dies with *reached configured PHP memory limit: 128M* and reports
"Found 2 errors" that have nothing to do with the code.
## Testing-Framework Version Mapping
| testing-framework | PHPUnit | TYPO3 Versions |
|-------------------|---------|----------------|
| v8 | 10 | 12.4, 13.4 |
| v9 | 11 | 13.4, 14.0+ |
## PHPUnit 11 Compatibility Issues
### Final TestCase Constructor
PHPUnit 11 makes `TestCase::__construct()` final. Extensions that override the constructor will fail:
```
Cannot override final method PHPUnit\Framework\TestCase::__construct()
```
**Fix:** Replace constructor-based initialization with property declarations:
```php
// ❌ PHPUnit 11: Fatal error
abstract class ExtensionTestCase extends FunctionalTestCase
{
public function __construct(string $name = '')
{
parent::__construct($name);
$this->coreExtensionsToLoad = ['install'];
$this->testExtensionsToLoad = ['vendor/extension'];
}
}
// ✅ Works with both PHPUnit 10 and 11
abstract class ExtensionTestCase extends FunctionalTestCase
{
protected array $coreExtensionsToLoad = ['install'];
protected array $testExtensionsToLoad = ['vendor/extension'];
}
```
### CGL vs PHPStan Conflict for Static Assertions
PHPUnit 11 marks assertion methods (`assertEquals`, `assertSame`, etc.) as non-static, but TYPO3 CGL (php-cs-fixer) enforces `self::assertEquals()` style.
**Resolution:** CGL is authoritative for code style. Suppress PHPStan false positives:
```yaml
# Build/phpstan/phpstan.neon
parameters:
ignoreErrors:
-
message: '#Call to an undefined static method .+::(assert|fail|mark)#'
reportUnmatched: false
```
`reportUnmatched: false` is essential — on TYPO3 12.4 with testing-framework v8 (PHPUnit 10), the pattern has no matches.
## Archived TYPO3-CI GitHub Actions
Several TYPO3 CI GitHub Actions have been archived and their Docker images return 403 Forbidden:
| Action | Status | Replacement |
|--------|--------|-------------|
| `TYPO3-CI-Xliff-Lint` | Archived (2021) | DIY `xmllint --schema xliff-core-1.2-strict.xsd` or remove if no `.xlf` files |
| Other `TYPO3-Continuous-Integration/*` | Check individually | May need replacement |
**Before adding an XLIFF linter:** Verify the extension actually has `.xlf` files:
```bash
find . -name '*.xlf' -not -path './.Build/*'
```
Many extensions don't ship translations and the CI job was added as boilerplate.
## Test Fixture Isolation from TYPO3 Core
When tests depend on TYPO3 core class docblocks (e.g., testing documentation generation), **use local fixture classes** instead:
**Problem:** TYPO3 core changes docblock wording between versions (e.g., "that" → "which"), causing test assertion failures across the matrix.
**Solution:** Create controlled fixture classes in `Tests/Functional/Fixtures/Extensions/`:
```php
// Local fixture with stable, controlled docblock
namespace TYPO3Tests\ExampleExtension;
class PropertyExample
{
/**
* This is set to the language that is currently running
*/
public string $lang = 'default';
}
```
Update test config to reference the fixture class instead of the core class. This decouples tests from core docblock changes across TYPO3 versions.
## phpDocumentor Version Differences in Tests
phpDocumentor v8 and v9 differ in generic type rendering:
- **v8:** Preserves original spacing: `array<string,string>`
- **v9:** Normalizes with spaces: `array<string, string>`
**Fix:** Normalize generic type spacing in code that processes phpDoc output:
```php
// Strip spaces after commas inside angle brackets
preg_replace_callback('/<[^>]+>/', static function (array $match): string {
return str_replace(', ', ',', $match[0]);
}, $type);
```
**Related bug:** Never use `explode(' ', $returnComment, 2)` to split type from description when generic types are involved — types like `array<string, string>` contain internal spaces. Use bracket-depth-aware parsing instead.
## `CoversClass` on a coverage-excluded class: red in CI, green locally
`#[CoversClass(X::class)]` where `X` is excluded from the coverage source set in `Build/phpunit.xml` emits *"Class X is not a valid target for code coverage"* — one PHPUnit warning per test, and with `failOnWarning="true"` that is a red build. The trap: the warning only exists when coverage is **enabled**. `runTests.sh -s unit` runs without coverage and reports zero warnings; CI runs with Xdebug and sees them all. Reproduce locally with `runTests.sh -s unitCoverage` before pushing, whenever you add `CoversClass` attributes or touch the coverage include/exclude lists.
references/ci-workflows-meta-package.md
# netresearch/typo3-ci-workflows Meta-Package
## What It Is
`netresearch/typo3-ci-workflows` is a Composer meta-package that bundles the full
set of dev-time tools used across all Netresearch TYPO3 extensions into one
`require-dev` entry. Instead of maintaining 10+ individual version constraints in
each extension's `composer.json`, one line brings everything in:
```bash
composer require --dev netresearch/typo3-ci-workflows
```
## What It Bundles (representative list)
| Package | Purpose |
|---|---|
| `phpunit/phpunit` (transitive via `typo3/testing-framework`) | PHPUnit test runner |
| `phpstan/phpstan` | Static analysis |
| `phpstan/phpstan-phpunit` | PHPUnit-specific rules |
| `phpstan/phpstan-strict-rules` | Strict rule set |
| `phpstan/phpstan-deprecation-rules` | Deprecation detection |
| `phpstan/extension-installer` | Auto-registers PHPStan extensions |
| `phpat/phpat` | Architecture testing |
| `saschaegerer/phpstan-typo3` | TYPO3-specific PHPStan extension |
| `infection/infection` | Mutation testing |
| `captainhook/captainhook` | Git hook automation |
| `friendsofphp/php-cs-fixer` | Code style |
| `rector/rector` | Automated refactoring |
Because `phpunit/phpunit` is transitive, **do not add a direct `phpunit/phpunit`
entry to `require-dev`** — it pins a phpunit version that may conflict with the
PHP version constraint of the extension (phpunit 12.5.8+ requires PHP >= 8.3, which
breaks the PHP-8.2 matrix cell).
## Adoption
Replace individual dev dependencies:
```json
// Before
"require-dev": {
"phpunit/phpunit": "^11 || ^12",
"phpstan/phpstan": "^2",
"phpat/phpat": "^0.11",
"infection/infection": "^0.29",
"friendsofphp/php-cs-fixer": "^3"
}
// After
"require-dev": {
"netresearch/typo3-ci-workflows": "^1"
}
```
## composer install --no-plugins Workaround
`captainhook/hook-installer` (bundled transitively) registers git hooks on every
`composer install`. In git worktree environments the `.git` directory is a file
(pointer), not a directory, which confuses the installer and emits warnings or
errors.
**Workaround for local development in a worktree:**
```bash
composer install --no-plugins
```
This skips all Composer plugins, including the hook installer. Git hooks are
managed by the bare repository's worktree setup instead.
Create a shell alias or Makefile target:
```makefile
composer-install-local:
composer install --no-plugins
```
## Build/phpstan.no-plugins.neon Pattern
When running PHPStan locally **without** `phpstan/extension-installer` (e.g. after
`composer install --no-plugins`), the auto-registered extensions are absent and
PHPStan will error on unknown rules.
Create `Build/phpstan.no-plugins.neon` for this case:
```neon
# Build/phpstan.no-plugins.neon
# Use this file locally when extension-installer is inactive
# (e.g. after: composer install --no-plugins)
#
# Usage: phpstan analyse --configuration Build/phpstan.no-plugins.neon
includes:
- phpstan.neon
- vendor/phpstan/phpstan-phpunit/extension.neon
- vendor/phpstan/phpstan-phpunit/rules.neon
- vendor/phpstan/phpstan-strict-rules/rules.neon
- vendor/phpstan/phpstan-deprecation-rules/rules.neon
- vendor/saschaegerer/phpstan-typo3/extension.neon
parameters:
# Override anything set by extension-installer in the main neon
```
**Do not add these `includes:` to the main `phpstan.neon`.** When extension-installer
is active (in CI and standard `composer install`), it already registers them, and
duplicate includes cause PHPStan to exit 1 with "These files are included multiple
times".
## Reusable CI Workflow Integration
Pair the meta-package with the reusable GitHub Actions workflow:
```yaml
# .github/workflows/ci.yml
jobs:
ci:
uses: netresearch/typo3-ci-workflows/.github/workflows/ci.yml@<SHA>
with:
php-versions: '["8.2", "8.3", "8.4"]'
typo3-versions: '["13", "14"]'
upload-coverage: true
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
```
Pin to a full 40-character SHA. Checkpoints TT-22, TT-23, TT-24, and TT-41 are
all satisfied by this single workflow call.
## Functional tests are OPT-IN — `run-functional-tests` defaults to `false`
The reusable `netresearch/typo3-ci-workflows/.github/workflows/ci.yml` gates its
functional jobs (both the SQLite job and the DB-service job) on the boolean input
`run-functional-tests`, and **its default is `false`**. A caller that never sets
it has the **entire functional job SKIPPED on every event** — pull_request,
`merge_group`, and push alike. `Functional Tests` and `Functional Tests SQLite`
show up as `skipped`, not failing, so nothing looks wrong — meanwhile the whole
`Build/FunctionalTests.xml` (every `<testsuite>` in it) never runs in CI, and
functional/backend test rot accumulates silently for months.
Turn it on explicitly:
```yaml
with:
run-functional-tests: true
```
**Verify** it actually runs, don't assume: after enabling, open a `merge_group`
(or PR) run and confirm the functional cells show `success`, not `skipped`
(`gh run view <id> --json jobs --jq '.jobs[] | select(.name | test("Functional")) | {name, conclusion}'`).
**Trade-offs of enabling it:**
- Functional now runs on PRs too, expanding the matrix (≈ one cell per
PHP × TYPO3 combination) — CI gets slower.
- If your functional suite makes real outbound calls (e.g. provider-connection
smoke tests hitting an unreachable host and waiting for a timeout), those cells
are *slow*; mock the transport or gate such tests behind a marker.
- Enabling a job that was skipped changes its required-status-check context. While
skipped, the job reported a single **bare** context (`ci / Functional Tests SQLite`)
that satisfied the required check. Enabling it makes GitHub expand that into N
**matrix** contexts (`ci / Functional Tests SQLite (8.2, ^13.4)` … `(8.5, ^14.3)`),
so the bare required context no longer reports and PRs sit permanently `BLOCKED`
with every visible check green. Fix: update the branch ruleset's
`required_status_checks` (via `gh api -X PUT repos/O/R/rulesets/<id>`, or
`gh api -X PATCH repos/O/R/branches/main/protection/required_status_checks` for
classic branch protection) to
replace the bare context with the matrix-expanded ones — mirror how Unit/PHPStan
are already listed. This is also what makes the newly-enabled job actually *gate*
merges.
## Adding a MariaDB functional leg (and its two silent traps)
The reusable `ci.yml`'s functional job runs on **one** DBMS per call, chosen by
`functional-test-db` (default `sqlite`); its two functional jobs are mutually
exclusive per call (`== sqlite` vs `!= sqlite`). To keep an extension's
MySQL-only code paths (e.g. `MATCH … AGAINST` fulltext, strict-mode inserts —
see `functional-testing.md`) exercised in CI, add a **second, narrow call** of
the reusable workflow rather than trying to run both engines in one:
```yaml
ci-functional-mariadb:
uses: netresearch/typo3-ci-workflows/.github/workflows/ci.yml@<SHA> # pin to a commit SHA
with:
php-versions: '["8.4"]'
typo3-versions: '["^14.3"]'
run-functional-tests: true
functional-test-db: mariadb
db-image: 'mariadb:11.8'
```
Two traps that make the leg silently *not* test MariaDB, or fail to start:
- **`db-image` defaults to `mysql:9.6`.** Setting `functional-test-db: mariadb`
alone does **not** run against MariaDB — the DB service image is a separate
input. Without `db-image: 'mariadb:...'` the "MariaDB leg" runs on MySQL. Set
both.
- **MariaDB images ≥ 11 used to break the reusable workflow's health check.** The
job health-checked the DB service with a hardcoded `mysqladmin ping`; MariaDB
dropped the `mysql*` compatibility symlinks at 11.0 and ships only
`mariadb-admin`, so `mariadb:11.x` never turned healthy → "Failed to initialize
container". Fixed in [typo3-ci-workflows#174](https://github.com/netresearch/typo3-ci-workflows/pull/174):
the health command now tries both binaries. A caller that still pins
`mariadb:10.11` for this reason can move to a current series — but only if it
references the workflow `@main`; a call pinned to an older SHA carries the old
health check with it.
- **Pick a series MariaDB still supports.** Per the
[maintenance policy](https://mariadb.org/about/#maintenance-policy) those are
10.11 (until 2028-02), 11.4 (2029-05), 11.8 (2028-06) and 12.3 (2029-06).
12.0–12.2 are rolling releases, not LTS, and Docker Hub stopped rebuilding
`mariadb:12.2` in May 2026 — a tag that looks current and is not.
Also expect the enabled leg to surface **pre-existing** MySQL-strict-mode bugs
in an e2e-backend suite that has only ever run on SQLite — file those separately
and, if needed, scope the leg to `--testsuite functional` (via
`functional-test-command`) until they're fixed, then drop the scoping.
references/crypto-testing.md
# Cryptographic Testing Patterns
Testing cryptographic code requires specific patterns to ensure security while maintaining testability.
## When to Apply
- Secrets management extensions
- Envelope encryption implementations
- Key derivation functions
- Token/credential storage
- Memory-safe secret handling
## Unit Testing Cryptographic Services
### Testing Encryption Services
```php
<?php
declare(strict_types=1);
namespace Vendor\Extension\Tests\Unit\Service;
use PHPUnit\Framework\Attributes\Test;
use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
use Vendor\Extension\Service\EncryptionService;
final class EncryptionServiceTest extends UnitTestCase
{
private EncryptionService $subject;
private string $testKey;
protected function setUp(): void
{
parent::setUp();
// Use deterministic test key - NEVER use production keys
$this->testKey = sodium_crypto_secretbox_keygen();
$this->subject = new EncryptionService($this->testKey);
}
protected function tearDown(): void
{
// Clear sensitive test data from memory
sodium_memzero($this->testKey);
parent::tearDown();
}
#[Test]
public function encryptAndDecryptRoundTrip(): void
{
$plaintext = 'sensitive-api-key-12345';
$encrypted = $this->subject->encrypt($plaintext);
$decrypted = $this->subject->decrypt($encrypted);
self::assertSame($plaintext, $decrypted);
self::assertNotSame($plaintext, $encrypted);
}
#[Test]
public function encryptProducesDifferentCiphertextForSamePlaintext(): void
{
$plaintext = 'secret-value';
$encrypted1 = $this->subject->encrypt($plaintext);
$encrypted2 = $this->subject->encrypt($plaintext);
// Random nonce ensures different ciphertext each time
self::assertNotSame($encrypted1, $encrypted2);
}
#[Test]
public function decryptWithWrongKeyThrowsException(): void
{
$encrypted = $this->subject->encrypt('secret');
$wrongKey = sodium_crypto_secretbox_keygen();
$wrongService = new EncryptionService($wrongKey);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Decryption failed');
$wrongService->decrypt($encrypted);
sodium_memzero($wrongKey);
}
}
```
### Testing Envelope Encryption (DEK + KEK Pattern)
Envelope encryption uses a Data Encryption Key (DEK) encrypted by a Key Encryption Key (KEK):
```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\Service\EnvelopeEncryptionService;
use Vendor\Extension\Service\KeyManagementServiceInterface;
final class EnvelopeEncryptionServiceTest extends UnitTestCase
{
private EnvelopeEncryptionService $subject;
private KeyManagementServiceInterface&MockObject $keyManagementService;
private string $testKek;
protected function setUp(): void
{
parent::setUp();
$this->testKek = sodium_crypto_secretbox_keygen();
$this->keyManagementService = $this->createMock(KeyManagementServiceInterface::class);
$this->keyManagementService
->method('getKeyEncryptionKey')
->willReturn($this->testKek);
$this->subject = new EnvelopeEncryptionService($this->keyManagementService);
}
protected function tearDown(): void
{
sodium_memzero($this->testKek);
parent::tearDown();
}
#[Test]
public function storeGeneratesUniqueDekPerSecret(): void
{
$result1 = $this->subject->store('secret1');
$result2 = $this->subject->store('secret2');
// Each secret gets its own DEK
self::assertNotSame($result1['encrypted_dek'], $result2['encrypted_dek']);
}
#[Test]
public function retrieveDecryptsWithCorrectDek(): void
{
$original = 'my-api-secret';
$stored = $this->subject->store($original);
$retrieved = $this->subject->retrieve(
$stored['encrypted_value'],
$stored['encrypted_dek'],
$stored['nonce']
);
self::assertSame($original, $retrieved);
}
#[Test]
public function keyRotationReEncryptsWithNewKek(): void
{
$original = 'secret-to-rotate';
$stored = $this->subject->store($original);
$newKek = sodium_crypto_secretbox_keygen();
$rotated = $this->subject->rotateKey($stored, $this->testKek, $newKek);
// Encrypted DEK changes, but value remains accessible
self::assertNotSame($stored['encrypted_dek'], $rotated['encrypted_dek']);
sodium_memzero($newKek);
}
}
```
## Testing Memory-Safe Secret Handling
### Verifying sodium_memzero() Usage
For security-critical code, verify that secrets are cleared from memory:
```php
<?php
declare(strict_types=1);
namespace Vendor\Extension\Tests\Unit\Http;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
use Vendor\Extension\Http\VaultHttpClient;
use Vendor\Extension\Service\VaultServiceInterface;
use Psr\Http\Client\ClientInterface;
final class VaultHttpClientTest extends UnitTestCase
{
private VaultHttpClient $subject;
private VaultServiceInterface&MockObject $vaultService;
private ClientInterface&MockObject $httpClient;
protected function setUp(): void
{
parent::setUp();
$this->vaultService = $this->createMock(VaultServiceInterface::class);
$this->httpClient = $this->createMock(ClientInterface::class);
$this->subject = new VaultHttpClient($this->vaultService, $this->httpClient);
}
#[Test]
public function secretIsRetrievedJustInTime(): void
{
// Verify secret is retrieved only when needed
$this->vaultService
->expects(self::once())
->method('retrieve')
->with('api-key-identifier')
->willReturn('secret-value');
$this->httpClient
->expects(self::once())
->method('sendRequest');
$this->subject->request('GET', 'https://api.example.com', [
'auth_secret' => 'api-key-identifier',
]);
}
#[Test]
public function secretNotRetrievedWhenNotNeeded(): void
{
// Verify no vault access for requests without auth
$this->vaultService
->expects(self::never())
->method('retrieve');
$this->subject->request('GET', 'https://api.example.com');
}
}
```
### Testing the Secret Clearing Pattern
While directly testing `sodium_memzero()` is difficult (the memory is zeroed), test the pattern:
```php
#[Test]
public function requestClearsSecretEvenOnException(): void
{
$this->vaultService
->method('retrieve')
->willReturn('secret-value');
$this->httpClient
->method('sendRequest')
->willThrowException(new \RuntimeException('Network error'));
// The implementation should use try/finally to ensure cleanup
try {
$this->subject->request('GET', 'https://api.example.com', [
'auth_secret' => 'test-key',
]);
} catch (\RuntimeException) {
// Expected - secret should still be cleared in finally block
}
// If we got here without memory issues, the pattern is correct
self::assertTrue(true);
}
```
## Test Data Patterns
### Deterministic Test Keys
```php
final class CryptoTestHelper
{
/**
* Generate a deterministic test key for reproducible tests.
* NEVER use in production - only for testing.
*/
public static function createTestKey(string $seed = 'test'): string
{
return sodium_crypto_generichash($seed, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
}
/**
* Create a test secret with cleanup callback.
* @return array{secret: string, cleanup: callable}
*/
public static function createTestSecret(string $value): array
{
$secret = $value;
return [
'secret' => $secret,
'cleanup' => static function () use (&$secret): void {
if ($secret !== '') {
sodium_memzero($secret);
}
},
];
}
}
```
### Using Test Helpers
```php
protected function setUp(): void
{
parent::setUp();
$this->testData = CryptoTestHelper::createTestSecret('api-key-123');
}
protected function tearDown(): void
{
($this->testData['cleanup'])();
parent::tearDown();
}
```
## Functional Testing Encrypted Storage
For database-backed secret storage:
```php
<?php
declare(strict_types=1);
namespace Vendor\Extension\Tests\Functional\Repository;
use PHPUnit\Framework\Attributes\Test;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
use Vendor\Extension\Repository\SecretRepository;
final class SecretRepositoryTest extends FunctionalTestCase
{
protected array $testExtensionsToLoad = ['vendor/extension'];
private SecretRepository $subject;
private string $testKey;
protected function setUp(): void
{
parent::setUp();
$this->testKey = sodium_crypto_secretbox_keygen();
$this->subject = $this->get(SecretRepository::class);
}
protected function tearDown(): void
{
sodium_memzero($this->testKey);
parent::tearDown();
}
#[Test]
public function storedSecretIsEncryptedInDatabase(): void
{
$identifier = 'test-api-key';
$plaintext = 'super-secret-value';
$this->subject->store($identifier, $plaintext, $this->testKey);
// Direct database query to verify encryption
$row = $this->getConnectionPool()
->getConnectionForTable('tx_extension_secret')
->select(['*'], 'tx_extension_secret', ['identifier' => $identifier])
->fetchAssociative();
// Value in database should NOT match plaintext
self::assertNotSame($plaintext, $row['encrypted_value']);
self::assertNotEmpty($row['encrypted_dek']);
self::assertNotEmpty($row['nonce']);
}
#[Test]
public function retrieveReturnsDecryptedValue(): void
{
$identifier = 'test-secret';
$plaintext = 'my-secret-123';
$this->subject->store($identifier, $plaintext, $this->testKey);
$retrieved = $this->subject->retrieve($identifier, $this->testKey);
self::assertSame($plaintext, $retrieved);
}
}
```
## Algorithm-Specific Nonce Lengths
Different algorithms require different nonce lengths. Using wrong nonce length reduces security.
### The Problem
```php
// ❌ WRONG - Same nonce length for all algorithms reduces entropy
private const NONCE_LENGTH = 12; // AES-GCM length
public function encrypt(string $plaintext): string
{
$nonce = random_bytes(self::NONCE_LENGTH); // Always 12 bytes!
// For XChaCha20-Poly1305, this wastes 12 bytes of nonce entropy
// (should be 24 bytes)
if ($this->algorithm === 'xchacha20') {
$nonce = str_pad($nonce, 24, "\0"); // Padding with zeros = BAD!
}
}
```
### The Fix - Dynamic Nonce Length
```php
private function getNonceLength(): int
{
return match ($this->algorithm) {
'aes-256-gcm' => SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES, // 12 bytes
'xchacha20-poly1305' => SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES, // 24 bytes
default => throw new \InvalidArgumentException('Unknown algorithm'),
};
}
public function encrypt(string $plaintext): string
{
// ✅ CORRECT - Full entropy for each algorithm
$nonce = random_bytes($this->getNonceLength());
}
```
### Testing Nonce Length
```php
#[Test]
public function aesGcmUsesCorrectNonceLength(): void
{
$service = new EncryptionService('aes-256-gcm', $this->key);
$encrypted = $service->encrypt('test');
// Extract nonce from ciphertext
$nonce = substr($encrypted, 0, SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES);
self::assertSame(12, strlen($nonce));
}
#[Test]
public function xchachaUsesCorrectNonceLength(): void
{
$service = new EncryptionService('xchacha20-poly1305', $this->key);
$encrypted = $service->encrypt('test');
// Extract nonce from ciphertext
$nonce = substr($encrypted, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
self::assertSame(24, strlen($nonce));
}
#[Test]
public function noncesAreFullyRandom(): void
{
// Verify nonce doesn't contain padding zeros
$service = new EncryptionService('xchacha20-poly1305', $this->key);
$nonces = [];
for ($i = 0; $i < 10; $i++) {
$encrypted = $service->encrypt('test');
$nonce = substr($encrypted, 0, 24);
$nonces[] = $nonce;
// No nonce should end with 12 zero bytes (padding pattern)
$lastBytes = substr($nonce, 12);
self::assertNotSame(str_repeat("\0", 12), $lastBytes);
}
// All nonces should be unique
self::assertSame(10, count(array_unique($nonces)));
}
```
## Security Test Checklist
| Test Case | Purpose |
|-----------|---------|
| Round-trip encrypt/decrypt | Basic correctness |
| Different ciphertext for same input | Nonce randomness |
| Correct nonce length per algorithm | Algorithm compliance |
| Wrong key fails decryption | Key isolation |
| Tampered ciphertext fails | Integrity protection |
| Empty input handling | Edge case security |
| Key rotation preserves access | Migration safety |
| Secret cleared after use | Memory safety |
| No plaintext in logs | Audit safety |
## Anti-Patterns to Avoid
### Never Log Secrets
```php
// WRONG - logs actual secret
$this->logger->debug('Retrieved secret: ' . $secret);
// CORRECT - log only identifier
$this->logger->debug('Retrieved secret', ['identifier' => $identifier]);
```
### Never Use Weak Keys in Tests
```php
// WRONG - predictable key
$key = str_repeat('0', 32);
// CORRECT - proper key generation
$key = sodium_crypto_secretbox_keygen();
```
### Never Skip Cleanup in Tests
```php
// WRONG - secret remains in memory
protected function tearDown(): void
{
parent::tearDown();
}
// CORRECT - explicit cleanup
protected function tearDown(): void
{
if (isset($this->testKey)) {
sodium_memzero($this->testKey);
}
parent::tearDown();
}
```
## CI Integration
For security-critical extensions, run crypto tests in isolation:
```yaml
# .github/workflows/test.yml
jobs:
crypto-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: sodium
- run: composer install
- name: Run crypto-specific tests
run: |
vendor/bin/phpunit --testsuite Unit \
--filter 'Encryption|Crypto|Secret|Vault'
```
## Randomized Property Tests — Deterministic Seeding
Redaction, sanitizer, and streaming-window code is best proven with a randomized
property test (generate many raw inputs, assert an invariant such as
`concat(redactedChunks) === redact(fullRaw)`). Such a test must be **reproducible**
on failure — but do not reach for `srand()` + `mt_rand()`:
- **`mt_rand()` gets rewritten by CGL.** The coding-standards fixer (php-cs-fixer
`random_api_migration`) rewrites `mt_rand()` → `random_int()`. `random_int()` is
a CSPRNG and **ignores `srand()`**, so a seed you set has no effect after the
fixer runs. The test still passes locally (before the fixer) and becomes silently
non-deterministic in CI (after the fixer) — a flake you cannot reproduce.
Use a hand-rolled deterministic generator in the test instead — it survives the
fixer and gives a fixed sequence per seed. Prefer a hash-based generator: it is
100% portable (no integer-overflow / float-cast platform dependence a raw LCG has
on 32-bit PHP) and just as simple:
```php
$seed = 'fixed-seed';
$counter = 0;
$roll = static function () use (&$counter, $seed): int {
// 7 hex chars = 28 bits, always fits a signed int on 32- and 64-bit PHP
return (int) hexdec(substr(hash('sha256', $seed . $counter++), 0, 7));
};
for ($i = 0; $i < 1000; $i++) {
$raw = $this->randomPayload($roll); // build input from $roll(), not mt_rand()
self::assertSame(redact($raw), implode('', $this->streamThrough($raw)));
}
```
**Rule:** never depend on `srand()`/`mt_rand()` determinism in a test when CGL runs
in the gate. A deterministic hash-based generator (or a fixed data provider) is the
portable choice.
## Resources
- [libsodium Documentation](https://doc.libsodium.org/)
- [PHP Sodium Functions](https://www.php.net/manual/en/book.sodium.php)
- [OWASP Cryptographic Storage](https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html)
references/ddev-testing.md
# DDEV Testing for TYPO3 Extensions
DDEV setup and local-environment mechanics are owned by the `typo3-ddev` skill, not this one. See its `references/`:
- `.ddev/config.yaml` setup and PHP/database version matrix -- [`quickstart.md`](https://github.com/netresearch/typo3-ddev-skill/blob/main/skills/typo3-ddev/references/quickstart.md), [`0003-php-version-management.md`](https://github.com/netresearch/typo3-ddev-skill/blob/main/skills/typo3-ddev/references/0003-php-version-management.md)
- Multi-version local testing, database snapshots, `runTests.sh` integration -- [`advanced-options.md`](https://github.com/netresearch/typo3-ddev-skill/blob/main/skills/typo3-ddev/references/advanced-options.md)
- Why not to run automated tests via `ddev exec` (masks CI-only failures) -- [`quickstart.md`](https://github.com/netresearch/typo3-ddev-skill/blob/main/skills/typo3-ddev/references/quickstart.md)
- DDEV troubleshooting -- [`troubleshooting.md`](https://github.com/netresearch/typo3-ddev-skill/blob/main/skills/typo3-ddev/references/troubleshooting.md)
For running Playwright E2E tests against a DDEV-hosted TYPO3 instance, see [`e2e-testing.md`](e2e-testing.md) in this skill (`runTests.sh` DDEV network integration, why CI uses GitHub Services instead of DDEV).
references/e2e-testing.md
# E2E Testing with Playwright
TYPO3 Core uses **Playwright** exclusively for end-to-end and accessibility testing. This is the modern standard for browser-based testing in TYPO3 extensions.
**Reference:** [TYPO3 Core Build/tests/playwright](https://github.com/TYPO3/typo3/tree/main/Build/tests/playwright)
## When to Use E2E Tests
- Testing complete user journeys (login, browse, action)
- Frontend functionality validation
- Backend module interaction testing
- JavaScript-heavy interactions
- Visual regression testing
- Cross-browser compatibility
## Requirements
```json
// package.json
{
"engines": {
"node": ">=22.18.0 <23.0.0",
"npm": ">=11.5.2"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@axe-core/playwright": "^4.10.0"
},
"scripts": {
"playwright:install": "playwright install",
"playwright:open": "playwright test --ui --ignore-https-errors",
"playwright:run": "playwright test",
"playwright:codegen": "playwright codegen",
"playwright:report": "playwright show-report"
}
}
```
## Directory Structure
```
Build/
├── playwright.config.ts # Main Playwright configuration
├── package.json # Node dependencies
├── .nvmrc # Node version (22.18)
└── tests/
└── playwright/
├── config.ts # TYPO3-specific config (baseUrl, credentials)
├── e2e/ # End-to-end tests
│ ├── backend/
│ │ └── module.spec.ts
│ └── frontend/
│ └── pages.spec.ts
├── accessibility/ # Accessibility tests (axe-core)
│ └── modules.spec.ts
├── fixtures/ # Page Object Models
│ ├── setup-fixtures.ts
│ └── backend-page.ts
└── helper/
└── login.setup.ts # Authentication setup
```
## Configuration
### Playwright Config
```typescript
// Build/playwright.config.ts
import { defineConfig } from '@playwright/test';
import config from './tests/playwright/config';
export default defineConfig({
testDir: './tests/playwright',
timeout: 30000,
expect: {
timeout: 10000,
},
fullyParallel: false, // Tests within file run sequentially (safer for state)
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined, // CI: 4 workers, Local: half of CPUs
reporter: [
['list'],
['html', { outputFolder: '../typo3temp/var/tests/playwright-reports' }],
],
outputDir: '../typo3temp/var/tests/playwright-results',
use: {
baseURL: config.baseUrl,
ignoreHTTPSErrors: true,
trace: 'on-first-retry',
},
projects: [
{
name: 'login setup',
testMatch: /helper\/login\.setup\.ts/,
},
{
name: 'accessibility',
testMatch: /accessibility\/.*\.spec\.ts/,
dependencies: ['login setup'],
use: {
storageState: './.auth/login.json',
},
},
{
name: 'e2e',
testMatch: /e2e\/.*\.spec\.ts/,
dependencies: ['login setup'],
use: {
storageState: './.auth/login.json',
},
},
],
});
```
### TYPO3-Specific Config
```typescript
// Build/tests/playwright/config.ts
export default {
baseUrl: process.env.PLAYWRIGHT_BASE_URL ?? 'http://web:80/typo3/',
admin: {
username: process.env.PLAYWRIGHT_ADMIN_USERNAME ?? 'admin',
password: process.env.PLAYWRIGHT_ADMIN_PASSWORD ?? 'password',
},
};
```
## Authentication Setup
Store authentication state to avoid repeated logins:
```typescript
// Build/tests/playwright/helper/login.setup.ts
import { test as setup, expect } from '@playwright/test';
import config from '../config';
setup('login', async ({ page }) => {
await page.goto('/');
await page.getByLabel('Username').fill(config.admin.username);
await page.getByLabel('Password').fill(config.admin.password);
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForLoadState('networkidle');
// Verify login succeeded
await expect(page.locator('.t3js-topbar-button-modulemenu')).toBeVisible();
// Save authentication state
await page.context().storageState({ path: './.auth/login.json' });
});
```
## Page Object Model (Fixtures)
Create reusable page objects for TYPO3 backend:
```typescript
// Build/tests/playwright/fixtures/setup-fixtures.ts
import { test as base, type Locator, type Page, expect } from '@playwright/test';
export class BackendPage {
readonly page: Page;
readonly moduleMenu: Locator;
readonly contentFrame: ReturnType<Page['frameLocator']>;
constructor(page: Page) {
this.page = page;
this.moduleMenu = page.locator('#modulemenu');
this.contentFrame = page.frameLocator('#typo3-contentIframe');
}
async gotoModule(identifier: string): Promise<void> {
const moduleLink = this.moduleMenu.locator(
`[data-modulemenu-identifier="${identifier}"]`
);
await moduleLink.click();
await expect(moduleLink).toHaveClass(/modulemenu-action-active/);
}
async moduleLoaded(): Promise<void> {
await this.page.evaluate(() => {
return new Promise<void>((resolve) => {
document.addEventListener('typo3-module-loaded', () => resolve(), {
once: true,
});
});
});
}
async waitForModuleResponse(urlPattern: string | RegExp): Promise<void> {
await this.page.waitForResponse((response) => {
const url = response.url();
const matches =
typeof urlPattern === 'string'
? url.includes(urlPattern)
: urlPattern.test(url);
return matches && response.status() === 200;
});
}
}
export class Modal {
readonly page: Page;
readonly container: Locator;
readonly title: Locator;
readonly closeButton: Locator;
constructor(page: Page) {
this.page = page;
this.container = page.locator('.modal');
this.title = this.container.locator('.modal-title');
this.closeButton = this.container.locator('[data-bs-dismiss="modal"]');
}
async close(): Promise<void> {
await this.closeButton.click();
await expect(this.container).not.toBeVisible();
}
}
type BackendFixtures = {
backend: BackendPage;
modal: Modal;
};
export const test = base.extend<BackendFixtures>({
backend: async ({ page }, use) => {
await use(new BackendPage(page));
},
modal: async ({ page }, use) => {
await use(new Modal(page));
},
});
export { expect, Locator };
```
## Writing E2E Tests
### Basic Test Structure
```typescript
// Build/tests/playwright/e2e/backend/module.spec.ts
import { test, expect } from '../../fixtures/setup-fixtures';
test.describe('My Extension Backend Module', () => {
test('can access module', async ({ backend }) => {
await backend.gotoModule('web_myextension');
await backend.moduleLoaded();
const contentFrame = backend.contentFrame;
await expect(contentFrame.locator('h1')).toBeVisible();
});
test('can perform action in module', async ({ backend, modal }) => {
await backend.gotoModule('web_myextension');
await backend.contentFrame
.getByRole('button', { name: 'Create new record' })
.click();
await expect(modal.container).toBeVisible();
await expect(modal.title).toContainText('Create');
await modal.close();
});
test('can save form data', async ({ backend }) => {
await backend.gotoModule('web_myextension');
const contentFrame = backend.contentFrame;
await contentFrame.getByLabel('Title').fill('Test Title');
await contentFrame.getByLabel('Description').fill('Test Description');
await contentFrame.getByRole('button', { name: 'Save' }).click();
await backend.waitForModuleResponse(/module\/web\/myextension/);
await expect(contentFrame.locator('.alert-success')).toBeVisible();
});
});
```
## Running Tests
```bash
# Install Playwright browsers
npm run playwright:install
# Run all tests
npm run playwright:run
# Run with UI mode (interactive)
npm run playwright:open
# Run specific test file
npx playwright test e2e/backend/module.spec.ts
# Run tests matching pattern
npx playwright test --grep "can access"
# Generate test code (record & playback)
npm run playwright:codegen
# Run in headed mode (see browser)
npx playwright test --headed
# Debug mode
npx playwright test --debug
# Generate HTML report
npm run playwright:report
```
## runTests.sh Integration (Recommended)
The recommended approach is to run E2E tests via `runTests.sh`, which handles Docker networking automatically:
```bash
# Start TYPO3 with ddev, then run E2E tests
ddev start && ./Build/Scripts/runTests.sh -s e2e
# Or with custom TYPO3 URL
TYPO3_BASE_URL=https://my-typo3.local ./Build/Scripts/runTests.sh -s e2e
```
### Playwright Docker Image
Use the official Playwright Docker image with pre-installed browsers:
```bash
IMAGE_PLAYWRIGHT="mcr.microsoft.com/playwright:v1.57.0-noble"
```
**Important**: Keep versions synced between `package.json` and `runTests.sh`:
- `package.json`: `"@playwright/test": "^1.57.0"`
- `runTests.sh`: `IMAGE_PLAYWRIGHT="mcr.microsoft.com/playwright:v1.57.0-noble"`
### ddev Network Integration
When ddev is running, `runTests.sh` automatically:
1. Detects ddev and gets the router IP
2. Connects Playwright container to `ddev_default` network
3. Adds `--add-host` entries for ddev hostname resolution
```bash
# In runTests.sh e2e section:
ROUTER_IP=$(docker inspect ddev-router --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
DDEV_PARAMS="--network ddev_default"
DDEV_PARAMS="${DDEV_PARAMS} --add-host my-extension.ddev.site:${ROUTER_IP}"
```
### Permission Handling
Pre-create `node_modules` and detect root-owned files:
```bash
mkdir -p node_modules
if [ "$(find node_modules -maxdepth 1 -user root 2>/dev/null | head -1)" ]; then
echo "Error: node_modules contains root-owned files."
echo "Please remove: sudo rm -rf node_modules"
exit 1
fi
```
## DDEV Integration (Alternative)
```yaml
# .ddev/docker-compose.playwright.yaml
services:
playwright:
container_name: ddev-${DDEV_SITENAME}-playwright
image: mcr.microsoft.com/playwright:v1.57.0-noble
volumes:
- ../:/var/www/html
working_dir: /var/www/html/Build
environment:
- PLAYWRIGHT_BASE_URL=http://web:80/typo3/
depends_on:
- web
```
```bash
# Run Playwright in DDEV
ddev exec -s playwright npx playwright test
```
## CI/CD Integration
> **IMPORTANT: Do NOT use DDEV in CI!**
>
> DDEV is for local development only. For CI, use GitHub Services + PHP built-in server.
> See `assets/github-actions-e2e.yml` for the full template.
### Why NOT DDEV in CI?
| Issue | Impact |
|-------|--------|
| Slow startup | 2-3+ minutes for Docker orchestration |
| Complexity | Docker-in-Docker, networking, volumes |
| Resource heavy | Multiple containers exceed runner limits |
| Fragile | Port conflicts, DNS issues, cert problems |
| Non-standard | TYPO3 Core uses direct PHP, not DDEV |
### Correct CI Pattern: GitHub Services
```yaml
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20
# Use GitHub Services for database (NOT DDEV)
services:
db:
image: mariadb:11.4
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: typo3
ports:
- 3306:3306
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: mysqli, pdo_mysql, gd, intl
- name: Install Composer dependencies
run: composer install --prefer-dist --no-progress
- name: Setup TYPO3
run: |
# Create LocalConfiguration.php with MySQL connection
mkdir -p .Build/Web/typo3conf
cat > .Build/Web/typo3conf/LocalConfiguration.php << 'EOF'
<?php
return [
'DB' => ['Connections' => ['Default' => [
'driver' => 'mysqli',
'host' => '127.0.0.1',
'dbname' => 'typo3',
'user' => 'root',
'password' => 'root',
]]],
'SYS' => [
'encryptionKey' => '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
'trustedHostsPattern' => 'localhost|127\\.0\\.0\\.1',
],
];
EOF
.Build/bin/typo3 extension:setup --no-interaction
.Build/bin/typo3 backend:user:create --username=admin --password='Joh316!!' --admin --no-interaction
.Build/bin/typo3 cache:flush
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Playwright
run: |
npm ci
npx playwright install --with-deps chromium
# Start PHP built-in server (NOT DDEV)
- name: Start PHP server
run: |
php -S 0.0.0.0:8080 -t .Build/Web > /tmp/php-server.log 2>&1 &
sleep 3
- name: Run Playwright tests
env:
TYPO3_BASE_URL: http://localhost:8080
run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: Tests/E2E/Playwright/reports/
```
### Dual-Mode Playwright Configuration
Configure Playwright to work in both environments:
```typescript
// playwright.config.ts
export default defineConfig({
use: {
// DDEV for local, localhost for CI
baseURL: process.env.TYPO3_BASE_URL || 'https://my-extension.ddev.site',
ignoreHTTPSErrors: true, // For DDEV self-signed certs
},
});
```
**Local development:** `npx playwright test` (uses DDEV default)
**CI:** Sets `TYPO3_BASE_URL=http://localhost:8080`
## Naming Conventions
- Pattern: `<feature>.spec.ts`
- Examples: `page-module.spec.ts`, `login.spec.ts`
- Location: `Build/tests/playwright/e2e/<category>/`
## Common Pitfalls
**Backend module DOM lives in an iframe — `page.locator` can't see it**
TYPO3 backend modules render inside a content iframe (`#typo3-contentIframe`;
the frame URL carries a `?token=...`). `page.locator()`, `page.getByText()` and
`page.content()` operate on the **outer shell only** — they do *not* pierce the
iframe. Asserting on module DOM from the page level **silently returns 0 / not
found**, even when the element renders perfectly. This is an easy trap: a green
shell + a failing module assertion reads like a product bug when it is only a
wrong-frame test.
```typescript
// Wrong - matches 0 elements in the outer shell, so this just times out
await expect(page.locator('#my-panel')).toBeVisible();
// Right - enter the module iframe first (see BackendPage.contentFrame above)
const frame = page.frameLocator('#typo3-contentIframe');
await expect(frame.locator('#my-panel')).toBeVisible();
```
Before concluding "the module doesn't render," dump `page.frames()` — you'll see
the shell plus the `?token=` module frame. Assert inside the latter.
**Fields in a non-active settings tab are attached, not visible**
In tabbed backend forms (e.g. the User Settings / Setup module), every tab pane
is rendered into the DOM but inactive panes are hidden via CSS. A field in a
non-default tab is therefore **attached but not visible**. Use `toBeAttached()`
to assert "the field rendered" without driving the (fragile) tab UI; reserve
`toBeVisible()` for when the field's tab is actually active.
```typescript
const frame = page.frameLocator('#typo3-contentIframe');
// Robust: proves the field rendered regardless of which tab is active
await expect(frame.locator('#my-field')).toBeAttached();
```
**WebAuthn needs a secure context, and a container name over http is not one**
Anything touching `navigator.credentials` — passkey login, WebAuthn MFA — is
unavailable unless the page is a secure context. A TYPO3 served from a container
the browser reaches by name over plain http is not: `window.isSecureContext` is
`false`, `window.PublicKeyCredential` is `undefined`, and every ceremony spec
fails on the environment rather than on the code.
Chromium trusts anything under `.localhost`, so point the browser at a name in
that space and resolve it to the container:
```typescript
const target = new URL(process.env.TYPO3_BASE_URL ?? 'http://localhost:8080');
use: {
baseURL: 'http://typo3.localhost',
launchOptions: {
args: [`--host-resolver-rules=MAP typo3.localhost ${target.host}`],
},
},
```
The replacement in a `MAP` rule may carry a port, and it overrides the
destination port — so `baseURL` stays port-less even when the instance listens
somewhere else. Measured: `MAP typo3.localhost probe-web:8080` with
`page.goto('http://typo3.localhost')` answers 200.
`--unsafely-treat-insecure-origin-as-secure` is the obvious alternative and does
not work here: Chromium honours it only together with `--user-data-dir`, and
`browserType.launch()` rejects that argument outright. Measured in the Playwright
image — plain container name: `isSecureContext=false`; with the flag: still
`false`; with the `.localhost` alias: `true`, and the CDP virtual authenticator
attaches.
The instance then sees `Host: typo3.localhost`, so anything deriving a WebAuthn
rpId or origin from the request host has to be configured to match. Gate the
rewrite on a variable your runner sets, or a run pointed at a real instance via
`TYPO3_BASE_URL` gets sent to a name its vhost never heard of.
**`page.request` resolves through Node, not through Chromium**
`--host-resolver-rules` is a browser flag. `page.request.*` and
`request.newContext()` are Playwright's own HTTP client and resolve with Node,
which knows nothing about it — so browser-driven specs pass while every API spec
dies with `getaddrinfo ENOTFOUND typo3.localhost`. Give the Playwright container
a hosts entry as well:
```bash
# The argument to add, alongside whatever the runner already passes
# (-v "${ROOT_DIR}:${ROOT_DIR}", -w, `npm ci &&`, ${IMAGE_PLAYWRIGHT}):
--add-host "typo3.localhost:${apache_ip}"
```
Read the address off the web container once it is up, since it is per run:
```bash
apache_ip=$(${CONTAINER_BIN} inspect "apache-${SUFFIX}" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
```
Measured both ways: without it `page.goto` returns 200 while `page.request` and
`request.newContext` both fail; with it all three answer 200.
**`config/system/additional.php` must assign, not return**
TYPO3 `require`s that file for its side effects and discards its return value
(`ConfigurationManager::exportConfiguration()`). A provisioning script that
writes `<?php return ['SYS' => [...]];` configures nothing at all, and the file
is valid PHP either way, so nothing catches it by inspection — the tell is an
exception in an e2e run reported by the `ProductionExceptionHandler` while the
file names the `DebugExceptionHandler`.
```php
<?php
// Wrong: TYPO3 never reads this
return ['SYS' => ['displayErrors' => 1]];
// Right
$GLOBALS['TYPO3_CONF_VARS']['SYS']['displayErrors'] = 1;
```
Worth an executed check rather than a review comment: cut the generated file
out, `require` it, and assert the keys arrive in `TYPO3_CONF_VARS`.
**A server-side file cache usually cannot be reset from the test side**
The instinct — delete the cache directory between tests to clear a counter — hits
two walls in a containerised instance. PHP-FPM typically runs as root there, so
the cache files belong to root in a directory that is not group-writable, and the
Playwright container gets `EACCES` on every attempt. Renaming the directory *is*
permitted when the parent is world-writable and still does not work: PHP-FPM
resolves the old path out of its **realpath cache** until `realpath_cache_ttl`
expires — 120 seconds by default, and configurable — and keeps writing into the
directory that was moved aside, so the counter goes on climbing in a directory
nothing is looking at.
Configure the instance instead — raise the limit, shorten the window — and keep
the deletion only as the path that works when the instance belongs to whoever
runs the suite.
**Do not assert a shared per-IP counter at this level**
Rate limiters key on the client address, and every spec in a run arrives from the
same one. A spec that deliberately exhausts the budget takes it from everything
that runs after it, and the verdicts then follow the execution order rather than
the code: which specs fail changes with the file order. Assert the refusal where
it can be driven deterministically — a unit test over the limiter service — and
keep the e2e assertion to what only this level sees, that the endpoint holds its
contract under a burst instead of answering 500.
## E2E Testing for AJAX Endpoints
Backend modules often use AJAX routes for dynamic functionality. Test these endpoints thoroughly:
### Intercepting AJAX Requests
```typescript
// Build/tests/playwright/e2e/backend/ajax-module.spec.ts
import { test, expect } from '../../fixtures/setup-fixtures';
test.describe('AJAX Endpoint Testing', () => {
test('validates form via AJAX', async ({ page, backend }) => {
await backend.gotoModule('web_myextension_wizard');
// Intercept the AJAX validation request
const validationPromise = page.waitForResponse(
(response) =>
response.url().includes('/ajax/myext/wizard/validate') &&
response.status() === 200
);
// Fill form and trigger validation
await backend.contentFrame.getByLabel('Provider Name').fill('My Provider');
await backend.contentFrame.getByLabel('API Key').fill('sk-test-123');
await backend.contentFrame.getByRole('button', { name: 'Next' }).click();
// Verify AJAX response
const response = await validationPromise;
const json = await response.json();
expect(json.success).toBe(true);
expect(json.errors).toEqual({});
});
test('handles validation errors from AJAX', async ({ page, backend }) => {
await backend.gotoModule('web_myextension_wizard');
// Submit without required fields
await backend.contentFrame.getByRole('button', { name: 'Next' }).click();
// Wait for error response
const response = await page.waitForResponse(
(r) => r.url().includes('/ajax/myext/wizard/validate')
);
const json = await response.json();
expect(json.success).toBe(false);
expect(json.errors).toHaveProperty('name');
// Verify error is displayed in UI
await expect(
backend.contentFrame.locator('.invalid-feedback')
).toBeVisible();
});
});
```
### Testing Connection/Test Buttons
```typescript
test('tests API connection via AJAX', async ({ page, backend }) => {
await backend.gotoModule('web_myextension_wizard');
// Fill connection details
await backend.contentFrame.getByLabel('API Key').fill('sk-test-123');
// Mock successful connection response
await page.route('**/ajax/myext/wizard/test-connection', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
message: 'Connection successful',
models: [
{ id: 'gpt-4o', name: 'GPT-4o' },
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
],
}),
});
});
// Click test button
const testButton = backend.contentFrame.getByRole('button', {
name: 'Test Connection',
});
await testButton.click();
// Verify success notification
await expect(page.locator('.alert-success')).toBeVisible();
// Verify models were populated
const modelSelect = backend.contentFrame.getByLabel('Model');
await expect(modelSelect.locator('option')).toHaveCount(3); // including empty option
});
test('handles connection failure gracefully', async ({ page, backend }) => {
await backend.gotoModule('web_myextension_wizard');
await backend.contentFrame.getByLabel('API Key').fill('invalid-key');
// Mock failed connection
await page.route('**/ajax/myext/wizard/test-connection', async (route) => {
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({
success: false,
message: 'Invalid API key',
}),
});
});
await backend.contentFrame
.getByRole('button', { name: 'Test Connection' })
.click();
// Verify error notification
await expect(page.locator('.alert-danger')).toBeVisible();
await expect(page.locator('.alert-danger')).toContainText('Invalid API key');
});
```
### Testing Multi-Step Wizards
```typescript
test('completes multi-step wizard', async ({ page, backend }) => {
await backend.gotoModule('web_myextension_wizard');
// Step 1: Provider
await backend.contentFrame.getByLabel('Provider Name').fill('OpenAI Prod');
await backend.contentFrame.getByLabel('API Key').fill('sk-test-key');
await backend.contentFrame
.getByRole('button', { name: 'Test Connection' })
.click();
// Wait for test to complete
await page.waitForResponse((r) =>
r.url().includes('/ajax/myext/wizard/test-connection')
);
await backend.contentFrame.getByRole('button', { name: 'Next' }).click();
// Step 2: Model (verify we advanced)
await expect(backend.contentFrame.locator('h2')).toContainText('Step 2');
await backend.contentFrame.getByLabel('Model').selectOption('gpt-4o');
await backend.contentFrame.getByRole('button', { name: 'Next' }).click();
// Step 3: Configuration
await expect(backend.contentFrame.locator('h2')).toContainText('Step 3');
await backend.contentFrame.getByLabel('Temperature').fill('0.7');
await backend.contentFrame.getByRole('button', { name: 'Finish' }).click();
// Verify completion
const saveResponse = await page.waitForResponse(
(r) =>
r.url().includes('/ajax/myext/wizard/save') && r.status() === 200
);
const result = await saveResponse.json();
expect(result.success).toBe(true);
// Verify redirect or success message
await expect(backend.contentFrame.locator('.wizard-complete')).toBeVisible();
});
```
### Testing Toggle Actions
```typescript
test('toggles record active state via AJAX', async ({ page, backend }) => {
await backend.gotoModule('web_myextension');
// Wait for list to load
await expect(backend.contentFrame.locator('table tbody tr')).toHaveCount(3);
// Click toggle button
const toggleButton = backend.contentFrame
.locator('tr')
.first()
.getByRole('button', { name: 'Toggle' });
await toggleButton.click();
// Verify AJAX call succeeded
const response = await page.waitForResponse(
(r) =>
r.url().includes('/ajax/myext/toggle') && r.status() === 200
);
const json = await response.json();
expect(json.success).toBe(true);
// Verify UI updated
await expect(toggleButton).toHaveAttribute('data-active', 'false');
});
```
### Network Request Assertions
```typescript
test('sends correct request payload', async ({ page, backend }) => {
await backend.gotoModule('web_myextension_wizard');
// Capture the request
const requestPromise = page.waitForRequest(
(r) => r.url().includes('/ajax/myext/wizard/validate')
);
await backend.contentFrame.getByLabel('Name').fill('Test Provider');
await backend.contentFrame.getByLabel('Type').selectOption('openai');
await backend.contentFrame.getByRole('button', { name: 'Validate' }).click();
const request = await requestPromise;
const postData = request.postDataJSON();
expect(postData).toEqual({
step: 'provider',
data: {
name: 'Test Provider',
type: 'openai',
},
});
});
```
### AJAX Timeout and Error Handling
```typescript
test('handles AJAX timeout gracefully', async ({ page, backend }) => {
await backend.gotoModule('web_myextension_wizard');
// Simulate slow/timeout response
await page.route('**/ajax/myext/wizard/test-connection', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 35000)); // Exceed timeout
await route.abort('timedout');
});
await backend.contentFrame.getByLabel('API Key').fill('sk-test');
await backend.contentFrame
.getByRole('button', { name: 'Test Connection' })
.click();
// Verify timeout error displayed
await expect(page.locator('.alert-warning')).toBeVisible({ timeout: 40000 });
await expect(page.locator('.alert-warning')).toContainText('timed out');
});
```
## PHP-Based E2E Testing (Alternative)
For extensions that primarily test API interactions without browser UI, PHP-based E2E tests offer a lightweight alternative to Playwright.
### When to Use PHP E2E Tests
- Testing complete workflows without browser interaction
- API endpoint verification with mocked HTTP clients
- Multi-provider integrations (LLM, payment gateways)
- When Playwright overhead is unnecessary
### Directory Structure
```
Tests/
├── E2E/
│ ├── AbstractE2ETestCase.php
│ └── Backend/
│ ├── AbstractBackendE2ETestCase.php
│ └── ConfigurationWorkflowE2ETest.php
```
### Base Test Case
```php
<?php
declare(strict_types=1);
namespace Vendor\Extension\Tests\E2E;
use GuzzleHttp\Psr7\HttpFactory;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
/**
* Base class for PHP-based End-to-End tests.
*
* E2E tests verify complete workflows from service entry point
* through to response handling, using mocked HTTP clients to
* simulate external API interactions.
*/
abstract class AbstractE2ETestCase extends TestCase
{
protected RequestFactoryInterface $requestFactory;
protected StreamFactoryInterface $streamFactory;
protected function setUp(): void
{
parent::setUp();
$this->requestFactory = new HttpFactory();
$this->streamFactory = new HttpFactory();
}
/**
* Create a stub HTTP client that returns sequential responses.
*
* @param list<ResponseInterface> $responses
*/
protected function createMockHttpClient(array $responses): ClientInterface&Stub
{
$client = self::createStub(ClientInterface::class);
$client->method('sendRequest')
->willReturnOnConsecutiveCalls(...$responses);
return $client;
}
/**
* Create a request-capturing HTTP client.
*
* @return array{client: ClientInterface&Stub, requests: array<RequestInterface>}
*/
protected function createCapturingHttpClient(ResponseInterface $response): array
{
$requests = [];
$client = self::createStub(ClientInterface::class);
$client->method('sendRequest')
->willReturnCallback(function (RequestInterface $request) use ($response, &$requests) {
$requests[] = $request;
return $response;
});
return ['client' => $client, 'requests' => &$requests];
}
/**
* Create a JSON success response.
*
* @param array<string, mixed> $data
*/
protected function createJsonResponse(array $data, int $status = 200): ResponseInterface
{
return new Response(
status: $status,
headers: ['Content-Type' => 'application/json'],
body: \json_encode($data, JSON_THROW_ON_ERROR),
);
}
}
```
### E2E Test Example
```php
<?php
declare(strict_types=1);
namespace Vendor\Extension\Tests\E2E\Backend;
use Vendor\Extension\Service\ProviderService;
use Vendor\Extension\Service\ConfigurationService;
use Vendor\Extension\Tests\E2E\AbstractE2ETestCase;
/**
* E2E test for complete provider configuration workflow.
*/
final class ConfigurationWorkflowE2ETest extends AbstractE2ETestCase
{
/**
* @test
* Complete workflow: create provider -> test connection -> save configuration
*/
public function completeProviderConfigurationWorkflow(): void
{
// Arrange: Mock external API responses
$testConnectionResponse = $this->createJsonResponse([
'models' => [
['id' => 'gpt-4o', 'name' => 'GPT-4o'],
['id' => 'gpt-4o-mini', 'name' => 'GPT-4o Mini'],
],
]);
$chatResponse = $this->createJsonResponse([
'id' => 'chatcmpl-123',
'choices' => [
['message' => ['content' => 'Test successful']],
],
]);
$httpClient = $this->createMockHttpClient([
$testConnectionResponse,
$chatResponse,
]);
// Create services with mocked HTTP client
$providerService = new ProviderService($httpClient, $this->requestFactory);
$configService = new ConfigurationService($providerService);
// Act: Execute complete workflow
// Step 1: Test connection
$connectionResult = $providerService->testConnection('sk-test-key');
self::assertTrue($connectionResult->isSuccessful());
self::assertCount(2, $connectionResult->getModels());
// Step 2: Configure provider
$config = $configService->createProviderConfiguration(
name: 'Production OpenAI',
apiKey: 'sk-test-key',
model: 'gpt-4o',
);
self::assertNotNull($config->getId());
// Step 3: Verify configuration works
$testResult = $providerService->sendTestMessage($config, 'Hello');
self::assertSame('Test successful', $testResult->getContent());
}
/**
* @test
* Workflow handles connection failure gracefully
*/
public function workflowHandlesConnectionFailure(): void
{
// Arrange: Mock failed connection
$errorResponse = $this->createJsonResponse(
['error' => ['message' => 'Invalid API key']],
401
);
$httpClient = $this->createMockHttpClient([$errorResponse]);
$providerService = new ProviderService($httpClient, $this->requestFactory);
// Act & Assert
$result = $providerService->testConnection('invalid-key');
self::assertFalse($result->isSuccessful());
self::assertStringContainsString('Invalid API key', $result->getErrorMessage());
}
}
```
### Combining PHP E2E with Playwright
For comprehensive testing, use both approaches:
| Test Type | PHP E2E | Playwright E2E |
|-----------|---------|----------------|
| API workflows | ✓ | |
| HTTP request/response | ✓ | |
| Multi-provider logic | ✓ | |
| Browser UI interactions | | ✓ |
| JavaScript behavior | | ✓ |
| Accessibility (axe-core) | | ✓ |
| Visual regression | | ✓ |
```bash
# Run PHP E2E tests (fast, no browser)
Build/Scripts/runTests.sh -s unit # Or separate e2e-php suite
# Run Playwright E2E tests (browser-based)
Build/Scripts/runTests.sh -s e2e
```
## Resources
- [Playwright Documentation](https://playwright.dev/docs/intro)
- [TYPO3 Core Playwright Tests](https://github.com/TYPO3/typo3/tree/main/Build/tests/playwright)
- [Playwright Test API](https://playwright.dev/docs/api/class-test)
- [Page Object Model](https://playwright.dev/docs/pom)
- [Playwright Network Mocking](https://playwright.dev/docs/mock)
- [PSR-18 HTTP Client](https://www.php-fig.org/psr/psr-18/) - For PHP E2E tests
references/enforcement-rules.md
# Enforcement Rules
This skill enforces the following patterns. Violations should be flagged and corrected.
## PHPUnit Quality Checks (MANDATORY)
| Rule | Enforcement |
|------|-------------|
| **Use `createStub()` for test doubles without expectations** | Flag any `createMock()` call that has no corresponding `expects()` |
| **Use `createMock()` only when verifying calls** | Mock objects MUST have at least one `expects()` call |
| **Use `self::` for static assertions** | Flag `$this->assertSame()`, use `self::assertSame()` instead |
| **Use `#[Test]` attribute** | Flag `@test` annotation and `test` method prefix in new tests |
| **Use `#[CoversClass()]` or `#[CoversNothing]` attribute** | All test classes MUST declare either which class they cover or `#[CoversNothing]` for tests that intentionally do not cover application code (e.g. PHP/libxml behavior) |
| **camelCase test method names** | Flag inconsistent capitalization at word boundaries |
**Detection:**
```bash
# Find mocks without expectations (per-variable detection)
grep -rn '\$[A-Za-z_][A-Za-z0-9_]*\s*=\s*\$this->createMock(' Tests/ | while IFS=: read -r file line rest; do
# Extract variable name on the left-hand side of the assignment
var=$(echo "$rest" | sed -n 's/^\s*\(\$[A-Za-z_][A-Za-z0-9_]*\)\s*=.*/\1/p')
if [ -n "$var" ]; then
# Check whether this specific mock variable is ever used with expects()
if ! grep -q "$var->expects(" "$file"; then
echo "NOTICE: $file:$line: mock $var created with createMock() but has no expects() calls"
fi
fi
done
# Alternatively, rely on PHPUnit's runtime notice for mocks without expectations:
# vendor/bin/phpunit --display-notices | grep 'does not set up any expectations'
# Find $this-> assertions that should use self::
grep -rn '\$this->assert' Tests/
# Find legacy @test annotations
grep -rn '@test' Tests/ | grep -v 'vendor'
```
## DDEV and Test Execution (MANDATORY)
| Rule | Enforcement |
|------|-------------|
| **NEVER use DDEV for running tests** | Not in CI, not in runTests.sh, not in documentation examples |
| **NEVER use DDEV in CI/CD** | Flag any `.github/workflows/*.yml` or `.gitlab-ci.yml` using `ddev` commands |
| **Use PHP built-in server for E2E** | E2E workflows MUST use `php -S` for HTTP, not DDEV |
| **Use Docker containers for functional tests** | Functional tests requiring DB MUST use service containers (MariaDB/MySQL) |
| **Dual-mode Playwright config** | `playwright.config.ts` MUST use `TYPO3_BASE_URL` env var |
**Why:** DDEV is for local development environments only. Using DDEV for running tests is slow (2-3+ min startup), complex (Docker-in-Docker in CI), resource-heavy, and fragile. The TYPO3 community standard is direct PHP or testing containers. Use PHP built-in server for E2E tests, Docker containers for functional tests.
**Correct pattern:**
```yaml
# GitHub Actions E2E
services:
db:
image: mariadb:11.4
# ...
steps:
- name: Start PHP server
run: php -S 0.0.0.0:8080 -t .Build/Web &
- name: Run Playwright
env:
TYPO3_BASE_URL: http://localhost:8080
run: npm run test:e2e
```
**Incorrect pattern (flag this):**
```yaml
# WRONG - Never do this in CI
- run: ddev start
- run: ddev exec vendor/bin/phpunit
```
## Troubleshooting Test Failures
### E2E Tests Fail
When E2E tests fail, debug systematically:
| Symptom | Likely Cause | Fix |
|---------|--------------|-----|
| **Timeout on page load** | TYPO3 not started, wrong URL | Check `TYPO3_BASE_URL` env var, verify `php -S` is running |
| **Element not found** | Page not rendered, JS error | Add `await page.waitForLoadState('networkidle')`, check browser console |
| **Login fails** | Missing fixture, wrong credentials | Verify `be_users.csv` fixture loaded, check password hash |
| **Screenshot shows blank page** | PHP error, 500 response | Check `var/log/typo3_*.log`, enable debug mode |
| **Works locally, fails in CI** | See CI debugging section below | Environment differences |
**Debugging steps:**
1. **Capture screenshot on failure** (Playwright does this automatically)
2. **Check Playwright trace** for network requests: `npx playwright show-trace trace.zip`
3. **Verify TYPO3 is accessible**: `curl -I $TYPO3_BASE_URL`
4. **Check TYPO3 logs**: `cat .Build/Web/var/log/typo3_*.log`
### Tests Pass Locally But Fail in CI
This is a common frustration. Use this checklist:
| Check | Local vs CI Difference | Resolution |
|-------|------------------------|------------|
| **PHP version** | Local may differ from CI matrix | Ensure local PHP matches CI target |
| **Database state** | Local has data, CI starts fresh | Add missing fixtures to test setup |
| **File permissions** | Local user differs from CI runner | Avoid hardcoded paths, use `sys_get_temp_dir()` |
| **Timing** | Local is fast, CI is slow | Add explicit waits, avoid `sleep()` |
| **Environment vars** | Local `.env`, CI lacks it | Define all required vars in CI workflow |
| **Extensions loaded** | Local has extra PHP extensions | Check `php -m` output in CI logs |
| **Filesystem case** | macOS case-insensitive, Linux case-sensitive | Fix `require 'MyClass.php'` vs `myclass.php` |
**CI debugging workflow:**
```bash
# 1. Reproduce locally with CI-like conditions
docker run --rm -it php:8.3-cli php -m # Check extensions
# 2. Add debug output to failing test
$this->markTestSkipped('DEBUG: ' . var_export($actualValue, true));
# 3. Check CI logs for environment differences
# Look for: PHP version, loaded extensions, env vars
# 4. Use GitHub Actions debug logging
env:
ACTIONS_STEP_DEBUG: true
```
**Golden rule:** If tests pass locally but fail in CI, the bug is in your test's assumptions about the environment, not in the CI.
references/event-dispatch-testing.md
# Event Dispatch Testing Patterns
## Testing Try/Catch Guarded Event Dispatch
When event dispatch is wrapped in try/catch for robustness, both the success and exception paths need testing.
### Pattern: Guarded Dispatch
```php
// Production code
try {
$event = $this->eventDispatcher->dispatch(
new ImageProcessedEvent($filePath, $result)
);
} catch (\Throwable $e) {
$this->logger->error('Event listener failed', ['exception' => $e]);
}
```
### Test: Success Path
```php
public function testEventIsDispatched(): void
{
$eventDispatcher = $this->createMock(EventDispatcherInterface::class);
$eventDispatcher->expects(self::once())
->method('dispatch')
->with(self::isInstanceOf(ImageProcessedEvent::class))
->willReturnArgument(0); // PSR-14: dispatch() returns the (possibly modified) event
// ... invoke production code ...
}
```
### Test: Exception Path (Catch Block)
```php
public function testEventDispatchFailureIsLogged(): void
{
$eventDispatcher = $this->createMock(EventDispatcherInterface::class);
$eventDispatcher->method('dispatch')
->willThrowException(new \RuntimeException('Listener failed'));
$logger = $this->createMock(LoggerInterface::class);
$logger->expects(self::once())
->method('error')
->with(
self::stringContains('Event listener failed'),
self::callback(function (array $context): bool {
return array_key_exists('exception', $context)
&& $context['exception'] instanceof \Throwable;
})
);
// ... invoke production code, verify it doesn't throw ...
}
```
## Testing PHP Warning/Error Functions
Functions like `getimagesize()` and `file_get_contents()` can trigger PHP warnings when given invalid input.
### Pattern: Suppressed Warning with Return Check
```php
// Production code
$size = @getimagesize($filePath);
if ($size === false) {
throw new InvalidImageException('Cannot read image dimensions');
}
```
### Test: Warning Trigger Path
```php
public function testInvalidImageThrowsException(): void
{
$this->expectException(InvalidImageException::class);
// Pass a non-image file to trigger the getimagesize failure
$processor->getImageDimensions('/path/to/not-an-image.txt');
}
```
### When to Use @ Suppression
| Context | @ OK? | Reason |
|---------|-------|--------|
| `@mkdir($dir, 0775, true)` | Yes | TOCTOU race condition — dir may be created between check and create |
| `@file_get_contents($path)` | Yes | If return value is checked (`=== false`) |
| `@getimagesize($path)` | Yes | If return value is checked (`=== false`) |
| `@unlink($path)` | Depends | OK in cleanup, not OK if deletion is critical |
references/framework-compat-gate.md
# Framework Compatibility Gate
A package whose only consumers are TYPO3 projects has a failure mode its own
test suite cannot reach: the code is correct, the tests are green, and Composer
still refuses to install it next to TYPO3. The suite runs the package in
isolation, so the version constraints it shares with the framework are never
compared to anything.
This is not hypothetical. Two SDKs required `phpdocumentor/reflection-docblock:
^5.3.0`. `typo3/cms-extbase ^14.3` requires `^6.0.3`. Every TYPO3 14 project was
therefore unable to install them, the previous major carried the same pin so
falling back did not help — and 120 green unit tests, PHPStan level 8, Rector,
CGL and a mutation-testing gate all said nothing, because none of them ever put
the package and the framework in the same dependency graph. It surfaced months
later, by accident.
## The gate
One script, one CI job: install the package into a throwaway project together
with the TYPO3 packages a consumer pulls, then load both.
```bash
#!/usr/bin/env bash
# tools/typo3-compat.sh <typo3-constraint> <class-that-must-load>
set -euo pipefail
constraint=${1:?}; probeClass=${2:?}
root=$(cd "$(dirname "$0")/.." && pwd)
package=$(php -r 'echo json_decode(file_get_contents($argv[1]), true)["name"];' "$root/composer.json")
composer=(composer); [ -f "$root/composer.phar" ] && composer=(php "$root/composer.phar")
work=$(mktemp -d); trap 'rm -rf "$work"' EXIT; cd "$work"
"${composer[@]}" init --quiet --no-interaction --name=vendor/typo3-compat-probe
"${composer[@]}" config minimum-stability dev
"${composer[@]}" config prefer-stable true
"${composer[@]}" config allow-plugins.typo3/cms-composer-installers true
"${composer[@]}" config allow-plugins.typo3/class-alias-loader true
# The version is pinned in the repository definition rather than derived from
# the checkout: CI builds on a detached HEAD, where the branch name a path
# repository would otherwise report does not exist.
"${composer[@]}" config repositories.package --json \
"{\"type\":\"path\",\"url\":\"$root\",\"options\":{\"versions\":{\"$package\":\"9999999-dev\"}}}"
# --no-scripts: the TYPO3 installer plugin runs console commands that need a
# configured application; they fail in a bare probe and say nothing about the
# question asked here, which is whether the versions fit and load.
"${composer[@]}" require --no-interaction --no-progress --prefer-dist --no-scripts \
"typo3/cms-core:$constraint" "typo3/cms-extbase:$constraint" "$package:9999999-dev"
php -r '
require $argv[1] . "/vendor/autoload.php";
foreach ([$argv[2], "TYPO3\\CMS\\Core\\Core\\Bootstrap"] as $class) {
if (!class_exists($class)) {
fwrite(STDERR, sprintf("Class %s is not autoloadable.%s", $class, PHP_EOL));
exit(1);
}
}
' "$work" "$probeClass"
```
GitLab CI, binding, across every TYPO3 line the consumers run:
```yaml
typo3-compat:
stage: testing
image: php:$PHP
needs: []
parallel:
matrix:
- TYPO3: [ '^12.4', '^13.4', '^14.3' ]
PHP: [ '8.4', '8.5' ]
before_script:
- apt-get update -yqq
- apt-get install -yqq git libicu-dev libxml2-dev libzip-dev zip unzip
- docker-php-ext-install intl xml zip
script:
- curl -sS https://getcomposer.org/installer | php
- bash tools/typo3-compat.sh "$TYPO3" 'Vendor\Package\EntryClass'
```
No Xdebug and no `needs`: the job resolves and loads, it collects no coverage
and does not want the project's own vendor directory.
## Prove the gate can fail
A gate that has never gone red is a hypothesis. Two probes settle it, both
cheap:
- Put the old constraint back (`^5.3.0`) and run the newest TYPO3 line — the
run must exit non-zero on `Conclusion: don't install …`.
- Pass an unsatisfiable constraint (`^99.0`) — the run must fail as well.
If either passes, the job is measuring something other than resolvability.
## Reading a failure
Composer names the conflicting package, not the guilty constraint. Compare the
two requirements directly before concluding anything:
```bash
composer why-not typo3/cms-core 14.3
curl -sS https://repo.packagist.org/p2/typo3/cms-extbase.json \
| jq -r '.packages["typo3/cms-extbase"][] | select(.version|startswith("v14.3")) | .require["phpdocumentor/reflection-docblock"]'
```
Widen to the union of the supported lines (`^5.6.5 || ^6.0.3`) rather than
jumping to the newest — the older TYPO3 lines still have to install. Then run
the package's own suite against the newly admitted versions: resolvability and
correctness are different questions, and only the suite answers the second.
## When the package has no runtime dependencies
Keep the job anyway. It costs one CI minute, and it turns the next dependency
someone adds into a checked assumption instead of an unchecked one.
references/functional-test-patterns.md
# Functional Test Patterns for TYPO3 12/13
> **Source**: Real-world patterns from testing a production TYPO3 extension (2024-12)
## Container Reset Between Tests
When testing classes that use dependency injection, reset the container between tests:
```php
protected function setUp(): void
{
parent::setUp();
// Reset container to ensure clean DI state
$this->resetContainer();
}
```
**Why**: Prevents test pollution from cached service instances.
## Site Configuration in Functional Tests
Create site configuration via YAML files, not PHP APIs:
```php
protected function setUp(): void
{
parent::setUp();
$this->importCSVDataSet(__DIR__ . '/Fixtures/pages.csv');
// Create site configuration directory
$siteConfigPath = $this->instancePath . '/config/sites/main';
GeneralUtility::mkdir_p($siteConfigPath);
// Write YAML configuration directly
file_put_contents(
$siteConfigPath . '/config.yaml',
Yaml::dump([
'rootPageId' => 1,
'base' => '/',
'languages' => [
[
'languageId' => 0,
'title' => 'English',
'locale' => 'en_US.UTF-8',
'base' => '/',
],
],
])
);
}
```
### `Site::getBase()` with `baseVariants` is functional-only
Code that resolves a site base URL — egress/SSRF policy, probe-URL builders,
anything calling `$site->getBase()` — cannot be **unit**-tested when the site
carries `baseVariants`. `baseVariants` are evaluated through TYPO3's
ExpressionLanguage provider, which needs the DI/provider bootstrap the unit
`UnitTestCase` does not set up; a plain unit test throws
`ArgumentCountError` from `ProviderConfigurationLoader` the moment `getBase()`
touches a variant.
Test such code as **functional** — write the site YAML with a `baseVariants` block
(extending the site-config example above) so ExpressionLanguage is wired:
```yaml
rootPageId: 1
base: '/'
baseVariants:
-
base: 'https://staging.example.com/'
condition: 'applicationContext == "Production/Staging"'
```
Reserve unit tests for site code that never resolves a variant base.
## Disabling Session for Context Fixtures
When testing contexts that don't need session:
```php
// Fixture: Tests/Functional/Fixtures/tx_contexts_contexts.csv
"uid","pid","title","type","type_conf","disabled","hide_in_backend"
1,0,"Test Context","ip","","0","0"
// Test class
protected function setUp(): void
{
parent::setUp();
$this->importCSVDataSet(__DIR__ . '/Fixtures/tx_contexts_contexts.csv');
// Disable session to avoid "session not available" errors
$GLOBALS['TYPO3_CONF_VARS']['FE']['sessionDataLifetime'] = 0;
}
```
## LinkVars Warning Fix
Avoid `linkVars not set` warnings in functional tests:
```php
// In test setup or fixture TypoScript
$GLOBALS['TSFE']->config['config']['linkVars'] = '';
```
Or in site TypoScript fixture:
```typoscript
config.linkVars =
```
## PHPUnit 10/11/12 Migration Patterns
### Removed: `$this->at()` Matcher
**PHPUnit 9** (deprecated):
```php
$mock->expects($this->at(0))->method('foo')->willReturn('first');
$mock->expects($this->at(1))->method('foo')->willReturn('second');
```
**PHPUnit 10+**:
```php
$mock->expects($this->exactly(2))
->method('foo')
->willReturnOnConsecutiveCalls('first', 'second');
```
### Callback Matcher for Complex Sequences
```php
$callCount = 0;
$mock->method('foo')
->willReturnCallback(function () use (&$callCount) {
return match (++$callCount) {
1 => 'first',
2 => 'second',
default => 'default',
};
});
```
### Mock Objects Without Expectations
PHPUnit 12 shows notices when mocks created with `createMock()` have no configured expectations.
> **WARNING:** `#[AllowMockObjectsWithoutExpectations]` is a PHPUnit 12-only attribute. It does NOT exist in PHPUnit 11 (used on PHP 8.2 CI). Using it causes a fatal error on PHPUnit 11. **Do not use this attribute** in projects that must support PHPUnit 11.
**Solution: Use `createStub()` instead of `createMock()`** when no expectations are needed:
```php
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(MyService::class)]
final class MyServiceTest extends FunctionalTestCase
{
public function testSomething(): void
{
// GOOD: createStub() for doubles without expectations
$stub = $this->createStub(DependencyInterface::class);
$stub->method('getValue')->willReturn('default');
$service = new MyService($stub);
self::assertTrue($service->isValid());
}
}
```
**When to use `createStub()`:**
- Test doubles used only for satisfying type hints
- Fuzz tests where the double's interactions aren't the focus
- Tests where the double's behavior is irrelevant
**When to use `createMock()`:**
- You need `expects()` to verify call counts or arguments
See [Test Environment Guards](test-environment-guards.md#phpunit-version-compatibility-createmock-vs-createstub) for the full decision guide.
### PHPUnit 12: Attribute-Based Annotations
PHPUnit 12 prefers PHP 8 attributes over docblock annotations:
```php
// Old (deprecated)
/**
* @covers \MyClass
* @group slow
*/
class MyTest extends TestCase {}
// New (PHPUnit 10+)
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
#[CoversClass(MyClass::class)]
#[Group('slow')]
final class MyTest extends TestCase
{
#[Test]
public function itDoesTheThing(): void {}
}
```
### Common PHPUnit 12 Attributes
| Attribute | Purpose |
|-----------|---------|
| `#[Test]` | Mark method as test |
| `#[CoversClass(Foo::class)]` | Code coverage target |
| `#[CoversNothing]` | Exclude from coverage |
| `#[Group('slow')]` | Test grouping |
| `#[DataProvider('dataMethod')]` | Data provider |
| `#[Depends('testFirst')]` | Test dependencies |
| `#[AllowMockObjectsWithoutExpectations]` | Suppress mock notices (PHPUnit 12 ONLY -- use `createStub()` instead) |
## Database Credentials for DDEV
In `Build/phpunit/FunctionalTests.xml`:
```xml
<php>
<env name="typo3DatabaseDriver" value="mysqli"/>
<env name="typo3DatabaseHost" value="db"/>
<env name="typo3DatabasePort" value="3306"/>
<env name="typo3DatabaseUsername" value="db"/>
<env name="typo3DatabasePassword" value="db"/>
<env name="typo3DatabaseName" value="func_tests"/>
</php>
```
## Test Framework Compatibility Matrix
| PHPUnit | TYPO3 Testing Framework | TYPO3 Version |
|---------|------------------------|---------------|
| ^10.5 | ^8.0 | 12.4 LTS |
| ^11.0 | ^8.2 \|\| ^9.0 | 12.4, 13.4 |
| ^12.0 | ^9.0 | 13.4 LTS |
## Functional Test with Request Attribute (v13)
Testing code that uses PSR-7 request attributes:
```php
use TYPO3\CMS\Core\Http\ServerRequest;
use TYPO3\CMS\Frontend\Page\PageInformation;
public function testWithPageInformation(): void
{
$pageInfo = new PageInformation();
$pageInfo->setId(1);
$pageInfo->setRootLine([['uid' => 1]]);
$request = (new ServerRequest())
->withAttribute('frontend.page.information', $pageInfo);
$result = $this->subject->process($request);
self::assertSame(1, $result->getPageId());
}
```
## Backend controller & AJAX gotchas (found while functional-testing controllers)
Three traps that surface when functional-testing TYPO3 backend controllers and
running the suite locally:
### `-s functional` hangs on WSL2 under Docker contention
The full functional suite can hang indefinitely (many minutes, **zero output** =
a setup hang, not a test failure) when it competes for the Docker daemon with a
running `ddev` project and/or Playwright. Run **targeted files** locally and
treat **CI functional as authoritative**:
```bash
# Not the whole suite while ddev/Playwright are busy:
./Build/Scripts/runTests.sh -s functional Tests/Functional/Controller/Backend/FooControllerTest.php
```
### `JsonResponse` 500s on non-UTF-8 and takes no encode flags
TYPO3's `\TYPO3\CMS\Core\Http\JsonResponse` encodes with `JSON_THROW_ON_ERROR`
and accepts **no** encoding-options argument. An AJAX action that echoes
untrusted bytes back to the browser — tool output, log lines, injected text —
throws an uncaught exception on a single malformed byte, returning an **HTML 500
page** the frontend can't parse (it surfaces as a bare "Unknown error"). Build
the response via the injected PSR-17 factory so bad bytes degrade instead of
throwing (prefer the factory over `new \TYPO3\CMS\Core\Http\Response()` — that
class is not public API):
```php
// $this->responseFactory is an injected Psr\Http\Message\ResponseFactoryInterface
$json = json_encode($data, JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE);
$response = $this->responseFactory->createResponse($status)
->withHeader('Content-Type', 'application/json; charset=utf-8');
$response->getBody()->write($json);
$response->getBody()->rewind(); // else getContents() in an emitter/middleware sees an empty stream
return $response;
```
A functional test for this: run the action with a scripted provider/model whose
response carries `"\xFF\xFE"`, and assert the action returns `200` with
decodable JSON — not that it throws.
### `jsonResponse()` is a FATAL name clash on an Extbase `ActionController`
`ActionController` already declares `protected function jsonResponse(?string $json = null)`.
A helper named `jsonResponse` with a narrower visibility or different signature
is a **fatal** "access level must be …" / signature error at class-load time —
not a lint warning. Name controller JSON helpers something else (`respondJson`,
`streamLine`, …).
## Removing a constructor property? Grep the property name across `Tests/`
Functional/E2E suites often build controllers with `newInstanceWithoutConstructor()` plus reflection injection (`setPrivateProperty($c, 'propName', …)` or a `createControllerWithReflection(X::class, ['propName' => …])` helper). Those factory calls never mention the action methods you moved — so a call-site grep scoped to the refactor misses them, and PHPStan, unit, cgl and rector all stay green. The failure appears only in the functional suite: `ReflectionException: Property X::$prop does not exist`.
Before removing or moving a constructor property, grep the **property name as a string literal** across `Tests/`, and resolve each hit against the class its enclosing factory actually instantiates — most hits usually belong to sibling controllers that legitimately keep the dependency.
references/functional-testing.md
# 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);
}
}
```
## Gotcha: a green functional suite can prove nothing
A passing functional run is not proof the tests ran. Three ways CI stays green while nothing is verified — check for all three:
1. **A wrapped `setUp` swallows a broken environment into a skip.** A base class that guards `parent::setUp()` like this:
```php
protected function setUp(): void
{
try {
parent::setUp();
} catch (\Throwable $e) {
self::markTestSkipped('Failed to initialize functional test: ' . $e->getMessage());
}
}
```
turns an **unreachable database** — or a broken fixture, or a TCA error — into `OK, Tests: 25, Assertions: 0, Skipped: 25`, exit 0. The suite tested nothing and CI is green. Proven: pointing the run at a not-yet-ready MariaDB produced exactly that. Prefer letting the environment failure fail the test (keep only a deliberate "no database configured" skip, and put that check *before* `parent::setUp()`), and **gate the run on assertion count, not exit code** — `Assertions: 0` across the suite means it proved nothing.
2. **A coverage flag that never reaches the runner.** `composer ci:test:php:functional -- --coverage-clover=cov.xml` silently produces no file if the composer script wraps its command in `sh -c '… runTests.sh …'` — composer appends the args *after* the quoted string, so they become `$0`/`$@` of the wrapper and never reach `runTests.sh` (nor the PHPUnit it drives). Forward them: `sh -c '… runTests.sh … "$@"' --`. (One extension uploaded no coverage for three months this way.)
3. **A coverage config with no `<source>`.** Even with the flag, PHPUnit answers `No filter is configured, code coverage will not be processed` and writes nothing unless `FunctionalTests.xml` has a `<source>` block. And because the Codecov step runs with `fail_ci_if_error: false`, uploading a file that was never written never fails. **Verify coverage actually lands** (the Codecov commit/flag updates), don't assume a green upload step means it worked.
The through-line: **success reported ≠ behaviour proven.** Gate on a produced artifact (assertions, a non-empty clover), not on exit 0.
## 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
> **Migration note (`typo3/testing-framework` v9):** the legacy XML loader `importDataSet()` was removed and replaced by `importCSVDataSet()`. Convert XML fixtures to CSV: one row per record, a leading `,"uid","pid",...` header line per table, and a quoted table-name row above each table. The CSV loader is stricter about column order and quoting -- see the rules below. Extensions on `typo3/testing-framework: ^8.2 || ^9.0` should standardise on CSV so the same fixtures work on TYPO3 v12, v13 and v14.
### CSV Format
Create fixtures in `Tests/Functional/Fixtures/`:
```csv
"pages"
,"uid","pid","title","doktype"
,1,0,"Root",1
,2,1,"Products",1
,3,1,"Services",1
```
```csv
"tx_myext_domain_model_product"
,"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
```
**CSV fixture format rules:**
1. **First row is the table name** (quoted): `"pages"`, `"tx_myext_domain_model_product"`
2. **Second row is the column header** (leading comma, quoted column names): `,"uid","pid","title"`
3. **Data rows** start with a leading comma: `,1,0,"Root",1`
4. **Foreign key references must be consistent**: if a product references `pid=2`, a pages fixture must contain `uid=2`. Inconsistent references cause silent test failures where records appear missing.
5. **Multiple tables can share a single CSV file** by repeating the table-name + header pattern
### 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
// Composer package name format — use 'vendor/extension-name' from composer.json
protected array $testExtensionsToLoad = [
'vendor/my-extension',
'vendor/dependency-extension',
];
```
**Important:** Always use the `vendor/extension-name` pattern matching the `name` field in the extension's `composer.json`. This is the only format that works reliably across all testing setups (local, CI, DDEV). The legacy `typo3conf/ext/my_extension` path format is deprecated and should not be used in new tests.
### Core Extensions
```php
protected array $coreExtensionsToLoad = [
'form',
'workspaces',
];
```
### Load ALL hard dependencies (or the bootstrap fails cryptically)
`coreExtensionsToLoad` / `testExtensionsToLoad` must include **every** TYPO3
extension your extension hard-depends on — both the `ext_emconf.php`
`constraints.depends` AND every `typo3/cms-*` in the composer `require`
(composer-only extensions on v14.3 derive their dependencies from `require`; see
the typo3-conformance skill's ext_emconf migration notes).
If a declared dependency is not loaded, the package graph is unsatisfiable, the
DI container falls back to the **failsafe container**, and the real cause is
hidden behind a misleading error:
```
TYPO3\CMS\Core\DependencyInjection\NotFoundException:
Container entry "TYPO3\CMS\Core\Configuration\Extension\ExtTablesFactory" is not available.
```
When you see that `ExtTablesFactory` / failsafe-container error, the fix is almost
always "a declared dependency is not loaded" — add it to `coreExtensionsToLoad`.
(Example: an extension that requires `typo3/cms-reports` for a Reports status
provider must list `'reports'` in **every** functional test's `coreExtensionsToLoad`,
not just one.)
## 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"
beStrictAboutOutputDuringTests="false"
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="pdo_sqlite"/>
</php>
</phpunit>
```
**Key configuration notes:**
- **`bootstrap="FunctionalTestsBootstrap.php"`**: Always use `FunctionalTestsBootstrap.php` (not the vendor autoload). This bootstrap initializes the TYPO3 testing framework, creates temp directories, and sets up the test instance environment.
- **`beStrictAboutOutputDuringTests="false"`**: Required when testing ViewHelpers via `StandaloneView`, since rendering output triggers PHPUnit's output-during-tests strictness check. Without this, ViewHelper E2E tests will fail as risky.
- **`typo3DatabaseDriver` env var**: Use `pdo_sqlite` for fast local/CI testing without requiring a database server. SQLite is sufficient for most functional tests and eliminates external service dependencies. Use `mysqli` or `pdo_mysql` only when testing database-specific behavior.
> **⚠️ SQLite hides MySQL/MariaDB strict-mode bugs — don't claim "cross-DBMS green" from an SQLite-only run.** SQLite is permissive where a production MySQL/MariaDB in strict mode rejects. A functional/E2E suite that passes on SQLite can fail on MySQL for bugs SQLite never surfaces:
> - **Overlong values**: SQLite silently truncates a value longer than a `varchar(N)` column; MySQL strict mode rejects the insert (error → often a 500 on an AJAX-persist path that bypasses FormEngine's `max` eval). The test only fails on MySQL.
> - **`decimal(p,s)` scale**: SQLite stores the full float; MySQL rounds to the column scale, so the same entity round-trips a *different* value per DBMS (`0.123456789` → `0.12`). Assertions pinned to the full value pass on SQLite, fail on MySQL.
> - **Error-path coverage**: an insert that *only* fails under strict mode runs an error branch SQLite never reaches — where an uninitialized property (e.g. a reflection-constructed controller's injected logger) then fatals. The bug is real in production but invisible to SQLite tests.
>
> For any code with DB-specific behavior (length limits, decimal columns, strict-mode-sensitive inserts), run the **full functional + e2e suite on MariaDB** locally — `runTests.sh -s functional -d mariadb` — before asserting it's cross-DBMS clean, and add a MariaDB leg to CI (see `ci-workflows-meta-package.md`). A suite that has *only ever* run on SQLite silently rots for MySQL.
### 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');
});
```
## Running Functional Tests with DDEV
Functional tests require the mysqli extension which is typically not available on the host system. Run tests inside the DDEV container:
```bash
# ❌ Wrong - mysqli not available on host PHP
./vendor/bin/phpunit -c Build/phpunit/FunctionalTests.xml
# ✅ Correct - Run inside DDEV with database credentials
ddev exec typo3DatabaseHost=db typo3DatabaseUsername=db typo3DatabasePassword=db typo3DatabaseName=db \
./vendor/bin/phpunit -c Build/phpunit/FunctionalTests.xml
```
### DDEV Database Configuration
When using DDEV, the database credentials are:
- **Host**: `db`
- **Username**: `db`
- **Password**: `db`
- **Database**: `db`
## Handling cHash Validation Errors
Frontend tests with query parameters may fail with "cHash empty" errors. Exclude test parameters from cHash validation:
```php
final class MyFunctionalTest extends FunctionalTestCase
{
/**
* Exclude test parameters from cHash validation to avoid errors.
*/
protected array $configurationToUseInTestInstance = [
'FE' => [
'cacheHash' => [
'excludedParameters' => ['test', 'myTestParam'],
],
],
];
}
```
## InternalRequest Query Parameters
`InternalRequest` does not parse URL-embedded query strings. Always use `withQueryParameters()`:
```php
// ❌ Wrong - Query string not parsed by InternalRequest
$request = new InternalRequest('http://localhost/?id=1&test=1');
// ✅ Correct - Use withQueryParameters()
$request = (new InternalRequest('http://localhost/'))
->withQueryParameters(['id' => 1, 'test' => 1]);
$response = $this->executeFrontendSubRequest($request);
```
## Singleton Reset for Test Isolation
Singleton classes must provide a `reset()` method to ensure fresh state between tests:
```php
// Singleton class with reset capability
final class Container
{
private static ?self $instance = null;
public static function get(): self
{
return self::$instance ??= new self();
}
public static function reset(): void
{
self::$instance = null;
}
}
// In test setUp()
protected function setUp(): void
{
parent::setUp();
Container::reset(); // Ensure fresh state between tests
}
```
## Session State Isolation in Fixtures
When testing contexts or features that use session storage, disable sessions in test fixtures to prevent test pollution:
```csv
# tx_contexts_contexts.csv
# Column: use_session - Set to 0 to prevent session state from persisting between tests
"tx_contexts_contexts"
,"uid","pid","title","alias","type","type_conf","invert","use_session","disabled","hide_in_backend"
,1,1,"test get","testget","getparam","...",0,0,0,0
```
**Why this matters**: Session-based contexts can cause flaky tests when session state persists between test runs. Always set `use_session=0` in fixtures unless specifically testing session functionality.
## Safe tearDown Pattern
When `setUp()` might fail (e.g., database connection issues), `tearDown()` should handle incomplete initialization:
```php
protected function tearDown(): void
{
// Clean up test-specific globals
unset($_GET['test']);
// Handle cases where setUp() didn't complete
try {
parent::tearDown();
} catch (Error) {
// Setup didn't complete, nothing to tear down
}
}
```
## Site Configuration (TYPO3 v12+)
Use `SiteWriter` instead of the deprecated `writeSiteConfiguration()`:
```php
use TYPO3\CMS\Core\Configuration\SiteWriter;
protected function setUp(): void
{
parent::setUp();
$this->importCSVDataSet(__DIR__ . '/Fixtures/pages.csv');
// ❌ Deprecated in TYPO3 v12
// $this->writeSiteConfiguration('test', ['rootPageId' => 1, 'base' => '/']);
// ✅ TYPO3 v12+ with SiteWriter
$siteWriter = $this->get(SiteWriter::class);
$siteWriter->createNewBasicSite('website-local', 1, 'http://localhost/');
// Set up TypoScript for frontend rendering
$this->setUpFrontendRootPage(1, [
'EXT:my_extension/Tests/Functional/Fixtures/TypoScript/Basic.typoscript',
]);
}
```
## Running Functional Tests
```bash
# Via runTests.sh
Build/Scripts/runTests.sh -s functional
# Via PHPUnit directly (on host with mysqli)
vendor/bin/phpunit -c Build/phpunit/FunctionalTests.xml
# Via DDEV (recommended)
ddev exec typo3DatabaseHost=db typo3DatabaseUsername=db typo3DatabasePassword=db typo3DatabaseName=db \
vendor/bin/phpunit -c Build/phpunit/FunctionalTests.xml
# Via Composer
composer ci:test:php:functional
# With specific database driver
typo3DatabaseDriver=pdo_mysql vendor/bin/phpunit -c Build/phpunit/FunctionalTests.xml
# Single test
vendor/bin/phpunit Tests/Functional/Domain/Repository/ProductRepositoryTest.php
```
## Functional Test Limitations
The functional test framework provides a database and DI container but does **NOT** provide a full TYPO3 frontend (TSFE) context. This means certain operations are unavailable or require extra setup:
### What Does NOT Work
| Operation | Error (v13) | Error (v14) | Alternative |
|-----------|-------------|-------------|-------------|
| `$cObj->parseFunc($html, null, '< lib.parseFunc_RTE')` | `parseFunc without any configuration` | `No valid attribute "applicationType"` | Unit test with mocked cObj + E2E test |
| TypoScript reference resolution (`< lib.*`) | LogicException | LogicException | Provide inline TS config array instead of reference |
| `typoLink_URL()` with page UIDs | Missing site config | Missing TSFE | Write YAML site config to filesystem in setUp |
| `$GLOBALS['TSFE']` access | null | null | Use `FrontendRequestHandler` for full rendering |
### Setting `$GLOBALS['TYPO3_REQUEST']`
**Caution:** Setting `$GLOBALS['TYPO3_REQUEST']` in `setUp()` affects ALL tests in the class and can cause unexpected side effects:
- The request needs an `applicationType` attribute, and its value is the **int bitmask** `SystemEnvironmentBuilder::REQUESTTYPE_FE` (or `_BE`) — **not** the `ApplicationType` enum case. `ApplicationType::fromRequest()` guards with `is_int($type)`, so passing `ApplicationType::FRONTEND` throws `RuntimeException` 1606222812, *No valid attribute "applicationType" found in request object*. Verified identical on 12.4, 13.4, 14.3 and main.
- Existing tests may break because TYPO3 enables additional processing paths when the global is present
- **Best practice:** Set the global only in specific test methods that need it, with `try/finally` cleanup:
```php
public function testThatNeedsRequest(): void
{
$GLOBALS['TYPO3_REQUEST'] = $this->request
->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_FE);
try {
// test code
} finally {
unset($GLOBALS['TYPO3_REQUEST']);
}
}
```
### When to Use Unit Tests Instead
If your code calls `parseFunc()`, `typoLink()`, or any method that requires the full TypoScript/TSFE pipeline, write:
1. **Unit test** with a mocked `ContentObjectRenderer` to verify your code calls the right method with the right arguments
2. **E2E test** to verify the actual rendered output in a real TYPO3 frontend
This split gives you fast feedback (unit) plus real-world confidence (E2E) without fighting the functional test framework.
## Mocking ExtensionConfiguration with GeneralUtility::addInstance()
In functional tests, `ExtensionConfiguration` is resolved from the DI container. To substitute it with a mock (e.g., to control configuration values without a real `ext_conf_template.txt`), use `GeneralUtility::addInstance()`:
```php
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Utility\GeneralUtility;
protected function setUp(): void
{
parent::setUp();
$extensionConfigurationMock = $this->createMock(ExtensionConfiguration::class);
$extensionConfigurationMock
->method('get')
->willReturnCallback(function (string $extension, string $key): mixed {
return match ($key) {
'enableFeature' => true,
'timeout' => 30,
default => null,
};
});
// Register the mock so TYPO3's DI resolves it instead of the real instance
GeneralUtility::addInstance(ExtensionConfiguration::class, $extensionConfigurationMock);
}
```
**Why this is needed:** Functional tests bootstrap a real TYPO3 instance, so `$this->get(ExtensionConfiguration::class)` returns the actual service. `GeneralUtility::addInstance()` queues a replacement that is consumed on the next `makeInstance()` call for that class.
## Repository Instance Cache Pattern
When a repository maintains an internal object cache (non-static), each test gets a fresh repository instance via `$this->get()`, which means the cache is empty at the start of each test. This eliminates the need for reflection-based cache resets between tests:
```php
final class TranslationRepository
{
/** @var array<string, Translation> */
private array $cache = [];
public function findByKey(string $key): ?Translation
{
if (isset($this->cache[$key])) {
return $this->cache[$key];
}
// ... query database ...
$this->cache[$key] = $result;
return $result;
}
}
```
```php
// In functional tests — no cache reset needed
protected function setUp(): void
{
parent::setUp();
// Each call to $this->get() returns a fresh instance with empty cache
$this->subject = $this->get(TranslationRepository::class);
}
```
**Key insight:** Use instance (non-static) properties for repository caches. Static caches persist across tests and require reflection hacks (`ReflectionProperty::setValue(null, [])`) to reset, making tests fragile and coupled to implementation details.
## ViewHelper E2E Tests with StandaloneView
> **v13 only.** `typo3/sysext/fluid/Classes/View/StandaloneView.php` is present on
> branch `13.4` and **gone on `14.3` and `main`**. A test written this way compiles
> out of the matrix the moment v14 is added. `ViewFactoryInterface` and
> `ViewFactoryData` (`typo3/sysext/core/Classes/View/`) exist on 13.4, 14.3 and
> main, so a test that resolves the view through the factory runs on the whole
> matrix — prefer it for anything new.
Test Fluid ViewHelpers end-to-end by rendering templates through `StandaloneView`. This verifies the full rendering pipeline including namespace registration, argument handling, and output:
```php
use TYPO3\CMS\Fluid\View\StandaloneView;
final class MyViewHelperTest extends FunctionalTestCase
{
protected array $testExtensionsToLoad = [
'vendor/my-extension',
];
#[Test]
public function viewHelperRendersExpectedOutput(): void
{
$view = $this->get(StandaloneView::class);
$view->setTemplateSource(
'{namespace myext=Vendor\MyExtension\ViewHelpers}'
. '<myext:myViewHelper argument="value" />'
);
$result = $view->render();
self::assertStringContainsString('expected output', $result);
}
#[Test]
public function viewHelperHandlesEmptyArgument(): void
{
$view = $this->get(StandaloneView::class);
$view->setTemplateSource(
'{namespace myext=Vendor\MyExtension\ViewHelpers}'
. '<myext:myViewHelper argument="" />'
);
$result = $view->render();
self::assertSame('', trim($result));
}
}
```
**Key patterns:**
1. **`setTemplateSource()`**: Inline template string avoids file dependencies. Register the ViewHelper namespace with `{namespace myext=...}` at the start of the template.
2. **`beStrictAboutOutputDuringTests="false"`**: Required in `FunctionalTests.xml` because `StandaloneView::render()` produces output that PHPUnit's strict mode would flag as risky.
3. **Test both success and edge cases**: Verify expected output, empty arguments, missing arguments, and invalid input.
## 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)
## `FunctionalTestCase::get()` resolves PRIVATE services — don't make services public for tests
The testing-framework's `private_container` fixture extension registers every private service and private alias into a public locator (`typo3.testing-framework.private-container`); `get()` falls back to it. So `$this->get(SomeConcrete::class)` works with `public: false` — never add `public: true` in `Services.yaml` just for a functional/E2E test. Genuine `public: true` reasons: a documented downstream consumer resolved by class name, or resolution outside DI via `GeneralUtility::makeInstance()` (TCA itemsProcFunc, DataHandler hooks). Verified at scale: 45→27 public services in one extension with zero test changes, all green — refuting the common "repositories must be public for `get()`" claim.
## Coverage of mock-exercised classes: measure per test file, not combined
A combined PHPUnit run can report 0% / heavily under-counted coverage for classes exercised through partial mocks (`createPartialMock`, `onlyMethods()`) — a cross-file attribution artifact, not missing coverage. Before any test-architecture decision based on a 0%, measure the file in isolation (`php -d xdebug.mode=coverage … --coverage-clover /tmp/cov.xml tests/…/OneTest.php`) and read that clover. One measured case: combined run 0/12 and 0/9, isolated runs 11/12 and 8/9.
references/fuzz-testing.md
# Fuzz Testing for TYPO3 Extensions
## Overview
Fuzz testing (fuzzing) automatically generates random/mutated inputs to find crashes, memory exhaustion, or unexpected exceptions. This is critical for code that parses untrusted input like HTML, XML, or user data.
> **Key Distinction**: Fuzz testing mutates **inputs** to find bugs. For testing that mutates **code** to verify test quality, see [Mutation Testing](mutation-testing.md).
## When to Use Fuzz Testing
- HTML/XML parsers (e.g., DOMDocument-based code)
- User input processors
- Data transformation services
- File format parsers
- Any code handling untrusted external data
## Tools
### nikic/php-fuzzer (Recommended)
Coverage-guided fuzzer for PHP library/parser testing.
**Installation:**
```bash
composer require --dev nikic/php-fuzzer:^0.0.11
```
**Key features:**
- Coverage-guided mutation (finds new code paths)
- Corpus management (saves interesting inputs)
- Crash detection and reproduction
- Memory limit enforcement
## Creating Fuzz Targets
### Basic Structure
Create fuzz targets in `Tests/Fuzz/` directory:
```php
<?php
declare(strict_types=1);
use MyVendor\MyExtension\Service\MyParser;
require_once dirname(__DIR__, 2) . '/.Build/vendor/autoload.php';
/** @var PhpFuzzer\Config $config */
$parser = new MyParser();
$config->setTarget(function (string $input) use ($parser): void {
// Call the method being fuzzed
$parser->parse($input);
});
// Limit input length to prevent memory exhaustion
$config->setMaxLen(65536);
```
### TYPO3 Extension Example
For a TYPO3 extension with HTML parsing (like an RTE image handler):
```php
<?php
declare(strict_types=1);
/**
* Fuzzing target for ImageAttributeParser.
*
* Tests parseImageAttributes() with random/mutated HTML inputs
* to find crashes, memory exhaustion, or unexpected exceptions.
*/
use MyVendor\MyExtension\Service\ImageAttributeParser;
require_once dirname(__DIR__, 2) . '/.Build/vendor/autoload.php';
/** @var PhpFuzzer\Config $config */
$parser = new ImageAttributeParser();
$config->setTarget(function (string $input) use ($parser): void {
// Test primary parsing method
$parser->parseImageAttributes($input);
// Test related methods with same input
$parser->parseLinkWithImages($input);
});
$config->setMaxLen(65536);
```
### Testing Classes with Dependencies
For classes requiring TYPO3 dependencies:
```php
<?php
declare(strict_types=1);
use MyVendor\MyExtension\DataHandling\SoftReference\MySoftReferenceParser;
use TYPO3\CMS\Core\Html\HtmlParser;
require_once dirname(__DIR__, 2) . '/.Build/vendor/autoload.php';
/** @var PhpFuzzer\Config $config */
// Create dependencies
$htmlParser = new HtmlParser();
$parser = new MySoftReferenceParser($htmlParser);
// Set required properties via reflection if needed
$reflection = new ReflectionClass($parser);
$parserKeyProp = $reflection->getProperty('parserKey');
$parserKeyProp->setValue($parser, 'my_parser_key');
$config->setTarget(function (string $input) use ($parser): void {
$parser->parse(
'tt_content',
'bodytext',
1,
$input,
);
});
$config->setMaxLen(65536);
```
## Seed Corpus
Create seed inputs that the fuzzer uses as starting points in `Tests/Fuzz/corpus/`:
```
Tests/Fuzz/
├── ImageAttributeParserTarget.php
├── SoftReferenceParserTarget.php
├── corpus/
│ ├── image-parser/
│ │ ├── basic-img.txt # <img src="test.jpg" alt="Test" />
│ │ ├── fal-reference.txt # <img data-htmlarea-file-uid="123" />
│ │ ├── nested-structure.txt # <a href="#"><img src="x.jpg" /></a>
│ │ └── malformed.txt # <img src="test
│ └── softref-parser/
│ ├── basic-content.txt
│ └── multiple-images.txt
└── README.md
```
### Good Seed Inputs
Include variety in seeds:
- Valid minimal inputs
- Valid complex inputs
- Edge cases (empty, very long)
- Malformed inputs
- Special characters and encoding edge cases
## Running Fuzz Tests
### Via Composer Scripts
```json
{
"scripts": {
"ci:fuzz:image-parser": [
".Build/bin/php-fuzzer fuzz Tests/Fuzz/ImageAttributeParserTarget.php Tests/Fuzz/corpus/image-parser --max-runs 10000"
],
"ci:fuzz:softref-parser": [
".Build/bin/php-fuzzer fuzz Tests/Fuzz/SoftReferenceParserTarget.php Tests/Fuzz/corpus/softref-parser --max-runs 10000"
],
"ci:fuzz": [
"@ci:fuzz:image-parser",
"@ci:fuzz:softref-parser"
]
}
}
```
### Via runTests.sh
```bash
# Add to Build/Scripts/runTests.sh
fuzz)
FUZZ_TARGET="${1:-Tests/Fuzz/ImageAttributeParserTarget.php}"
FUZZ_CORPUS="Tests/Fuzz/corpus/image-parser"
FUZZ_MAX_RUNS="${2:-10000}"
if [[ "${FUZZ_TARGET}" == *"SoftReference"* ]]; then
FUZZ_CORPUS="Tests/Fuzz/corpus/softref-parser"
fi
COMMAND=(.Build/bin/php-fuzzer fuzz "${FUZZ_TARGET}" "${FUZZ_CORPUS}" --max-runs "${FUZZ_MAX_RUNS}")
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name fuzz-${SUFFIX} ${IMAGE_PHP} "${COMMAND[@]}"
SUITE_EXIT_CODE=$?
;;
```
Usage:
```bash
# Default target
Build/Scripts/runTests.sh -s fuzz
# Specific target
Build/Scripts/runTests.sh -s fuzz Tests/Fuzz/ImageAttributeParserTarget.php
# Custom max-runs
Build/Scripts/runTests.sh -s fuzz Tests/Fuzz/ImageAttributeParserTarget.php 50000
```
### Directly
```bash
# Run fuzzer with corpus directory (positional argument, not --corpus option!)
.Build/bin/php-fuzzer fuzz Tests/Fuzz/ImageAttributeParserTarget.php \
Tests/Fuzz/corpus/image-parser \
--max-runs 10000
```
## Interpreting Results
### Normal Output
```
Running fuzz test...
NEW: 0xabc123 - Found new coverage path
REDUCE: 0xdef456 - Simplified input while maintaining coverage
...
Fuzzing complete. 10000 runs, 0 crashes.
```
### Crash Found
```
CRASH: Tests/Fuzz/crashes/crash-abc123.txt
Error: Call to undefined method...
To reproduce:
php Tests/Fuzz/ImageAttributeParserTarget.php < Tests/Fuzz/crashes/crash-abc123.txt
```
### What to Look For
| Result | Meaning | Action |
|--------|---------|--------|
| NEW | Found input triggering new code path | Good - corpus expanding |
| REDUCE | Simplified input while keeping coverage | Good - efficient corpus |
| CRASH | Input caused exception/error | **Fix the bug** |
| TIMEOUT | Input caused infinite loop/hang | **Fix the performance issue** |
| OOM | Input caused memory exhaustion | **Fix memory handling** |
## CI Integration
Fuzz testing is typically **not run in CI** due to time requirements. Instead:
1. Run locally before releases
2. Run on schedule (weekly) for security-critical code
3. Run in dedicated security testing pipelines
### Optional CI Integration (Short Runs)
```yaml
# .github/workflows/fuzz.yml
name: Fuzz Testing
on:
schedule:
- cron: '0 0 * * 0' # Weekly on Sunday
workflow_dispatch: # Manual trigger
jobs:
fuzz:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- run: composer install
- name: Fuzz ImageAttributeParser
run: composer ci:fuzz:image-parser
continue-on-error: true
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-crashes
path: Tests/Fuzz/crashes/
```
## Best Practices
1. **Target security-critical code** - Prioritize parsers handling untrusted input
2. **Use meaningful seeds** - Good starting corpus improves coverage
3. **Set memory limits** - Prevent runaway memory usage with `setMaxLen()`
4. **Fix crashes immediately** - Fuzzer-found bugs are often exploitable
5. **Don't ignore OOM** - Memory exhaustion can be a DoS vector
6. **Document findings** - Track what was fuzzed and any issues found
## Directory Structure
```
Tests/
├── Unit/
├── Functional/
├── E2E/
└── Fuzz/
├── README.md
├── ImageAttributeParserTarget.php
├── SoftReferenceParserTarget.php
├── corpus/
│ ├── image-parser/
│ │ ├── seed1.txt
│ │ └── seed2.txt
│ └── softref-parser/
│ └── seed1.txt
└── crashes/ # Auto-generated when crashes found
└── crash-xxx.txt
```
## Resources
- [nikic/php-fuzzer](https://github.com/nikic/PHP-Fuzzer) - PHP coverage-guided fuzzer
- [Google OSS-Fuzz](https://google.github.io/oss-fuzz/) - Continuous fuzzing infrastructure
- [OWASP Fuzzing](https://owasp.org/www-community/Fuzzing) - Security fuzzing concepts
references/integration-testing.md
# Integration Testing for TYPO3 Extensions
Integration tests verify interactions between components with realistic (but mocked) external dependencies.
## Integration vs Functional vs E2E
| Type | Database | External APIs | TYPO3 Framework | Speed |
|------|----------|---------------|-----------------|-------|
| **Unit** | No | No | No | Fast (ms) |
| **Integration** | No | Mocked | Partial | Medium (ms) |
| **Functional** | Yes | No | Full | Slow (s) |
| **E2E** | Yes | Real/Mocked | Full + Browser | Slowest (s-min) |
**Integration tests** fill the gap between unit tests (isolated) and functional tests (full framework):
- Test component interactions
- Mock external APIs (HTTP, LDAP, OAuth)
- Verify request/response handling
- Test without database overhead
## Directory Structure
```
Tests/
├── Unit/
├── Integration/
│ ├── AbstractIntegrationTestCase.php
│ ├── Service/
│ │ └── ApiServiceIntegrationTest.php
│ └── Provider/
│ └── OAuthProviderIntegrationTest.php
└── Functional/
```
## Base Test Case
```php
<?php
declare(strict_types=1);
namespace Vendor\Extension\Tests\Integration;
use GuzzleHttp\Psr7\HttpFactory;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
/**
* Base class for integration tests.
*
* Provides utilities for testing API interactions
* with realistic HTTP responses.
*/
abstract class AbstractIntegrationTestCase extends TestCase
{
protected RequestFactoryInterface $requestFactory;
protected StreamFactoryInterface $streamFactory;
protected function setUp(): void
{
parent::setUp();
$this->requestFactory = new HttpFactory();
$this->streamFactory = new HttpFactory();
}
/**
* Create an HTTP client stub that returns sequential responses.
*
* @param list<ResponseInterface> $responses
*/
protected function createHttpClientWithResponses(array $responses): ClientInterface&Stub
{
$client = self::createStub(ClientInterface::class);
$client->method('sendRequest')
->willReturnOnConsecutiveCalls(...$responses);
return $client;
}
/**
* Create a successful JSON response.
*
* @param array<string, mixed> $body
*/
protected function createSuccessResponse(array $body, int $statusCode = 200): ResponseInterface
{
return new Response(
status: $statusCode,
headers: ['Content-Type' => 'application/json'],
body: \json_encode($body, JSON_THROW_ON_ERROR),
);
}
/**
* Create an error response.
*
* @param array<string, mixed> $body
*/
protected function createErrorResponse(array $body, int $statusCode = 400): ResponseInterface
{
return new Response(
status: $statusCode,
headers: ['Content-Type' => 'application/json'],
body: \json_encode($body, JSON_THROW_ON_ERROR),
);
}
/**
* Create a stub HTTP client that captures request bodies.
*
* @return array{client: ClientInterface&Stub, requests: array<RequestInterface>}
*/
protected function createRequestCapturingClient(ResponseInterface $response): array
{
$requests = [];
$client = self::createStub(ClientInterface::class);
$client->method('sendRequest')
->willReturnCallback(function (RequestInterface $request) use ($response, &$requests) {
$requests[] = $request;
return $response;
});
return ['client' => $client, 'requests' => &$requests];
}
}
```
## Integration Test Examples
### API Service Integration
```php
<?php
declare(strict_types=1);
namespace Vendor\Extension\Tests\Integration\Service;
use Vendor\Extension\Service\ExternalApiService;
use Vendor\Extension\Tests\Integration\AbstractIntegrationTestCase;
final class ExternalApiServiceIntegrationTest extends AbstractIntegrationTestCase
{
/**
* @test
*/
public function fetchDataReturnsDeserializedResponse(): void
{
// Arrange: Mock HTTP client with expected response
$expectedData = [
'items' => [
['id' => 1, 'name' => 'Item 1'],
['id' => 2, 'name' => 'Item 2'],
],
'total' => 2,
];
$client = $this->createHttpClientWithResponses([
$this->createSuccessResponse($expectedData),
]);
$service = new ExternalApiService(
httpClient: $client,
requestFactory: $this->requestFactory,
);
// Act
$result = $service->fetchItems();
// Assert
self::assertCount(2, $result->getItems());
self::assertSame(2, $result->getTotal());
}
/**
* @test
*/
public function fetchDataHandlesRateLimitWithRetry(): void
{
// Arrange: First request fails with 429, second succeeds
$client = $this->createHttpClientWithResponses([
$this->createErrorResponse(['error' => 'Rate limit exceeded'], 429),
$this->createSuccessResponse(['items' => [], 'total' => 0]),
]);
$service = new ExternalApiService(
httpClient: $client,
requestFactory: $this->requestFactory,
);
// Act
$result = $service->fetchItems();
// Assert: Should succeed after retry
self::assertSame(0, $result->getTotal());
}
/**
* @test
*/
public function createItemSendsCorrectPayload(): void
{
// Arrange: Capture the request
['client' => $client, 'requests' => $requests] = $this->createRequestCapturingClient(
$this->createSuccessResponse(['id' => 123, 'created' => true], 201)
);
$service = new ExternalApiService(
httpClient: $client,
requestFactory: $this->requestFactory,
streamFactory: $this->streamFactory,
);
// Act
$service->createItem('Test Item', ['category' => 'test']);
// Assert: Verify request payload
self::assertCount(1, $requests);
$request = $requests[0];
self::assertSame('POST', $request->getMethod());
self::assertStringContainsString('/api/items', (string)$request->getUri());
$body = \json_decode((string)$request->getBody(), true);
self::assertSame('Test Item', $body['name']);
self::assertSame('test', $body['category']);
}
}
```
### OAuth Provider Integration
```php
<?php
declare(strict_types=1);
namespace Vendor\Extension\Tests\Integration\Provider;
use Vendor\Extension\Provider\OAuthProvider;
use Vendor\Extension\Tests\Integration\AbstractIntegrationTestCase;
final class OAuthProviderIntegrationTest extends AbstractIntegrationTestCase
{
/**
* @test
*/
public function exchangeCodeForTokenReturnsAccessToken(): void
{
// Arrange: Mock OAuth token endpoint response
$tokenResponse = [
'access_token' => 'test_access_token_123',
'token_type' => 'Bearer',
'expires_in' => 3600,
'refresh_token' => 'test_refresh_token_456',
];
$client = $this->createHttpClientWithResponses([
$this->createSuccessResponse($tokenResponse),
]);
$provider = new OAuthProvider(
httpClient: $client,
requestFactory: $this->requestFactory,
streamFactory: $this->streamFactory,
clientId: 'test_client',
clientSecret: 'test_secret',
tokenEndpoint: 'https://oauth.example.com/token',
);
// Act
$token = $provider->exchangeCodeForToken('auth_code_xyz');
// Assert
self::assertSame('test_access_token_123', $token->getAccessToken());
self::assertSame('Bearer', $token->getTokenType());
self::assertSame(3600, $token->getExpiresIn());
}
/**
* @test
*/
public function refreshTokenObtainsNewAccessToken(): void
{
// Arrange
$refreshResponse = [
'access_token' => 'new_access_token_789',
'token_type' => 'Bearer',
'expires_in' => 3600,
];
['client' => $client, 'requests' => $requests] = $this->createRequestCapturingClient(
$this->createSuccessResponse($refreshResponse)
);
$provider = new OAuthProvider(
httpClient: $client,
requestFactory: $this->requestFactory,
streamFactory: $this->streamFactory,
clientId: 'test_client',
clientSecret: 'test_secret',
tokenEndpoint: 'https://oauth.example.com/token',
);
// Act
$token = $provider->refreshToken('old_refresh_token');
// Assert: Verify refresh grant was sent
$body = (string)$requests[0]->getBody();
self::assertStringContainsString('grant_type=refresh_token', $body);
self::assertStringContainsString('refresh_token=old_refresh_token', $body);
self::assertSame('new_access_token_789', $token->getAccessToken());
}
}
```
## Provider Response Helpers
For LLM/AI provider integrations, create response helpers:
```php
/**
* Get OpenAI-style chat completion response.
*
* @return array<string, mixed>
*/
protected function getOpenAiChatResponse(
string $content = 'Test response',
string $model = 'gpt-4o',
string $finishReason = 'stop',
): array {
return [
'id' => 'chatcmpl-' . \bin2hex(\random_bytes(12)),
'object' => 'chat.completion',
'created' => \time(),
'model' => $model,
'choices' => [
[
'index' => 0,
'message' => [
'role' => 'assistant',
'content' => $content,
],
'finish_reason' => $finishReason,
],
],
'usage' => [
'prompt_tokens' => \random_int(10, 100),
'completion_tokens' => \random_int(20, 200),
'total_tokens' => \random_int(30, 300),
],
];
}
/**
* Get Claude-style chat completion response.
*
* @return array<string, mixed>
*/
protected function getClaudeChatResponse(
string $content = 'Test response',
string $model = 'claude-sonnet-4-20250514',
string $stopReason = 'end_turn',
): array {
return [
'id' => 'msg_' . \bin2hex(\random_bytes(12)),
'type' => 'message',
'role' => 'assistant',
'content' => [
['type' => 'text', 'text' => $content],
],
'model' => $model,
'stop_reason' => $stopReason,
'usage' => [
'input_tokens' => \random_int(10, 100),
'output_tokens' => \random_int(20, 200),
],
];
}
```
## When to Use Integration Tests
### Use Integration Tests For:
- HTTP client interactions
- OAuth/authentication flows
- Third-party API integrations
- Request/response serialization
- Error handling and retries
- Rate limiting behavior
### Use Functional Tests Instead For:
- Database operations
- TYPO3 DataHandler hooks
- TCA/FlexForm processing
- Caching behavior
- Full request lifecycle
## Running Integration Tests
Integration tests typically run with unit tests (same speed characteristics):
```bash
# Run with unit tests
Build/Scripts/runTests.sh -s unit
# Or in separate suite if desired
.Build/bin/phpunit -c Build/phpunit/IntegrationTests.xml
```
## Best Practices
1. **Mock external dependencies**: Never make real HTTP calls
2. **Test error paths**: 4xx, 5xx, timeouts, malformed responses
3. **Verify request payloads**: Capture and assert request bodies
4. **Use realistic responses**: Copy actual API responses
5. **Keep tests fast**: No database, no network
6. **Document API contracts**: Response helpers serve as documentation
## Dependency Injection Pattern
For services with HTTP dependencies, use constructor injection:
```php
final class MyApiService
{
public function __construct(
private readonly ClientInterface $httpClient,
private readonly RequestFactoryInterface $requestFactory,
private readonly StreamFactoryInterface $streamFactory,
) {}
}
```
This enables easy testing:
```php
$service = new MyApiService(
httpClient: $this->createHttpClientWithResponses([...]),
requestFactory: $this->requestFactory,
streamFactory: $this->streamFactory,
);
```
## Resources
- [PSR-18 HTTP Client](https://www.php-fig.org/psr/psr-18/)
- [PSR-17 HTTP Factories](https://www.php-fig.org/psr/psr-17/)
- [Guzzle PSR-7](https://github.com/guzzle/psr7)
references/javascript-testing.md
# 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 — an RTE image plugin:**
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
});
});
```
### Vitest: Cross-directory Coverage (production at `../../../Resources/...`)
TYPO3 extensions place JS tests under `Tests/JavaScript/` while
production code lives in `Resources/Public/JavaScript/`. Tests grouped
into subdirectories (e.g. `Tests/JavaScript/Plugins/foo.test.ts`)
import production via relative paths such as
`../../../Resources/Public/JavaScript/Plugins/foo.js`.
By default, **Vitest's v8 coverage provider silently drops files
outside the test workspace** — `lcov.info` ends up empty even though
all tests pass. Symptom: SonarCloud (or any lcov consumer) reports 0%
JS coverage despite imports working and tests asserting against the
production code.
This recipe assumes `vitest.config.ts` lives in `Tests/JavaScript/`
and Vitest runs from that directory (so `reportsDirectory: './coverage'`
resolves to `Tests/JavaScript/coverage/`, matching the Sonar path
below). The fix is `coverage.allowExternal: true`:
```ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
include: ['**/*.test.ts', '**/*.test.js'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
reportsDirectory: './coverage',
// Production code lives outside this workspace
// (../../../Resources/Public/JavaScript/), so v8 needs
// allowExternal to instrument it — without this,
// lcov.info is empty.
allowExternal: true,
include: ['**/Resources/Public/JavaScript/**/*.js'],
exclude: ['**/node_modules/**', '**/Tests/**', '**/mocks/**'],
},
},
});
```
Note that the `coverage.include` glob uses a suffix pattern
(`**/Resources/...`) rather than the relative-path form
(`../../../Resources/...`). v8 matches file paths anywhere in the
project tree, not relative to Vitest's working directory; the relative
form silently produces 0/0 even with `allowExternal` enabled.
Coverage is opt-in: Vitest only writes `lcov.info` when invoked with
`--coverage` (or with `coverage.enabled: true` in the config). Wire it
into a script so CI and local runs match:
```json
{
"scripts": {
"test": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
```
For SonarCloud upload (paths are repository-root-relative), point at
the resulting lcov:
```properties
sonar.javascript.lcov.reportPaths=Tests/JavaScript/coverage/lcov.info
```
## Unit Tests Do Not Prove UI Works
Vitest/Jest unit tests around DOM helpers, event handlers, or "controller" JS classes verify **logic in isolation**. They do **not** exercise:
- TYPO3's real `Modal` component (lives in `@typo3/backend/modal.js`, ships its own shadow DOM in v13+)
- Real browser event dispatch / bubbling
- Backend page chrome (iframes, top frame, module router in v14)
- Network round-trips against actual TYPO3 endpoints
**Rule:** never claim a UI/JS change "works" on the basis of green unit tests alone. For any change that touches the rendered backend UI, do one of:
1. Write a Playwright E2E spec under `Tests/E2E/` and run it against DDEV.
2. Push the branch and ask the human to verify in a real browser.
A passing unit test is evidence that the function under test does what its tests assert -- not that the feature works for a backend user. Skipping this distinction is the single most common cause of "you said it worked, but it doesn't" feedback on PRs.
## TYPO3 Modal API: `button.clicked` Does Not Cross Shadow DOM
TYPO3 v13+ wraps the backend `Modal` in a shadow DOM. The internal `button.clicked` event is dispatched **inside** the shadow root and does not bubble out to the modal host element. Code that does this:
```javascript
// BROKEN: event never fires the listener -- the shadow root swallows it
const modal = Modal.show({ /* ... */ });
modal.addEventListener('button.clicked', (e) => { /* ... */ });
```
silently does nothing on v13+ even though it appeared to work on v12 with the legacy modal. The supported, cross-version API is the per-button `trigger` callback supplied at `Modal.show()` time:
```javascript
import Modal from '@typo3/backend/modal.js';
import Severity from '@typo3/backend/severity.js';
Modal.show({
title: 'Confirm',
content: 'Delete this passkey?',
severity: Severity.warning,
buttons: [
{ text: 'Cancel', btnClass: 'btn-default', trigger: () => { /* dismissed */ } },
{ text: 'Delete', btnClass: 'btn-warning', trigger: () => { performDelete(); } },
],
});
```
`trigger` callbacks are invoked the same way on TYPO3 v12, v13 and v14, regardless of whether the modal is rendered into the light DOM or a shadow root. Use them as the only event hook for modal buttons. (Page Object Models in `references/e2e-testing.md` test the rendered modal from the outside via `.modal` selectors -- they do not rely on `button.clicked` either.)
## 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
});
```
## JavaScript Migration Testing
When migrating jQuery code to native JavaScript (ES modules), specific testing patterns
are critical to catch subtle scoping and security bugs that static analysis often misses.
### Variable Scoping Bug in `$.each` to `for...of` Migration
**The Problem:** jQuery's `$.each(array, function(key, value) { ... })` creates a
**function scope** per iteration. Every `var` declaration inside the callback is unique
to that iteration. When migrating to native `for (const [key, value] of Object.entries(obj))`,
the loop body is only a **block scope**. Any `var` declarations are **hoisted** to the
enclosing function and **shared** across all iterations.
This silently breaks closures that capture loop variables (event handlers, callbacks,
`setTimeout`, etc.).
**Real-world bug (t3x-rte_ckeditor_image [#633](https://github.com/netresearch/t3x-rte_ckeditor_image/issues/633), [PR #641](https://github.com/netresearch/t3x-rte_ckeditor_image/pull/641)):**
The image dialog's aspect ratio handler iterated over `{width, height}` with `$.each`.
Inside the callback, `var el` and `var max` were captured by a `constrainDimensions`
closure attached as an event handler. After converting `$.each` to `for...of` without
also converting `var` to `let`/`const`, `el` and `max` were shared across width/height
iterations. The height handler always referenced the width element, so changing width
never triggered height auto-adjustment.
E2E symptom: `toBeLessThanOrEqual` expected height ratio <=1, received 300 (the raw
pixel value instead of the computed ratio).
**Before (jQuery -- works correctly):**
```javascript
$.each({width: maxWidth, height: maxHeight}, function (dimension, max) {
var el = document.getElementById('dimension-' + dimension);
var constrainDimensions = function () {
// `el` and `max` are unique per iteration (function scope)
if (parseInt(el.value, 10) > max) {
el.value = max;
}
};
el.addEventListener('input', constrainDimensions);
});
```
**After (broken -- `var` hoisted to function scope):**
```javascript
for (const [dimension, max] of Object.entries({width: maxWidth, height: maxHeight})) {
var el = document.getElementById('dimension-' + dimension); // BUG: shared!
var constrainDimensions = function () {
// `el` always points to LAST iteration's element (height)
// `max` is always the LAST iteration's value
};
el.addEventListener('input', constrainDimensions);
}
```
**After (fixed -- `let` creates block scope):**
```javascript
for (const [dimension, max] of Object.entries({width: maxWidth, height: maxHeight})) {
const el = document.getElementById('dimension-' + dimension); // unique per iteration
const constrainDimensions = function () {
// `el` and `max` are correctly captured per iteration
if (parseInt(el.value, 10) > max) {
el.value = max;
}
};
el.addEventListener('input', constrainDimensions);
}
```
**Rule:** When migrating `$.each` to `for...of`, convert ALL `var` declarations inside
the loop body to `let`/`const` at the SAME TIME as the loop conversion. Never split
these into separate commits.
### Testing Intermediate Commits in Migration PRs
When a jQuery removal PR has multiple commits (e.g., "convert loops" then "convert var
to let/const"), CI may test intermediate commits where `$.each` is already converted
but `var` has not yet been changed. This intermediate state has the scoping bug described
above.
**Best practice:**
- Squash loop conversion and `var` to `let`/`const` conversion into a single atomic commit
- If separate commits are needed for review clarity, mark intermediate commits as
`[skip ci]` or ensure the PR's merge strategy squashes them
- E2E tests that exercise closure behavior (event handlers, callbacks) will catch this
class of bug -- add them before the migration
### `insertAdjacentHTML` and CodeQL XSS Warnings
When replacing jQuery's `$.append()` or `$.html()` with native DOM methods, avoid
`insertAdjacentHTML` with template literals:
```javascript
// TRIGGERS CodeQL js/xss-through-dom
el.insertAdjacentHTML('beforeend', `<span>${userInput}</span>`);
```
**Fix:** Use `createElement` + `textContent` for user-controlled content:
```javascript
// SAFE -- textContent auto-escapes
const span = document.createElement('span');
span.textContent = userInput;
el.appendChild(span);
```
For static HTML without user input, `insertAdjacentHTML` is acceptable but CodeQL may
still flag it. Prefer `createElement` chains to avoid false positives and keep the
codebase consistently safe.
### E2E Test Patterns for JS Migration Verification
When testing that a jQuery-to-native-JS migration preserves behavior, focus on:
1. **Closure-dependent behavior:** Event handlers registered inside loops must still
reference the correct variables. Test each iteration's handler independently.
2. **DOM manipulation timing:** jQuery's `.ready()` vs native `DOMContentLoaded` or
ES module top-level execution can shift when code runs.
3. **Event delegation:** jQuery's `.on(selector, handler)` delegation must be replaced
with explicit `addEventListener` on the correct target or a manual delegation pattern.
4. **AJAX/fetch migration:** jQuery's `$.ajax` coerces responses differently than
`fetch`. Verify response parsing in E2E tests.
```typescript
// E2E test verifying aspect ratio constraint survives migration
test('changing width auto-adjusts height to maintain aspect ratio', async ({ page }) => {
// ... navigate to image dialog ...
const widthInput = page.locator('#image-width');
const heightInput = page.locator('#image-height');
// Get original dimensions
const originalWidth = await widthInput.inputValue();
const originalHeight = await heightInput.inputValue();
const aspectRatio = parseInt(originalHeight, 10) / parseInt(originalWidth, 10);
// Change width
await widthInput.fill('200');
await widthInput.dispatchEvent('input');
// Height must auto-adjust
const newHeight = parseInt(await heightInput.inputValue(), 10);
const expectedHeight = Math.round(200 * aspectRatio);
expect(newHeight).toBeLessThanOrEqual(expectedHeight + 1);
expect(newHeight).toBeGreaterThanOrEqual(expectedHeight - 1);
});
```
## 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)
- [MDN: var hoisting](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/var#hoisting)
- [MDN: let block scope](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let)
- [CodeQL js/xss-through-dom](https://codeql.github.com/codeql-query-help/javascript/js-xss-through-dom/)
references/mock-validity.md
# Mock Validity for Multi-Version Dependencies
When extensions support multiple major versions of a dependency (e.g., `"intervention/image": "^3 || ^4"`), test mocks must remain compatible across all supported versions. This reference covers common pitfalls and patterns.
## The Problem
Mocking a method that exists on an interface in one version but not another causes silent test failures or runtime errors when CI runs against the other version.
```php
// WRONG - The mock targets Intervention\Image\Interfaces\ImageInterface,
// but it configures toWebp(), which is only implemented on the concrete
// Image class in intervention/image v3 and is not declared on ImageInterface
// in any supported version.
$imageMock = $this->createMock(ImageInterface::class);
$imageMock->expects(self::once())
->method('toWebp') // Does not exist on ImageInterface in v4!
->willReturn($encodedMock);
```
## Rule: Verify Mocked Methods Exist on the Interface
Before mocking `->method('foo')` on an interface, verify that `foo` is declared on that interface in **all** supported versions of the dependency.
### Verification Checklist
1. Open the interface definition in `vendor/` for the current version
2. Check the method exists on the **interface**, not just concrete implementations
3. Repeat for each major version in the composer constraint
4. If the method only exists on a concrete class, either:
- Mock the concrete class instead (if not final)
- Use an adapter pattern (preferred -- see below)
- Use `method_exists()` guards in the mock setup
### Example: Intervention Image v3 vs v4
```php
// intervention/image v3: Image class has ->toWebp(), ->toJpeg(), etc.
// intervention/image v4: Image class has ->encodeByPath(), ->encodeByExtension()
// Both versions: ImageInterface has ->save(), ->encode()
// WRONG - version-specific method on interface mock
$imageMock = $this->createMock(ImageInterface::class);
$imageMock->method('toWebp')->willReturn($encodedMock);
// CORRECT - use only methods defined on the interface in ALL versions
$imageMock = $this->createMock(ImageInterface::class);
$imageMock->method('save')->willReturn($imageMock);
```
## Scanning Mocks After Dependency Constraint Changes
When widening a dependency constraint (e.g., `"^3"` to `"^3 || ^4"`), perform a mock audit:
```bash
# Find all mocked methods on the dependency's interfaces
grep -rn "->method('" Tests/ | grep -i "intervention\|dependency-name"
# Cross-reference with the interface in each supported version
# Check vendor/intervention/image/src/Interfaces/ImageInterface.php
```
### Automated Check Pattern
```php
/**
* Verify that all mocked methods exist on the interface.
* Run this test against each supported dependency version.
*/
#[Test]
public function mockedMethodsExistOnInterface(): void
{
$interface = new \ReflectionClass(ImageInterface::class);
$methods = array_map(
static fn(\ReflectionMethod $m) => $m->getName(),
$interface->getMethods(),
);
// List every method your tests mock on this interface
$mockedMethods = ['save', 'encode', 'width', 'height'];
foreach ($mockedMethods as $method) {
self::assertTrue(
in_array($method, $methods, true),
sprintf(
'Test mocks %s::%s() but this method does not exist on the interface '
. 'in the installed version. Check all supported versions.',
ImageInterface::class,
$method,
),
);
}
}
```
## Mock Callback Signature Verification
When using `willReturnCallback()`, the callback's parameter signature must match the actual method signature. If production code changes to pass additional arguments, callbacks silently ignore them or fail.
### The Problem
```php
// Production code changed from:
// $processor->process($path)
// to:
// $processor->process($path, $quality)
// WRONG - callback signature is stale, ignores $quality
$processorMock->method('process')
->willReturnCallback(function (string $path) {
return '/processed/' . basename($path);
});
// Test passes but $quality is silently dropped -- assertions on quality are missing
```
### Rule: Match Callback Signatures to Method Signatures
```php
// CORRECT - callback accepts all parameters the real method declares
$processorMock->method('process')
->willReturnCallback(function (string $path, int $quality = 80) {
self::assertSame(75, $quality); // Verify the quality parameter
return '/processed/' . basename($path);
});
```
### Use Variadic Signatures for Forward Compatibility
When a method signature may evolve across versions, use a variadic callback:
```php
// FORWARD-COMPATIBLE - accepts any arguments the method might pass
$processorMock->method('process')
->willReturnCallback(function (string $path, mixed ...$options): string {
// $options[0] would be quality if passed
return '/processed/' . basename($path);
});
```
### Callback Signature Audit
After any production code change that adds parameters to a method call, search for all test callbacks mocking that method:
```bash
# Find all willReturnCallback usages for a specific method
grep -rn "method('process')" Tests/ | grep -A5 "willReturnCallback"
```
### A Missing `use` in a Callback Signature Passes the Suite
The sibling failure of a stale signature: a callback whose parameter *type* is not imported. PHP resolves a parameter's class type only when a value is actually checked against it, and `null` never is — so a nullable parameter that only ever receives `null` in the test never touches the unresolvable name.
```php
// The test file has no `use ...\ValueObject\AgentRunReference;`
$mgr->method('chatWithTools')->willReturnCallback(
function (
array $messages,
?AgentRunReference $run = null, // resolves to the CURRENT namespace
?InjectedContext $injected = null,
) use (&$seen): CompletionResponse {
$seen = $injected;
return $this->response('done');
},
);
```
Green. Every call in the test passes `null` for `$run`, so PHP never loads
`Netresearch\NrLlm\Tests\Unit\Service\Tool\AgentRunReference` — a class that
does not exist. The day production starts passing a real object, the test dies
with `Argument #2 must be of type ?AgentRunReference` naming a class nobody
recognises.
**PHPStan finds it and the suite cannot:**
```
Parameter $run of anonymous function has invalid type
Netresearch\NrLlm\Tests\Unit\Service\Tool\AgentRunReference.
```
The recommended PHPStan configuration in `quality-tools.md` lists `Tests` in
`paths`, so the analyser does see this — but only if you run it **after**
writing the tests, over the final tree. Ordering matters here in a way it does
not for production code: a green `runTests.sh -s unit` is not evidence that a
new test file is even type-correct, so analysing before the tests exist checks
the half that was already fine.
Two habits close it without waiting for the analyser:
- Copy the parameter list from the interface, then copy its `use` statements.
A signature pasted without its imports is the whole failure mode.
- Treat every `?Type $x = null` parameter in a test double as unverified until
something passes a non-null value — either a second test case that does, or
PHPStan.
## Test Assertion Specificity After Refactoring
When refactoring production code, test assertions must maintain equivalent specificity. A refactoring that changes the API surface (e.g., replacing `toWebp()->save()` with `save('output.webp')`) requires updated assertions that verify the same behavior.
### The Problem
```php
// OLD production code:
// $image->toWebp()->save($path)
// OLD test assertion:
// $imageMock->expects(self::never())->method('toWebp')
// This asserts "WebP conversion never happens"
// NEW production code:
// $image->save($path) -- format determined by extension
// NEW test (WRONG - lost specificity):
// $imageMock->expects(self::once())->method('save')
// This only asserts "save was called" but not "save was NOT called with .webp"
```
### Rule: Maintain Equivalent Assertion Specificity
```php
// NEW test (CORRECT - equivalent specificity to the old assertion)
$imageMock->expects(self::exactly(2))
->method('save')
->willReturnCallback(function (string $path) use ($imageMock): ImageInterface {
// Assert that no .webp save happens (equivalent to old "never toWebp()")
self::assertStringNotContainsString(
'.webp',
$path,
'Image should not be saved as WebP when WebP is disabled',
);
return $imageMock;
});
```
### Specificity Mapping Guide
When refactoring, map old assertions to new ones:
| Old Assertion | New Equivalent |
|--------------|----------------|
| `expects(never())->method('toWebp')` | `save()` callback asserts path has no `.webp` extension |
| `expects(once())->method('toJpeg')` | `save()` callback asserts path ends with `.jpg` |
| `expects(once())->method('resize')->with(800, 600)` | `save()` callback verifies dimensions if applicable |
| Method-level `expects(exactly(N))` | `expects(exactly(N))` with path-based assertions in callback |
### Anti-Pattern: Generic Callbacks Without Assertions
```php
// WRONG - callback provides return value but asserts nothing
$mock->method('save')
->willReturnCallback(fn(string $path) => $mock);
// CORRECT - callback asserts expectations about the arguments
$mock->expects(self::exactly(2))
->method('save')
->willReturnCallback(function (string $path) use ($mock): ImageInterface {
self::assertStringEndsWith('.jpg', $path);
return $mock;
});
```
### Gotcha: `->with()` Only Constrains the Arguments You Pass
`->with($a)` checks **only** the first argument; any further arguments in the actual
call are unconstrained. So when you add a parameter to a method, existing
single-argument expectations keep passing silently — they neither fail nor verify
the new argument:
```php
// Production change:
// getCacheLifetime(Context $c) -> getCacheLifetime(Context $c, ?int $pageId)
// This existing expectation still PASSES after the change, even though the call
// is now getCacheLifetime($context, 42) — the extra arg is not checked:
$mock->expects(self::once())->method('getCacheLifetime')->with($context);
```
Consequences:
- Widening a signature (N -> N+1 args) will **not** break `->with()` assertions that
only pin the original args — convenient, but a green suite does not prove the new
argument is exercised.
- To verify the new argument, pin it explicitly: `->with($context, 42)` (or
`self::anything()` for positions you intentionally leave open).
## Adapter Pattern Testing
When your extension wraps a version-specific third-party API behind an adapter interface, test through the adapter interface rather than creating complex version-specific mock setups.
### The Problem
```php
// WRONG - creates version-specific mocks that break across versions
$driverMock = $this->createMock(DriverInterface::class); // v3-only interface
$driverMock->method('init')->willReturn($driverMock);
$manager = new ImageManager($driverMock);
// ... complex setup that differs between v3 and v4
```
### The Solution: Mock the Adapter Interface
```php
// Define your adapter interface
interface ImageProcessorInterface
{
public function resize(string $sourcePath, int $width, int $height): string;
public function convert(string $sourcePath, string $targetFormat): string;
public function optimize(string $sourcePath, int $quality): string;
}
// Your adapter wraps the version-specific API
final class InterventionImageProcessor implements ImageProcessorInterface
{
public function __construct(private readonly ImageManager $manager) {}
public function resize(string $sourcePath, int $width, int $height): string
{
$image = $this->manager->read($sourcePath);
$image->resize($width, $height);
return $image->save($sourcePath)->basePath(); // Note: This example overwrites the source file.
}
}
```
```php
// TEST - mock the adapter interface, not the third-party library
final class ImageServiceTest extends UnitTestCase
{
/** @var ImageProcessorInterface&MockObject */
private ImageProcessorInterface $processorMock;
protected function setUp(): void
{
parent::setUp();
/** @var ImageProcessorInterface&MockObject $processorMock */
$processorMock = $this->createMock(ImageProcessorInterface::class);
$this->processorMock = $processorMock;
$this->subject = new ImageService($this->processorMock);
}
#[Test]
public function optimizeImageCallsProcessorWithCorrectQuality(): void
{
$this->processorMock
->expects(self::once())
->method('optimize')
->with('/path/to/image.jpg', 75)
->willReturn('/path/to/image.jpg');
$this->subject->optimizeImage('/path/to/image.jpg', 75);
}
}
```
### Benefits of Adapter Pattern Testing
| Concern | Without Adapter | With Adapter |
|---------|----------------|--------------|
| Version-specific mocks | Required for each version | None needed |
| Test complexity | High (mock internal APIs) | Low (mock your own interface) |
| Breakage on upgrade | Tests break when dependency updates | Only adapter implementation changes |
| Mock validity | Must verify methods on third-party interfaces | Mock your own stable interface |
| Test isolation | Coupled to dependency internals | Fully decoupled |
### When to Use Adapter Pattern
- The dependency has significantly different APIs across supported major versions
- Multiple classes in your extension interact with the dependency
- The dependency's interfaces are not stable across versions
- You need to support `^major1 || ^major2` in `composer.json`
### When NOT to Use Adapter Pattern
- The dependency has a stable, well-defined interface that does not change
- Only one class in your extension uses the dependency
- The overhead of the adapter exceeds the testing benefit
references/mutation-testing.md
# Mutation Testing for TYPO3 Extensions
## Overview
Mutation testing verifies test suite quality by introducing small bugs (mutants) into the code and checking if tests catch them. If a test suite has good coverage but low mutation score, the tests may not be actually testing the important behaviors.
> **Key Distinction**: Mutation testing mutates **code** to verify test quality. For testing that mutates **inputs** to find crashes, see [Fuzz Testing](fuzz-testing.md).
| Aspect | Mutation Testing | Fuzz Testing |
|--------|-----------------|--------------|
| **Mutates** | Source code | Input data |
| **Purpose** | Verify test quality | Find crashes/vulnerabilities |
| **Example** | `if (x != y)` → `if (x == y)` | `<img src="` → `<img src="../../../../etc/passwd` |
| **Finds** | Weak/missing tests | Parsing bugs, security issues |
| **Tool (PHP)** | Infection | nikic/php-fuzzer |
## When to Use Mutation Testing
- After achieving high code coverage (70%+) to verify test quality
- Before releases to ensure critical paths are well-tested
- When refactoring to ensure tests catch regressions
- To identify "weak spots" in test coverage
## The Single-Mutant Check: Prove One Regression Guard By Hand
A full Infection run is the wrong tool when the question is narrow: *does the test I just
wrote actually catch the bug I just fixed?* Answer it directly — revert the production line
to its buggy form, run the affected tests, and require them to **fail**; then restore the
fix and require them to pass. A test that stays green both ways guards nothing, and it looks
identical to a working one in a passing suite.
Do this whenever the bug is one a test could plausibly miss: a silently dropped argument, a
default that quietly substitutes for a rejected value, a mocked collaborator that accepts any
call shape. Mock-based tests are especially prone to it — a mock cannot reproduce the real
library's argument filtering, so asserting *how* it was called is the only thing that binds.
Run it through `runTests.sh` so the check inherits the project's Docker PHP-version isolation —
a pass/fail signal is only worth as much as the environment it came from:
```bash
# 1. Baseline: the guard passes
Build/Scripts/runTests.sh -s unit -- --filter=theGuardingTest
# 2. Reintroduce the bug (exact string replace, not a regex sed)
python3 - <<'PY'
p = 'Classes/Service/Thing.php'
s = open(p).read()
old, new = "save($path, quality: $quality)", "save($path, $quality)"
assert s.count(old) == 1, f'expected 1 occurrence, found {s.count(old)}'
open(p, 'w').write(s.replace(old, new))
PY
grep -n 'save(\$path' Classes/Service/Thing.php # confirm the edit applied
# 3. The guard MUST fail now
Build/Scripts/runTests.sh -s unit -- --filter=theGuardingTest
# 4. Restore and confirm green again
git checkout -- Classes/Service/Thing.php
git diff --quiet -- Classes/ && Build/Scripts/runTests.sh -s unit -- --filter=theGuardingTest
```
Where the project has no `runTests.sh` — or you are already inside the container — the direct
equivalent is `vendor/bin/phpunit -c Build/phpunit/UnitTests.xml --filter=theGuardingTest`. This is
the single-class case that [`test-runners.md`](test-runners.md) reserves plain `phpunit --filter`
for; do not generalise it to running whole suites outside the entry point.
Two traps make this check lie:
- **Verify the mutation applied.** A `sed` pattern that silently matches nothing leaves the
file untouched, and the suite then "fails" for some unrelated reason — or passes, and you
conclude the guard is worthless. Assert the occurrence count when replacing, and `grep`
the file afterwards. Restore from a byte copy and confirm with `git diff --quiet`.
- **Read the failure, not just the exit code.** In an environment with pre-existing failures
(a missing `gd`/`imagick` extension makes unrelated tests error), a non-zero exit proves
nothing on its own. Scope the run with `--filter`, or compare the failing-test set against
the baseline.
Re-run this after any refactor of the test itself — consolidating duplicated test setup can
quietly detach the assertion from the behaviour it was guarding.
## Tools
### Infection (Recommended)
PHP mutation testing framework with PHPUnit integration.
**Installation:**
```bash
composer require --dev infection/infection:^0.27
```
**Key features:**
- Mutates PHP code with various operators
- Integrates with PHPUnit and Pest
- Generates HTML and JSON reports
- Supports incremental analysis
## Preflight: Tests Must Pass Before Mutating
Infection runs the configured PHPUnit suite **once, unmutated**, before applying any mutations. If that initial run reports failures or errors -- even a single one -- Infection aborts and reports nothing. In practice, two recurring causes blow up the preflight:
1. **Flaky fuzz tests.** `random_int(0, $n)` legitimately returns `0`, and `random_bytes(0)` then throws `\ValueError("random_bytes(): Argument #1 ($length) must be greater than 0")` (PHP 8.0+ — was `\Error` before). The fuzz suite passes most of the time and randomly fails inside Infection's preflight. **Fix:** use `random_int(1, $n)` (or `max(1, $n)`) anywhere a randomly-chosen length feeds into `random_bytes()` / `openssl_random_pseudo_bytes()` / similar zero-rejecting APIs.
2. **Functional tests included in the unit suite.** If `phpunit.xml` mixes unit and functional suites, Infection tries to boot a database it cannot reach during local mutation runs.
**Rule of thumb:** before running `infection`, run the exact same command Infection will run (`testFrameworkOptions` from `infection.json5`) and confirm it is green. Fix flakes there, not in Infection's CI logs.
## Suite Layout: Split Unit/Fuzz From Functional
Keep two PHPUnit configs:
- `phpunit.xml` (or `Build/phpunit/UnitTests.xml`) -- unit + fuzz suites only, no DB, fast.
- `phpunit.functional.xml` (or `Build/phpunit/FunctionalTests.xml`) -- functional tests, requires MySQL/MariaDB.
Point Infection's `testFrameworkOptions` at the unit/fuzz config so the preflight is fast and DB-free:
```json5
"testFrameworkOptions": "-c Build/phpunit/UnitTests.xml"
```
Infection also auto-discovers a `phpunit.xml` (or `phpunit.xml.dist`) in `phpUnit.configDir` (defaults to project root) when `testFrameworkOptions` is not used -- it expects the **standard file names**, so do not rename to `phpunit-unit.xml` or similar without setting `phpUnit.configDir` explicitly:
```json5
"phpUnit": {
"configDir": "Build/phpunit"
}
```
Either pin the config explicitly (`testFrameworkOptions`) or keep the default name and use `phpUnit.configDir` -- never both implicit. This keeps `composer ci:test:php:unit` and `composer ci:test:mutation` running the same PHPUnit invocation, which is the point of the split.
## Configuration
Create `infection.json5` in project root.
> **TYPO3 convention:** When using `.Build/` for vendor dependencies, use the local schema path
> `".Build/vendor/infection/infection/resources/schema.json"` instead of the remote URL. This
> works offline and reflects the actual installed version.
```json5
{
"$schema": ".Build/vendor/infection/infection/resources/schema.json",
"source": {
"directories": [
"Classes"
],
"excludes": [
"Domain/Model" // Skip simple DTOs
]
},
"logs": {
"html": ".Build/logs/infection.html",
"text": ".Build/logs/infection.log",
"summary": ".Build/logs/infection-summary.log"
},
"mutators": {
"@default": true,
// Disable noisy mutators if needed
"TrueValue": false,
"FalseValue": false
},
"minMsi": 60, // Minimum Mutation Score Indicator
"minCoveredMsi": 80, // Minimum MSI for covered code only
"testFramework": "phpunit",
"testFrameworkOptions": "-c Build/phpunit/UnitTests.xml"
}
```
## Mutation Operators
Infection applies these types of mutations:
### Arithmetic Operators
```php
// Original
$result = $a + $b;
// Mutants
$result = $a - $b; // PlusToMinus
$result = $a * $b; // PlusToMultiplication
```
### Comparison Operators
```php
// Original
if ($value > 10) { ... }
// Mutants
if ($value >= 10) { ... } // GreaterThan to GreaterThanOrEqual
if ($value < 10) { ... } // GreaterThan to LessThan
if (true) { ... } // Always truthy
```
### Boolean Operators
```php
// Original
if ($a && $b) { ... }
// Mutants
if ($a || $b) { ... } // LogicalAnd to LogicalOr
if ($a) { ... } // Remove operand
```
### Return Values
```php
// Original
return $value;
// Mutants
return null; // Return null
return []; // Return empty array
return !$value; // Negate boolean
```
### Method Calls
```php
// Original
$this->save($entity);
// Mutant
// Line removed (method call deleted)
```
## Running Mutation Tests
### Via Composer Scripts
```json
{
"scripts": {
"ci:test:mutation": [
"@ci:test:php:unit",
".Build/bin/infection --threads=4"
],
"ci:test:mutation:quick": [
".Build/bin/infection --threads=4 --only-covered --min-msi=60"
]
}
}
```
### Via runTests.sh
```bash
# Add to Build/Scripts/runTests.sh
mutation)
# Run unit tests first to generate coverage
COMMAND=(.Build/bin/phpunit -c Build/phpunit/UnitTests.xml --coverage-xml=.Build/logs/coverage-xml --coverage-html=.Build/logs/coverage-html)
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name unit-${SUFFIX} ${IMAGE_PHP} "${COMMAND[@]}"
# Run mutation testing
COMMAND=(.Build/bin/infection --threads=4 --coverage=.Build/logs/coverage-xml)
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name mutation-${SUFFIX} ${IMAGE_PHP} "${COMMAND[@]}"
SUITE_EXIT_CODE=$?
;;
```
### Directly
```bash
# Full mutation test run
.Build/bin/infection --threads=4
# Quick run (only test covered code)
.Build/bin/infection --threads=4 --only-covered
# With existing coverage
.Build/bin/infection --threads=4 --coverage=.Build/logs/coverage-xml
# Filter to specific directory
.Build/bin/infection --threads=4 --filter=Classes/Service
```
## Interpreting Results
### Mutation Score Indicator (MSI)
```
Mutations: 150 total
Killed: 120 (80%) ← Tests caught the mutation
Escaped: 15 (10%) ← Tests MISSED the mutation (bad!)
Errors: 5 (3%) ← Mutation caused fatal error
Uncovered: 10 (7%) ← No tests for this code
MSI: 80% ← (Killed + Errors) / Total
Covered MSI: 86% ← MSI for covered code only
```
### Understanding Results
| Status | Meaning | Action |
|--------|---------|--------|
| **Killed** | Test failed when mutant introduced | Good - test is effective |
| **Escaped** | Test passed with mutant | **Bad - add/improve tests** |
| **Errors** | Mutant caused fatal error | Usually OK (type errors) |
| **Uncovered** | No test coverage | Add coverage first |
| **Timeout** | Test took too long with mutant | Usually OK |
| **Skipped** | Mutant not tested | Check config |
### Target Scores
| Level | MSI | Covered MSI | Use Case |
|-------|-----|-------------|----------|
| Basic | 50%+ | 60%+ | Initial implementation |
| Good | 70%+ | 80%+ | Production code |
| Excellent | 85%+ | 90%+ | Critical/security code |
## Improving Mutation Score
### 1. Fix Escaped Mutants
Review the HTML report to find escaped mutants:
```html
<!-- .Build/logs/infection.html -->
<!-- Shows: Original code, Mutated code, Test that should have caught it -->
```
### 2. Add Boundary Tests
```php
// If this escapes:
// if ($age >= 18) → if ($age > 18)
// Add boundary test:
public function testAgeExactly18IsAllowed(): void
{
self::assertTrue($this->validator->isAdult(18));
}
```
### 3. Add Negative Tests
```php
// If method removal escapes:
// $logger->error($message); // removed
// Add test verifying the call:
public function testErrorIsLogged(): void
{
$logger = $this->createMock(LoggerInterface::class);
$logger->expects(self::once())
->method('error')
->with('Expected message');
$service = new MyService($logger);
$service->doSomethingThatLogs();
}
```
### 4. Test Return Values
```php
// If return value mutation escapes:
// return $result; → return null;
// Verify return value explicitly:
public function testReturnsCalculatedValue(): void
{
$result = $calculator->compute(5, 3);
self::assertSame(8, $result); // Not just assertNotNull!
}
```
### 5. Kill Common TYPO3 Escaped Mutants
#### Concat mutations (string reordering/removal)
```php
// WEAK — Concat mutations escape because substring order doesn't matter
$message = $service->getErrorMessage();
self::assertStringContainsString('provider', $message);
self::assertStringContainsString('LLM module', $message);
// STRONG — kills Concat mutations by asserting the exact string
$message = $service->getErrorMessage();
self::assertSame(
'No LLM provider configured. Create a provider in Admin Tools > LLM > Providers.',
$message,
);
```
#### LogicalOr to LogicalAnd mutations
```php
// Given this code under test:
//
// public function getStatusMessage(\Throwable $e): string {
// $msg = $e->getMessage();
// if (str_contains($msg, '401') || str_contains($msg, 'Unauthorized')) {
// return 'The API key was rejected.';
// }
// return 'An unknown error occurred.';
// }
//
// Test each OR branch individually to kill the LogicalAnd mutation:
#[Test]
public function recognizes401Code(): void
{
// Only "401", no "Unauthorized" — kills LogicalAnd mutation
$result = $this->subject->getStatusMessage(
new \RuntimeException('HTTP 401 error'),
);
self::assertSame('The API key was rejected.', $result);
}
#[Test]
public function recognizesUnauthorized(): void
{
// Only "Unauthorized", no "401"
$result = $this->subject->getStatusMessage(
new \RuntimeException('Request Unauthorized'),
);
self::assertSame('The API key was rejected.', $result);
}
```
#### GreaterThan to GreaterThanOrEqual mutations
```php
// If code has: $check = $count > 0 ? createOkCheck() : createErrorCheck();
// You must assert BOTH branches to kill the mutation.
// 1. Test for count > 0 (e.g., count = 1)
$passingCheck = $this->service->runCheck(1);
self::assertSame(Severity::Ok, $passingCheck->severity);
self::assertNull($passingCheck->fixRoute);
// 2. Test for count = 0. This kills the `>` to `>=` mutation.
$failingCheck = $this->service->runCheck(0);
self::assertSame(Severity::Error, $failingCheck->severity);
self::assertSame('nrllm_providers', $failingCheck->fixRoute);
```
## CI Integration
### GitHub Actions
```yaml
# .github/workflows/tests.yml
mutation:
name: Mutation Testing
runs-on: ubuntu-latest
needs: [unit] # Run after unit tests pass
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
coverage: xdebug
- run: composer install
- name: Run unit tests with coverage
run: |
.Build/bin/phpunit -c Build/phpunit/UnitTests.xml \
--coverage-xml=.Build/logs/coverage-xml \
--log-junit=.Build/logs/phpunit.xml
- name: Run mutation testing
run: |
.Build/bin/infection \
--threads=4 \
--coverage=.Build/logs/coverage-xml \
--min-msi=60 \
--min-covered-msi=80 \
--skip-initial-tests
- name: Upload mutation report
if: always()
uses: actions/upload-artifact@v4
with:
name: mutation-report
path: .Build/logs/infection.html
```
### Quality Gate
```yaml
# Fail CI if mutation score drops
- name: Check mutation score
run: |
.Build/bin/infection \
--threads=4 \
--min-msi=60 \
--min-covered-msi=80 \
--only-covered
```
## Best Practices
1. **Run unit tests first** - Mutation testing needs passing tests
2. **Start with low thresholds** - Increase gradually (50% → 60% → 70%)
3. **Focus on covered code** - Use `--only-covered` for actionable results
4. **Prioritize escaped mutants** - These indicate weak tests
5. **Exclude trivial code** - Skip getters/setters/DTOs in config
6. **Run incrementally** - Use `--git-diff-filter=AM` for changed files only
7. **Document exclusions** - Explain why code is excluded from mutation testing
## Incremental Mutation Testing
For large codebases, run mutation testing only on changed files:
```bash
# Only test files changed in current branch
.Build/bin/infection \
--threads=4 \
--git-diff-filter=AM \
--git-diff-base=origin/main \
--only-covered
```
## Directory Structure
```
project/
├── infection.json5 # Infection configuration
├── .Build/
│ └── logs/
│ ├── infection.html # HTML report
│ ├── infection.log # Detailed log
│ └── infection-summary.log
└── Tests/
└── Unit/
└── Service/
└── MyServiceTest.php
```
## Resources
- [Infection PHP](https://infection.github.io/) - PHP mutation testing framework
- [Mutation Testing](https://en.wikipedia.org/wiki/Mutation_testing) - Concept overview
- [Pitest](https://pitest.org/) - Java mutation testing (for comparison)
- [Stryker](https://stryker-mutator.io/) - JavaScript/TypeScript mutation testing
references/performance-testing.md
# Performance Testing for TYPO3 Extensions
Performance tests validate efficiency claims and detect performance regressions.
## When to Use Performance Tests
- **Benchmark Claims**: Validate documented performance (e.g., "processes 1000 items in <100ms")
- **Regression Detection**: Catch performance degradation during development
- **Memory Leak Detection**: Ensure sustained operations don't leak memory
- **Optimization Validation**: Prove optimizations achieve expected improvements
## Directory Structure
```
Tests/
├── Performance/
│ ├── ServicePerformanceTest.php
│ └── ParserBenchmarkTest.php
Build/
└── phpunit/
└── PerformanceTests.xml
```
## PHPUnit Configuration
Create `Build/phpunit/PerformanceTests.xml`:
```xml
<?xml version="1.0"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
bootstrap="../../Tests/Unit/Bootstrap.php"
cacheDirectory=".phpunit.cache"
executionOrder="random"
requireCoverageMetadata="false"
beStrictAboutCoverageMetadata="false"
beStrictAboutOutputDuringTests="false"
failOnRisky="true"
failOnWarning="true"
colors="true"
>
<testsuites>
<testsuite name="Performance Tests">
<directory>../../Tests/Performance</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">../../Classes</directory>
</include>
</source>
</phpunit>
```
## Performance Test Pattern
```php
<?php
declare(strict_types=1);
namespace Vendor\Extension\Tests\Performance;
use PHPUnit\Framework\TestCase;
use Vendor\Extension\Service\MyService;
/**
* Performance benchmarks for MyService.
*
* Validates efficiency claims:
* - Processing 1000 items < 100ms
* - Memory usage remains constant (no leaks)
*/
final class MyServicePerformanceTest extends TestCase
{
private MyService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new MyService();
}
/**
* @test
* Benchmark: Processing 1000 items
* Target: < 100ms
*/
public function processingPerformance(): void
{
$items = $this->generateTestItems(1000);
$startTime = \microtime(true);
$startMemory = \memory_get_usage();
$results = [];
foreach ($items as $item) {
$results[] = $this->service->process($item);
}
$endTime = \microtime(true);
$endMemory = \memory_get_usage();
$duration = ($endTime - $startTime) * 1000; // ms
$memoryUsed = ($endMemory - $startMemory) / 1024; // KB
// Output benchmark results
echo "\n";
echo "Processing Performance:\n";
echo " Items: 1000\n";
echo " Duration: " . \number_format($duration, 2) . " ms\n";
echo " Memory Used: " . \number_format($memoryUsed, 2) . " KB\n";
echo " Per Item: " . \number_format($duration / 1000, 4) . " ms\n";
echo "\n";
// Assert performance targets
self::assertLessThan(100, $duration, 'Should complete in under 100ms');
self::assertCount(1000, $results, 'Should process all items');
}
/**
* @test
* Benchmark: Memory leak detection
* Target: < 500KB growth over 10 iterations
*/
public function memoryLeakDetection(): void
{
$iterations = 10;
$itemsPerIteration = 100;
$memorySnapshots = [];
for ($i = 0; $i < $iterations; $i++) {
$items = $this->generateTestItems($itemsPerIteration);
foreach ($items as $item) {
$this->service->process($item);
}
$memorySnapshots[] = \memory_get_usage();
\gc_collect_cycles();
}
$initialMemory = $memorySnapshots[0];
$finalMemory = \end($memorySnapshots);
$memoryGrowth = ($finalMemory - $initialMemory) / 1024;
echo "\n";
echo "Memory Leak Detection:\n";
echo " Iterations: {$iterations}\n";
echo " Items/Iter: {$itemsPerIteration}\n";
echo " Initial Memory: " . \number_format($initialMemory / 1024, 2) . " KB\n";
echo " Final Memory: " . \number_format($finalMemory / 1024, 2) . " KB\n";
echo " Memory Growth: " . \number_format($memoryGrowth, 2) . " KB\n";
echo "\n";
self::assertLessThan(500, $memoryGrowth, 'Memory growth should be < 500KB');
}
/**
* Generate test items for benchmarks.
*
* @return array<int, mixed>
*/
private function generateTestItems(int $count): array
{
$items = [];
for ($i = 0; $i < $count; $i++) {
$items[] = [
'id' => $i,
'data' => \str_repeat('x', 100),
];
}
return $items;
}
}
```
## Benchmark Patterns
### Timing Measurements
```php
// High-precision timing
$startTime = \microtime(true);
// ... operation ...
$duration = (\microtime(true) - $startTime) * 1000; // milliseconds
// Assert timing
self::assertLessThan(50, $duration, 'Operation should complete in < 50ms');
```
### Memory Measurements
```php
// Memory before/after
$startMemory = \memory_get_usage();
// ... operation ...
$memoryUsed = (\memory_get_usage() - $startMemory) / 1024; // KB
// Peak memory
$peakMemory = \memory_get_peak_usage() / 1024 / 1024; // MB
```
### Throughput Measurements
```php
$operations = 1000;
$startTime = \microtime(true);
for ($i = 0; $i < $operations; $i++) {
$service->doSomething();
}
$duration = \microtime(true) - $startTime;
$throughput = $operations / $duration; // operations/second
echo "Throughput: " . \number_format($throughput, 0) . " ops/sec\n";
```
## Running Performance Tests
### Via runTests.sh
Add a performance suite to `runTests.sh`:
```bash
'performance')
COMMAND="php ${PHP_OPCACHE_OPTS} .Build/bin/phpunit -c Build/phpunit/PerformanceTests.xml"
${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name ${CONTAINER_NAME} ${IMAGE_PHP} ${COMMAND}
SUITE_EXIT_CODE=$?
;;
```
### Via Makefile
```makefile
performance:
$(RUNTESTS) -s performance
```
### Direct Execution
```bash
.Build/bin/phpunit -c Build/phpunit/PerformanceTests.xml --testdox
```
## CI Integration
Performance tests are typically **not run in CI** due to variable execution times on shared runners. Instead:
1. **Run locally** before merging performance-critical changes
2. **Document baseline** in test output
3. **Set generous thresholds** (2-3x expected) if CI is required
```yaml
# Optional CI performance check (with loose thresholds)
- name: Performance Tests (Optional)
run: Build/Scripts/runTests.sh -s performance
continue-on-error: true
```
## Real-World Examples
### Parser Benchmark (streaming versus DOM, large XLIFF files)
```php
/**
* @test
* Compare streaming vs DOM parsing for large files
*/
public function streamingVsDomComparison(): void
{
$largeFile = $this->createLargeXliffFile(10000); // 10k units
// DOM parser
$domStart = \microtime(true);
$domParser->parse($largeFile);
$domTime = \microtime(true) - $domStart;
$domMemory = \memory_get_peak_usage();
// Reset
\gc_collect_cycles();
// Streaming parser
$streamStart = \microtime(true);
$streamParser->parse($largeFile);
$streamTime = \microtime(true) - $streamStart;
$streamMemory = \memory_get_peak_usage();
echo "Comparison (10k units):\n";
echo " DOM: " . \number_format($domTime * 1000, 2) . " ms, "
. \number_format($domMemory / 1024 / 1024, 2) . " MB\n";
echo " Streaming: " . \number_format($streamTime * 1000, 2) . " ms, "
. \number_format($streamMemory / 1024 / 1024, 2) . " MB\n";
self::assertLessThan($domTime, $streamTime, 'Streaming should be faster');
self::assertLessThan($domMemory, $streamMemory, 'Streaming should use less memory');
}
```
### Cache Efficiency Test (harmonised cache keys)
```php
/**
* @test
* Validate: Harmonization reduces cache operations by 60-80%
*/
public function cacheChurnReductionMeasurement(): void
{
$contentElements = $this->generateRandomContent(100);
// Count unique timestamps BEFORE
$timestampsBefore = \count(\array_unique(
\array_map(fn($c) => $c->getStarttime(), $contentElements)
));
// Harmonize all content
$harmonized = [];
foreach ($contentElements as $content) {
$result = $this->harmonizer->harmonize($content);
$harmonized[] = $result['starttime'];
}
$timestampsAfter = \count(\array_unique($harmonized));
$reduction = (($timestampsBefore - $timestampsAfter) / $timestampsBefore) * 100;
echo "Cache Churn Reduction: " . \number_format($reduction, 1) . "%\n";
self::assertGreaterThanOrEqual(60, $reduction, 'Should reduce by at least 60%');
}
```
## Best Practices
1. **Document targets**: State expected performance in test docblocks
2. **Output results**: Echo benchmark data for visibility
3. **Use assertions**: Don't just measure - assert expected bounds
4. **Isolate tests**: Run GC between measurements
5. **Warm up**: Consider JIT/opcache warm-up for accurate results
6. **Multiple runs**: Average over multiple iterations for stability
7. **Generous thresholds**: Allow 2-3x headroom for CI variability
## Resources
- [PHPBench](https://phpbench.readthedocs.io/) - Dedicated PHP benchmarking
- [Blackfire](https://blackfire.io/) - PHP profiling (advanced)
- [XHProf](https://github.com/longxinH/xhprof) - Hierarchical profiler
references/quality-tools.md
# 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
- **jscpd**: Copy/paste (duplicate code) detection
## Centralized CI Tooling: netresearch/typo3-ci-workflows
Netresearch TYPO3 extensions use a centralized dev-dependency package that provides all quality tools, shared configurations, and CI infrastructure.
### What typo3-ci-workflows provides
**Dev-dependencies (transitively installed):**
- `phpstan/phpstan` + `phpstan-strict-rules` + `phpstan-deprecation-rules` + `phpstan-phpunit`
- `saschaegerer/phpstan-typo3` (TYPO3-aware PHPStan rules)
- `phpat/phpat` (architecture testing)
- `ergebnis/phpstan-rules` (additional strict rules)
- `friendsofphp/php-cs-fixer` (code style)
- `rector/rector` + `ssch/typo3-rector` (automated refactoring)
- `captainhook/captainhook` (git hooks)
- `phpunit/phpunit` + `typo3/testing-framework`
- `infection/infection` (mutation testing)
- `giorgiosironi/eris` (property-based/fuzz testing)
**Shared configurations:**
- `config/phpstan/phpstan.neon` — shared parameters (level 10, excludePaths, bootstrapFiles, common ignoreErrors)
- `config/phpstan/includes-no-extension-installer.neon` — explicit PHPStan plugin includes for `--no-plugins` environments (captainhook + git worktree)
- `.php-cs-fixer.php` — shared code style rules
- `captainhook.json` — pre-commit hook configuration
**Template scripts:**
- `assets/Build/Scripts/runTests.sh.dist` — generic test runner (unit, functional, fuzz, mutation, phpstan, cgl, rector)
### Installation (recommended)
```bash
composer require --dev netresearch/typo3-ci-workflows
```
This single package replaces individual `composer require --dev` for all quality tools.
### PHPStan Configuration with typo3-ci-workflows
Create `Build/phpstan.neon`:
```neon
includes:
- %currentWorkingDirectory%/.Build/vendor/netresearch/typo3-ci-workflows/config/phpstan/phpstan.neon
- phpstan-baseline.neon
parameters:
paths:
- ../Classes
- ../Tests/Architecture
tmpDir: ../.Build/var/phpstan
ignoreErrors:
# Extension-specific ignores only — shared ignores are in the included config
-
message: '#no value type specified in iterable type array#'
path: ../Classes/SomeFile.php
services:
-
class: Vendor\Extension\Tests\Architecture\ArchitectureTest
tags:
- phpat.test
```
**Important notes:**
- The shared `phpstan.neon` sets `level: 10`, `reportUnmatchedIgnoredErrors: true`, common excludePaths, and bootstrapFiles
- Only add extension-specific ignoreErrors in your local config; shared ignores (ergebnis, test infrastructure, upgrade wizards) are handled centrally
- Always regenerate baseline after changing config: `.Build/bin/phpstan analyse -c Build/phpstan.neon --generate-baseline Build/phpstan-baseline.neon`
### captainhook + git worktree + explicit PHPStan includes
When using git worktrees with bare repositories, `composer install --no-plugins` is needed for the captainhook workaround. This means `phpstan/extension-installer` cannot auto-register plugins. Use the explicit includes file instead:
```neon
includes:
- %currentWorkingDirectory%/.Build/vendor/netresearch/typo3-ci-workflows/config/phpstan/includes-no-extension-installer.neon
- phpstan-baseline.neon
```
This file explicitly lists all PHPStan plugin neon files that `extension-installer` would auto-load.
**Do NOT mix both approaches** — if `extension-installer` is active AND you include `includes-no-extension-installer.neon`, PHPStan will error about duplicate includes.
#### Symptom when the includes are missing (`InvocationStubber::with()`)
If PHPStan runs in such a worktree **without** the explicit-includes config — e.g. after a `composer install --no-scripts` fallback, or in an extension that ships no `phpstan.no-plugins.neon` to point at — `phpstan-phpunit` is not active and every ordinary mock chain trips a false error:
```text
Call to an undefined method PHPUnit\Framework\MockObject\InvocationStubber::with().
Cannot call method willReturn() on mixed.
```
on lines like `$this->createMock(X::class)->method('m')->with(...)->willReturn(...)`. These are **not real** and are **not caused by your change** — the tell is that only a handful of files show them while hundreds of other mock-using tests pass.
Prefer the `--no-plugins` + explicit-includes approach above. When the repo has no no-plugins config to point PHPStan at, **verify with a controlled stash**:
```bash
git stash push -u # revert your change -> clean tree
# run PHPStan -> note the identical baseline error set (same untouched test files)
git stash pop
# re-run PHPStan -> confirm your change keeps the count unchanged (adds zero)
```
CI installs in a real (non-worktree) checkout where the hooks install cleanly and `extension-installer` registers the plugins, so these errors never appear there — **CI is authoritative** for the full-tree PHPStan result.
### If NOT using typo3-ci-workflows
For extensions that cannot use the centralized package, install tools individually:
```bash
composer require --dev \
phpstan/phpstan \
phpstan/phpstan-strict-rules \
phpstan/phpstan-deprecation-rules \
phpstan/phpstan-phpunit \
saschaegerer/phpstan-typo3 \
ergebnis/phpstan-rules \
friendsofphp/php-cs-fixer \
rector/rector \
ssch/typo3-rector
```
## PHPStan
### Configuration (standalone, without typo3-ci-workflows)
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 phpstan
# Directly
.Build/bin/phpstan analyse -c Build/phpstan.neon
# With baseline (ignore existing errors)
.Build/bin/phpstan analyse -c Build/phpstan.neon --generate-baseline Build/phpstan-baseline.neon
# Clear cache
rm -rf .Build/var/phpstan
```
### 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**:
- **New projects**: Start with level 5, aim for level 10 (max)
- **Existing extensions**: Level 8 is practical - levels 9/10 require extensive type annotations for `$GLOBALS`, TCA, and dynamic TYPO3 patterns
**Why Level 8 for existing extensions?**
- Strict boolean conditions and nullability checks
- Avoids excessive ignoreErrors for TYPO3's inherently untyped patterns
- Good balance between strictness and maintainability
**Why Level 10 for new projects?**
- 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 ignoreErrors (Level 8)
For existing TYPO3 extensions, these ignoreErrors handle common TYPO3 patterns:
```neon
parameters:
level: 8
ignoreErrors:
# TYPO3 TCA/GLOBALS access patterns - inherently untyped
- '#Cannot access offset .* on mixed#'
- '#Parameter .* of function array_key_exists expects array, mixed given#'
- '#Parameter .* of function array_merge expects array, mixed given#'
- '#Parameter .* of function in_array expects array, mixed given#'
- '#Argument of an invalid type mixed supplied for foreach#'
- '#Cannot cast mixed to int#'
- '#Cannot cast mixed to string#'
- '#Possibly invalid array key type#'
# Legacy code array type specifications
- '#no value type specified in iterable type array#'
- '#return type has no value type specified in iterable type#'
- '#type has no value type specified in iterable type#'
# TYPO3 v12/v13 API changes - during migration
- '#deprecated class TYPO3\\CMS\\Frontend\\Controller\\TypoScriptFrontendController#'
- '#Call to an undefined method TYPO3\\CMS\\Core\\Database\\Query\\QueryBuilder::execute#'
# Doctrine DBAL 4.x type parameter changes (int -> ParameterType enum)
- '~Parameter \\#2 \\$type of method .* expects .*, int given~'
# PHPStan strict rules violations in legacy code
- '#Construct empty\\(\\) is not allowed#'
- '#Strict comparison using .* will always evaluate to#'
```
### CI Workflow Paths with Build/ Configuration
When configs are in Build/, update CI workflows:
```yaml
# .github/workflows/ci.yml
- name: Run PHPStan
run: vendor/bin/phpstan analyse -c Build/phpstan.neon --no-progress
- name: Run PHP-CS-Fixer
run: vendor/bin/php-cs-fixer fix --config=Build/php-cs-fixer.php --dry-run --diff
- name: Run PHPCS
run: vendor/bin/phpcs --standard=Build/phpcs.xml
```
**Path resolution note**: PHPStan's `paths:` and `includes:` are resolved relative to the config file location. When config is in Build/:
```neon
# Build/phpstan.neon
includes:
- ../vendor/phpstan/phpstan-strict-rules/rules.neon # <- Note ../
parameters:
paths:
- ../Classes/ # <- Note ../
- ../Tests/
excludePaths:
- ../vendor/*
- ../.Build/*
```
### PHPStan in Tests - Common Patterns
When writing tests that validate runtime behavior guaranteed by PHPDoc types, PHPStan Level 9+ may report "alreadyNarrowedType" errors. These tests are still valuable as they verify implementation matches type declarations.
**Common Test-Specific Ignore Identifiers:**
| Identifier | When to Use |
|------------|-------------|
| `staticMethod.alreadyNarrowedType` | `assertTrue()`, `assertFalse()`, `assertIsArray()` when PHPStan knows the result |
| `function.alreadyNarrowedType` | `is_subclass_of()`, `is_array()`, `is_string()` when type is known from PHPDoc |
**Example - Testing Contract Guarantees:**
```php
#[Test]
public function allDiscoveredClassesExtendBaseClass(): void
{
$registry = new MatcherRegistry();
$result = $registry->getMatcherClasses(); // Returns array<class-string<AbstractCoreMatcher>>
foreach ($result as $matcherClass) {
// PHPStan knows this is always true from PHPDoc, but test validates runtime behavior
// @phpstan-ignore staticMethod.alreadyNarrowedType
self::assertTrue(
is_subclass_of($matcherClass, AbstractCoreMatcher::class), // @phpstan-ignore function.alreadyNarrowedType
sprintf('%s should extend AbstractCoreMatcher', $matcherClass)
);
}
}
#[Test]
public function allConfigurationsAreArrays(): void
{
$registry = new MatcherRegistry();
$configurations = $registry->getMatcherConfigurations(); // Returns array<class-string, array<string, mixed>>
foreach ($configurations as $matcherClass => $configuration) {
// @phpstan-ignore staticMethod.alreadyNarrowedType
self::assertIsArray(
$configuration,
sprintf('Configuration for %s should be an array', $matcherClass)
);
}
}
```
**When to Use These Ignores:**
- Tests validating that implementation matches PHPDoc contracts
- Tests checking class hierarchies or type relationships
- Tests ensuring configuration structures are correct
- Not in production code (fix the types instead)
- Not in tests where the assertion actually could fail
**Placement Rules:**
- **Next-line comment** (`// @phpstan-ignore ...`): Applies to the **next** line
- **Inline comment**: Applies to the **same** line where it appears
- Multiple identifiers: Separate with comma (`// @phpstan-ignore id1, id2`)
### 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 (if not using typo3-ci-workflows)
```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)
Build/Scripts/runTests.sh rector
# Apply changes
Build/Scripts/runTests.sh rector:fix
# Directly
.Build/bin/rector process --dry-run
.Build/bin/rector process
```
### 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 (if not using typo3-ci-workflows)
```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)
Build/Scripts/runTests.sh cgl
# Fix files
Build/Scripts/runTests.sh cgl:fix
# Directly
.Build/bin/php-cs-fixer fix --config=Build/.php-cs-fixer.php --dry-run --diff
.Build/bin/php-cs-fixer fix --config=Build/.php-cs-fixer.php
```
### Common Rules
```php
// array_syntax: short
$array = [1, 2, 3]; // correct
$array = array(1, 2, 3); // incorrect
// concat_space: one
$message = 'Hello ' . $name; // correct
$message = 'Hello '.$name; // incorrect
// 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 (if not using typo3-ci-workflows)
```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 lint
# Specific directory
vendor/bin/phplint Classes/
```
## jscpd (Copy/Paste Duplicate Detection)
### Installation
```bash
npm install --save-dev jscpd
```
### Configuration — jscpd 5.x schema
Create `Build/.jscpd.json`:
```json
{
"path": ["../Classes/"],
"format": ["php"],
"mode": "weak",
"threshold": 0,
"reporters": ["console-full"],
"minTokens": 100,
"minLines": 5,
"ignore": [],
"exitCode": 1
}
```
**jscpd 5.x breaking schema changes** (config written for older jscpd versions fails with confusing errors):
- The language field is `format`, not `languages`. A leftover `languages` key is rejected with `config file Build/.jscpd.json: unknown field 'languages'`.
- `exitCode` must be an **integer** (`1`), not a boolean. `"exitCode": true` fails with `invalid type: boolean 'true', expected i32`.
- The default `mode` counts comments as tokens, which inflates the duplicate-token count and can trip the threshold on files that only share license headers or PHPDoc blocks, not logic. Set `"mode": "weak"` to ignore whitespace/formatting-only differences and avoid these false positives — confirmed to eliminate spurious "found too many duplicates" failures caused purely by shared comment blocks.
### Running jscpd
```bash
# Via npx
npx jscpd --config Build/.jscpd.json
# Composer script integration
"ci:test:php:cpd": "npx jscpd --config Build/.jscpd.json"
```
A `threshold: 0` with a non-zero `exitCode` makes jscpd fail the build on **any** detected duplicate above `minTokens`/`minLines` — tune `threshold` upward (percentage) instead if some duplication is accepted.
## Composer Script Integration
With typo3-ci-workflows, use `Build/Scripts/runTests.sh` as the entry point:
```json
{
"scripts": {
"ci:cgl": "Build/Scripts/runTests.sh cgl:fix",
"ci:test:php:cgl": "Build/Scripts/runTests.sh cgl",
"ci:test:php:phpstan": "Build/Scripts/runTests.sh phpstan",
"ci:test:php:unit": "Build/Scripts/runTests.sh unit",
"ci:test:php:functional": "Build/Scripts/runTests.sh functional",
"ci:test:php:fuzz": "Build/Scripts/runTests.sh fuzz",
"ci:mutation": "Build/Scripts/runTests.sh mutation",
"ci:test:php:all": [
"@ci:test:php:unit",
"@ci:test:php:functional"
]
}
}
```
> **Security Note**: `composer audit` checks for known security vulnerabilities in dependencies. Run this regularly and especially before releases.
## Pre-commit Hook
With typo3-ci-workflows, captainhook handles pre-commit hooks automatically. Configure in `Build/captainhook.json`.
For manual setup, 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
For Netresearch extensions using typo3-ci-workflows, CI is provided by reusable workflows. See the [typo3-ci-workflows repository](https://github.com/netresearch/typo3-ci-workflows) for workflow configuration.
For standalone CI:
```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. **Use typo3-ci-workflows**: Centralized tooling ensures consistency across extensions
2. **PHPStan Level 10**: Aim for `level: max` in modern TYPO3 13+ projects
3. **Baseline for Legacy**: Use baselines to track existing issues during migration
4. **Security Audits**: Run `composer audit` regularly and in CI
5. **Auto-fix in CI**: Run fixes automatically, fail on violations
6. **Consistent Rules**: Share config via typo3-ci-workflows
7. **Pre-commit Checks**: Use captainhook for lint, PHPStan, CGL, security
8. **Latest PHP**: Run quality tools with latest PHP version (8.4+)
9. **Regular Updates**: Keep tools and rules updated
## Mutation Testing with Infection PHP
Mutation testing verifies that your tests actually catch bugs, not just execute code paths. Infection PHP introduces small changes (mutants) to source code and checks whether tests fail.
### Configuration (infection.json5)
Create `infection.json5` in the project root:
```json5
{
"$schema": "https://raw.githubusercontent.com/infection/infection/master/resources/schema.json",
"source": {
"directories": [
"Classes"
]
},
"phpUnit": {
"configDir": "Build/phpunit",
"customPath": ".Build/bin/phpunit"
},
"logs": {
"text": ".Build/var/infection/infection.log",
"html": ".Build/var/infection/infection.html",
"summary": ".Build/var/infection/summary.log"
},
"tmpDir": ".Build/var/infection",
"mutators": {
"@default": true
},
"minMsi": 30,
"minCoveredMsi": 60
}
```
**Key configuration details:**
- **`source.directories`**: Point at `Classes` (your production code). Never include `Tests/`.
- **`phpUnit.configDir`**: Directory containing `UnitTests.xml` (Infection auto-detects PHPUnit config files there).
- **`phpUnit.customPath`**: Path to the PHPUnit binary. When using `typo3-ci-workflows`, the binary is at `.Build/bin/phpunit` (not `vendor/bin/phpunit`).
- **`minMsi` / `minCoveredMsi`**: Mutation Score Indicator thresholds. Start conservatively (30% MSI, 60% covered MSI) and increase as test coverage improves. Aiming for 80%+ covered MSI is a good long-term target.
### Realistic MSI Thresholds
| Stage | minMsi | minCoveredMsi | Notes |
|-------|--------|---------------|-------|
| Initial setup | 30 | 60 | Baseline for new extensions |
| Growing coverage | 50 | 70 | After addressing low-hanging fruit |
| Mature test suite | 70 | 80 | Well-tested extension |
### Composer Script Integration
```json
{
"scripts": {
"ci:test:php:mutation": [
"infection --configuration=infection.json5 --threads=4"
]
}
}
```
### Installation
When using `netresearch/typo3-ci-workflows`, `infection/infection` is provided transitively -- no separate `composer require` is needed. For standalone setups:
```bash
composer require --dev infection/infection
```
### Running Mutation Tests
```bash
# Via Composer script
composer ci:test:php:mutation
# Directly with thread control
infection --configuration=infection.json5 --threads=4
# Only mutate specific directories
infection --configuration=infection.json5 --filter=Classes/Domain
# Show escaped mutants (mutants that were NOT caught by tests)
infection --configuration=infection.json5 --show-mutations
```
## 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/)
- [netresearch/typo3-ci-workflows](https://github.com/netresearch/typo3-ci-workflows)
- [Infection PHP Documentation](https://infection.github.io/guide/)
## Fresh-worktree PHPStan without plugins: spurious InvocationStubber errors
When the no-plugins route runs without the repo's explicit-includes config (fallback: `runTests.sh -s composer install --no-scripts` — note: no `--` separator, or composer treats the flag as a package name; bins land in `.Build/bin/`), phpstan-phpunit mis-resolves and reports spurious `Call to an undefined method …InvocationStubber::with()` / `Cannot call method willReturn() on mixed` on ordinary mock chains. These are NOT real and NOT caused by your change. Verify authoritatively with a controlled stash: `git stash push -u` → phpstan on the clean tree → note the identical baseline set → `git stash pop` → confirm your change adds zero. CI with a proper install stays clean.
references/release-workflow-validation.md
# Release Workflow Validation Before Tagging
Reusable workflows referenced inside `.github/workflows/release.yml` are resolved
at the pinned SHA at the moment the tag is pushed. If that SHA no longer contains
the referenced workflow file (renamed, deleted, or moved upstream), the release
run fails immediately with a "workflow not found" error.
By the time the CI reports the failure, the tag may already have been consumed by
a `gh release create` step that ran earlier in the same job — making the tag
permanently burned on GitHub.
## Validate Before Tagging
Run this helper script locally before `git tag`:
```bash
#!/usr/bin/env bash
# scripts/validate-release-workflow-refs.sh
# Checks every uses: <owner>/<repo>/.github/workflows/*.yml@<sha> reference
# in .github/workflows/release.yml to ensure the file exists at that SHA.
set -euo pipefail
RELEASE_YF=".github/workflows/release.yml"
echo "Validating reusable workflow references in $RELEASE_YF ..."
FAILED=0
while IFS= read -r ref; do
# ref format: owner/repo/.github/workflows/file.yml@sha
owner_repo="${ref%%/.github/*}"
rest="${ref#*/}"
rest="${rest#*/}"
sha="${ref##*@}"
workflow_path="${rest%@*}"
url="https://raw.githubusercontent.com/${owner_repo}/${sha}/${workflow_path}"
http_code=$(curl -s -o /dev/null -w "%{http_code}" "$url")
if [ "$http_code" = "200" ]; then
echo " OK $ref"
else
echo " FAIL (HTTP $http_code): $ref"
echo " URL checked: $url"
FAILED=1
fi
done < <(grep -oP 'uses:\s*\K[^\s#]+' "$RELEASE_YF" | grep '\.github/workflows/')
if [ "$FAILED" -eq 1 ]; then
echo ""
echo "ERROR: One or more reusable workflow references are broken."
echo "Update the SHA references in $RELEASE_YF before tagging."
exit 1
fi
echo "All reusable workflow references are valid."
```
Run it:
```bash
bash scripts/validate-release-workflow-refs.sh
```
If the script exits 0, proceed with tagging.
## Common Causes of Broken References
- **Upstream consolidation**: `tests.yml` renamed to `ci.yml` in the referenced repo
- **SHA rotation**: The repo pinned a commit that was later force-pushed (rare but possible on non-protected branches)
- **Repo rename or transfer**: The `owner/repo` portion of the `uses:` reference changed
- **Workflow file deleted**: The upstream project removed a workflow as part of restructuring
## Tagging and the "Burned Tag" Problem
**Safe to re-tag**: If the release workflow failed before `gh release create` ran (e.g., the `uses:` reference check is the very first step), the tag exists in git but no GitHub Release was published. You can safely delete and recreate the tag:
```bash
git tag -d v1.2.3
git push origin :refs/tags/v1.2.3
# fix the issue
git tag -s v1.2.3 -m "v1.2.3"
git push origin v1.2.3
```
**Burned tag**: If `gh release create` ran and created a GitHub Release (even a draft), the tag is permanently locked. GitHub will refuse to create a new release on the same tag name, even after deleting the release and the tag. You must use a new patch version (e.g., `v1.2.4`).
## Making the Release Workflow Self-Validating
Add the validation as the first step in the release job:
```yaml
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<sha>
- name: Validate reusable workflow references
run: bash scripts/validate-release-workflow-refs.sh
# ... rest of release steps
```
This prevents any release from proceeding if upstream workflow files have shifted,
and it fails fast — before any release assets, tags, or GitHub Releases are created.
references/sonarcloud.md
# SonarCloud for TYPO3 Extensions
> Continuous code quality and security analysis for PHP/TYPO3 projects
## Overview
SonarCloud provides automated code analysis for TYPO3 extensions:
- **200+ PHP rules** for bugs, vulnerabilities, and code smells
- **Coverage tracking** with visual reports
- **PR decoration** for immediate feedback
- **Quality gates** to enforce standards
- **Free for open-source** projects
## Two Analysis Modes — Pick the Right Config File
SonarCloud runs in one of **two mutually exclusive modes**, and they read **different
config files**. Editing the wrong one is a silent no-op:
| Mode | How it runs | Config file |
|------|-------------|-------------|
| **CI-based analysis** | You run the scanner (the GitHub Action below) | `sonar-project.properties` |
| **Automatic Analysis** | SonarCloud analyses the repo itself on push/PR — no Action, no scanner | **`.sonarcloud.properties`** |
Automatic Analysis is the **zero-config default** when you import a repo and never add
a scanner step. If your project uses it (no `SonarSource/*` action in the workflows),
then **`sonar-project.properties` is ignored** — all the CI-based config in this
document does nothing. Use the section below instead.
## Automatic Analysis (`.sonarcloud.properties`)
Put a `.sonarcloud.properties` at the **repo root**. It honours `sonar.exclusions`,
`sonar.cpd.exclusions`, and friends:
```properties
# .sonarcloud.properties (Automatic Analysis ONLY — NOT sonar-project.properties)
# Exclude vendored/minified assets and Fluid templates from analysis entirely.
# Fluid partials are HTML *fragments*, not documents — SonarCloud's Web/HTML rules
# raise false positives (missing DOCTYPE / <html lang> / <title>) that can drag the
# Reliability rating to C on otherwise-clean code.
sonar.exclusions=Resources/Public/JavaScript/Vendor/**,Resources/Private/Templates/**,Resources/Private/Partials/**,Resources/Private/Layouts/**
# Copy-paste detection: exclude things that are repetitive by nature.
# Fix duplication in *real source* by refactoring (DRY) — do NOT blanket-exclude it.
sonar.cpd.exclusions=Resources/Public/JavaScript/Vendor/**,Resources/Private/Templates/**,Resources/Private/Language/**,Tests/**
```
### TYPO3 gotchas
- **Duplication on new code → DRY, not exclusion.** A 5%+ duplication finding from
near-identical controller loops or repeated query blocks is fixed by extracting a
shared helper, not by adding the source path to `cpd.exclusions`. Reserve exclusions
for generated/vendored code, XLIFF, and tests.
- **Fluid templates** belong in `sonar.exclusions` (see above) — the Web/HTML ruleset
does not understand partials.
- **XLIFF** (`Resources/Private/Language/**`) and **`Tests/**`** belong in
`cpd.exclusions` — both are legitimately repetitive.
### Verify from the CLI (public projects need no auth)
For a **public** project, SonarCloud's web API is open — check the gate and issues for
a PR without a token (useful for confirming a fix before relying on the PR decoration):
```bash
# Unresolved issues on a PR (severity + rule + file)
curl -s "https://sonarcloud.io/api/issues/search?componentKeys=ORG_PROJECT&pullRequest=PR&resolved=false&ps=50"
# Quality Gate status for a PR
curl -s "https://sonarcloud.io/api/qualitygates/project_status?projectKey=ORG_PROJECT&pullRequest=PR"
```
### Annotations are not the gate
Automatic Analysis surfaces **every** issue — including CRITICAL-severity code smells
(e.g. `php:S1192` duplicated literals, `php:S3011` `setAccessible()` in tests) — as a
GitHub Checks **annotation**, *regardless* of whether the Quality Gate passes. A
`failure`-level annotation is **not** the same as a failed gate. The **Quality Gate**
(its configured new-code conditions) is the merge bar; non-gate code-smell annotations
do not block a merge. Don't treat a passing-gate-with-annotations PR as "broken".
## Don't SAST-edit template-synced files (exclude them instead)
`Build/Scripts/runTests.sh` — and the other files an extension syncs from this
`typo3-testing` skill's `assets/` (the test-runner template, `Build/playwright/**`,
PHPUnit/PHPStan/rector configs) — are **upstream-owned template files**. A downstream
extension should **never** apply cosmetic SonarCloud/static-analysis fixes to them in
place (e.g. `php:S100`/shell-naming renames, `S7684`, variable-style tweaks). Such edits
diverge the local copy from this template and cause drift and merge conflicts on the next
template sync — for a finding that was never the downstream repo's to fix.
Instead, **exclude template-synced paths from analysis** so the findings never reach the
PR in the first place:
```properties
# Automatic Analysis → .sonarcloud.properties
# CI-based analysis → sonar-project.properties
sonar.exclusions=**/Build/Scripts/runTests.sh,**/Build/playwright/**
# Or scope-suppress specific rules on the synced file rather than rewriting it:
sonar.issue.ignore.multicriteria=e1
sonar.issue.ignore.multicriteria.e1.ruleKey=php:S100
sonar.issue.ignore.multicriteria.e1.resourceKey=**/Build/Scripts/runTests.sh
```
Fix the finding **upstream** in this skill's `assets/` template if it is genuinely worth
fixing, then re-sync — that keeps every downstream copy identical. (Real instance: a
2026-06-27 SonarCloud sweep renamed shell functions in a downstream `runTests.sh`,
diverging it from this template.)
## Quick Start
### 1. Sign Up
1. Go to [sonarcloud.io](https://sonarcloud.io)
2. Sign in with GitHub
3. Create/join organization
4. Import your TYPO3 extension repository
### 2. Create Configuration
Add `sonar-project.properties` to your extension root:
```properties
sonar.projectKey=your-org_your-extension
sonar.organization=your-org
# TYPO3 Extension Structure
sonar.sources=Classes
sonar.tests=Tests
sonar.exclusions=**/vendor/**,.Build/**,var/**
# PHP Settings
sonar.php.version=8.2
sonar.php.coverage.reportPaths=var/log/coverage.xml
sonar.php.phpstan.reportPaths=var/log/phpstan.json
# Quality Gate
sonar.qualitygate.wait=true
```
### 3. Add GitHub Action
Create `.github/workflows/sonarcloud.yml`:
```yaml
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
jobs:
sonarcloud:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
coverage: xdebug
extensions: intl, pdo_sqlite
- name: Install dependencies
run: |
composer require --dev typo3/testing-framework
composer install --no-progress
- name: Run tests with coverage
run: |
vendor/bin/phpunit \
-c Build/phpunit/UnitTests.xml \
--coverage-clover var/log/coverage.xml
- name: Export PHPStan results
run: |
vendor/bin/phpstan analyze \
--configuration Build/phpstan.neon \
--error-format=json \
--no-progress \
> var/log/phpstan.json || true
- name: SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
```
### 4. Add Secret
1. Go to repository Settings → Secrets → Actions
2. Add `SONAR_TOKEN` from SonarCloud (Account → Security)
## TYPO3-Specific Configuration
### Full Configuration Example
```properties
sonar.projectKey=netresearch_my-typo3-extension
sonar.organization=netresearch
sonar.projectName=My TYPO3 Extension
sonar.projectVersion=1.0.0
# Source directories (TYPO3 extension structure)
sonar.sources=Classes,Configuration,Resources
sonar.tests=Tests
# Exclusions
sonar.exclusions=\
**/vendor/**,\
.Build/**,\
var/**,\
Resources/Public/JavaScript/Libs/**,\
**/*.min.js,\
**/*.min.css
# Test exclusions (don't analyze test code for coverage)
sonar.test.exclusions=Tests/**
# Coverage exclusions (files not to measure coverage for)
sonar.coverage.exclusions=\
Configuration/**,\
Resources/**,\
ext_emconf.php,\
ext_localconf.php,\
ext_tables.php
# PHP configuration
sonar.php.version=8.2
sonar.php.coverage.reportPaths=var/log/coverage.xml
sonar.php.phpstan.reportPaths=var/log/phpstan.json
sonar.php.tests.reportPath=var/log/junit.xml
# Encoding
sonar.sourceEncoding=UTF-8
# Quality gate
sonar.qualitygate.wait=true
```
### Integration with runTests.sh
If using TYPO3's standard test runner:
```yaml
- name: Run tests with coverage
run: |
Build/Scripts/runTests.sh -s unit -x
mv .Build/var/log/phpunit/coverage.xml var/log/coverage.xml
```
### Multi-TYPO3-Version Testing
```yaml
strategy:
matrix:
typo3: ['12.4', '13.0']
php: ['8.2', '8.3']
steps:
- name: Install TYPO3 ${{ matrix.typo3 }}
run: |
composer require "typo3/cms-core:^${{ matrix.typo3 }}" --no-update
composer update --no-progress
- name: Run tests
run: vendor/bin/phpunit -c Build/phpunit/UnitTests.xml --coverage-clover coverage.xml
# Only upload to SonarCloud once (main combination)
- name: SonarCloud Scan
if: matrix.typo3 == '13.0' && matrix.php == '8.2'
uses: SonarSource/sonarcloud-github-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
```
## PHPStan Integration
### Export PHPStan Results
```bash
# Generate JSON report for SonarCloud
vendor/bin/phpstan analyze \
--configuration Build/phpstan.neon \
--error-format=json \
--no-progress \
> var/log/phpstan.json
```
### Configuration
```properties
# sonar-project.properties
sonar.php.phpstan.reportPaths=var/log/phpstan.json
```
SonarCloud imports PHPStan issues and displays them alongside its own analysis.
## Coverage Configuration
### PHPUnit Coverage
```xml
<!-- Build/phpunit/UnitTests.xml -->
<phpunit>
<coverage>
<report>
<clover outputFile="var/log/coverage.xml"/>
</report>
</coverage>
<source>
<include>
<directory>Classes</directory>
</include>
<exclude>
<directory>Classes/ViewHelpers</directory>
</exclude>
</source>
</phpunit>
```
### Functional Test Coverage
```yaml
- name: Run functional tests with coverage
run: |
export typo3DatabaseDriver=pdo_sqlite
vendor/bin/phpunit \
-c Build/phpunit/FunctionalTests.xml \
--coverage-clover var/log/coverage-functional.xml
- name: Merge coverage reports
run: |
# Use phpcov or merge manually
vendor/bin/phpcov merge var/log/ --clover var/log/coverage.xml
```
## Quality Gates
### Recommended Gate for TYPO3 Extensions
| Condition | Threshold | Rationale |
|-----------|-----------|-----------|
| New bugs | 0 | No new bugs in PRs |
| New vulnerabilities | 0 | Security first |
| Coverage on new code | ≥80% | TYPO3 best practice |
| Duplicated lines | ≤3% | DRY principle |
| Maintainability rating | A | Clean code |
### Custom Gate Setup
1. Go to SonarCloud → Your Project → Project Settings → Quality Gates
2. Create new gate or copy "Sonar Way"
3. Adjust thresholds for TYPO3 requirements
### Gotchas: new-code duplication and coverage pull against each other
- **"New-code duplication" counts pre-existing duplication on lines you touch.**
A mechanical sweep that edits many lines (e.g. converting `createMock()` → `createStub()`
across the test suite) makes SonarCloud attribute the *surrounding* duplicated test
scaffolding to the PR as "new code", so a green project can suddenly fail
`new_duplicated_lines_density`. Fix it by factoring the repeated setup (mock wiring,
fixtures) into private helpers — not by reverting the original change.
- **De-duplicating tests can drop the coverage gate — but only when it removes exercised
paths.** *Pure* extraction of shared mock setup into helpers/traits does NOT change
coverage: test files are excluded from coverage metrics and the same production lines
still run. Coverage drops when the consolidation also *merges distinct scenarios* (so a
production branch is no longer exercised in any test) or *deletes* redundant test cases.
The local gate is usually *unit*-only (`phpunit -c Build/phpunit/UnitTests.xml`), so
re-check it after a dedup sweep and recover by adding unit tests for methods previously
only *functionally* covered (e.g. thin repository wrappers) — don't lower the gate.
- **Locate the duplicated blocks instead of guessing.** The gate reports a percentage, not
a location. Ask the API which *files* carry the new duplicated lines, then which line
ranges inside them, and dedupe exactly those:
```bash
# 1. which files — `duplications/show` needs a file key you do not have yet
curl -s -H "Authorization: Bearer $SONAR_TOKEN" \
"https://sonarcloud.io/api/measures/component_tree?component=ORG_PROJECT&pullRequest=PR&metricKeys=new_duplicated_lines&ps=200" \
| jq -r '.components[] | (.measures[0] | (.value // .periods[0].value // "0")) as $v
| select($v != "0") | select(.qualifier=="FIL") | "\($v)\t\(.path)"'
# 2. which line ranges inside one of them, paired with their twin
curl -s -H "Authorization: Bearer $SONAR_TOKEN" \
"https://sonarcloud.io/api/duplications/show?key=ORG_PROJECT%3Apath/to/File.php&pullRequest=PR" \
| jq '.duplications[].blocks | map("\(.from)-\(.from + .size - 1)")'
```
Two shapes in step 1 or it prints nothing: a `new_*` metric carries its value under
`periods[0].value`, **not** `.value`, and the response lists directories alongside files,
so filter `qualifier=="FIL"` to get paths step 2 accepts.
Blocks repeated 6–10× across a test family are common; the few lines you added inside one
of them are what the gate attributes to your PR.
- **Fixing the gate once does not keep it fixed.** As long as the surrounding blocks stay
duplicated, the *next* commit that adds a line inside them trips the gate again — the
percentage is recomputed per push against the new code of that push. Dedupe the region
rather than trimming your addition, and re-read the gate after every subsequent push
instead of assuming the earlier fix still holds.
- **The dedup fix trades duplication for other smells — check for them in the same pass.**
Collapsing 6–10 repeated arrangements into one helper concentrates every varying value
into its signature, which reliably produces `php:S107` (more than 7 parameters) and
`php:S3776` (cognitive complexity above 15). Both are MAJOR code smells that pass the
gate but ship in the diff. Keep the helper at ≤7 parameters — group rarely-used trailing
arguments into one options array, and drop any parameter another already determines —
and move branching out of the helper body into small private methods.
## PR Decoration
SonarCloud automatically comments on PRs:
```
┌──────────────────────────────────────────────┐
│ Quality Gate passed │
├──────────────────────────────────────────────┤
│ Coverage: 85.2% (+3.1%) │
│ │
│ 0 Bugs │
│ 0 Vulnerabilities │
│ 3 Code Smells (1 new) │
│ 0.8% Duplication │
└──────────────────────────────────────────────┘
```
## Badges
Add to your extension's README:
```markdown
[](https://sonarcloud.io/summary/new_code?id=your-org_your-extension)
[](https://sonarcloud.io/summary/new_code?id=your-org_your-extension)
[](https://sonarcloud.io/summary/new_code?id=your-org_your-extension)
```
## Common PHP Rules
SonarCloud catches TYPO3-relevant issues:
| Rule | Example | Severity |
|------|---------|----------|
| SQL Injection | Raw SQL without prepared statements | 🔴 Critical |
| XSS | Unescaped output in templates | 🔴 Critical |
| Hardcoded credentials | Passwords in code | 🔴 Critical |
| Deprecated API | `$GLOBALS['TYPO3_DB']` usage | 🟡 Major |
| Unused code | Dead methods, variables | 🟢 Minor |
| Complexity | Methods >20 cyclomatic complexity | 🟡 Major |
| Duplication | Copy-pasted code blocks | 🟢 Minor |
## Comparison with PHPStan
| Feature | SonarCloud | PHPStan |
|---------|------------|---------|
| Type analysis | Basic | ⭐⭐⭐⭐⭐ |
| Security rules | ⭐⭐⭐⭐⭐ | Basic |
| Coverage tracking | ✅ | ❌ |
| PR decoration | ✅ | ❌ |
| Quality gates | ✅ | ❌ |
| TYPO3-specific | Basic | ⭐⭐⭐⭐⭐ (with phpstan-typo3) |
| Code smells | ⭐⭐⭐⭐⭐ | ❌ |
| Duplication | ✅ | ❌ |
**Recommendation**: Use **both** - PHPStan for deep type analysis, SonarCloud for holistic quality view.
## Troubleshooting
### Coverage Not Showing
1. Verify coverage file exists and has content:
```bash
cat var/log/coverage.xml | head -20
```
2. Check path in sonar-project.properties matches actual location
3. Ensure source files in coverage match `sonar.sources` paths
### PHPStan Results Not Imported
1. Verify JSON format:
```bash
cat var/log/phpstan.json | jq .
```
2. Check file path in configuration
3. Run PHPStan with `|| true` to not fail on errors
### Quality Gate Failing
1. Check SonarCloud dashboard for specific failures
2. Review new code metrics (not overall)
3. Fix issues or adjust gate thresholds
### Scan Taking Too Long
Add exclusions for generated/vendor code:
```properties
sonar.exclusions=**/vendor/**,.Build/**,var/**,node_modules/**
```
## Best Practices
1. **Run locally first**: Test configuration before CI
```bash
docker run --rm -v $(pwd):/usr/src sonarsource/sonar-scanner-cli
```
2. **Focus on new code**: Use quality gates on new code, not legacy
3. **Integrate PHPStan**: Import PHPStan results for comprehensive analysis
4. **Regular reviews**: Check security hotspots weekly
5. **Team onboarding**: Share SonarCloud access with all developers
## Resources
- [SonarCloud PHP Documentation](https://docs.sonarcloud.io/advanced-setup/languages/php/)
- [PHP Rules](https://rules.sonarsource.com/php/)
- [GitHub Actions Integration](https://docs.sonarcloud.io/advanced-setup/ci-based-analysis/github-actions-for-sonarcloud/)
- [TYPO3 Testing Best Practices](https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/Testing/Index.html)
references/synthetic-secret-fixtures.md
# Synthetic Secret Fixtures in Tests
When writing fuzz or detection tests that must contain fake secrets (to prove the
detector recognises them), two independent systems will refuse to let you commit
obvious literals.
## The Two Blockers
### 1. GitHub Push Protection
GitHub scans pushed commits for known secret patterns. Literals like:
- `sk_live_...` (Stripe live key)
- `AKIA...` (AWS access key)
- `ghp_...` (GitHub personal access token)
- `SG....` (SendGrid API key)
- `xoxb-...` (Slack bot token)
- `AIza...` (Google API key)
…trigger a push block, even inside test files. The block cannot be bypassed with
a comment or annotation — only by never having the full literal in any committed
blob.
### 2. PHP-CS-Fixer `no_useless_concat_operator` Rule
Even if you try to split the literal with string concatenation:
```php
// Attempt — will be COLLAPSED by php-cs-fixer
$key = 'sk' . '_live_XYZ123';
```
The `no_useless_concat_operator` fixer detects that both operands are string
literals and merges them into `'sk_live_XYZ123'` on the next `composer cgl` run.
This restores the full literal, re-triggering GitHub's push protection.
## Correct Pattern: Use `implode()` or a Closure
Function calls **cannot** be collapsed by `no_useless_concat_operator` because the
fixer only operates on compile-time constants.
### Option A — `implode()` directly
```php
// Stripe-style live key fixture
$stripeKey = implode('', ['sk', '_live_', 'XXXXXXXXXXXXXXXXXXXXXXXX']);
// AWS access key fixture
$awsKey = implode('', ['AKIA', 'IOSFODNN7EXAMPLE']);
// GitHub PAT fixture
$ghpToken = implode('', ['ghp', '_', 'abcdefgh1234567890abcdefgh1234567890']);
```
`implode('', [...])` is semantically equivalent to concatenation but is a runtime
call — the fixer leaves it alone, and GitHub sees only array literals, not a full
key pattern.
### Option B — Helper Closure (preferred for many fixtures)
```php
// Define in setUp(), or extract to a private static function make(...), or use a top-level helper function
$make = static fn(string ...$parts): string => implode('', $parts);
// Usage in tests
$stripeKey = $make('sk', '_live_', 'XXXXXXXXXXXXXXXXXXXXXXXXXXXX');
$awsKey = $make('AKIA', 'IOSFODNN7', 'EXAMPLE');
$slackToken = $make('xoxb', '-', '123456789012', '-', 'AbCdEfGhIjKlMnOpQrStUvWx');
```
The closure approach is cleaner when a test class contains many synthetic secrets.
### Option C — Use Clearly Fake Prefixes That Don't Match Real Patterns
For detectors that test on _format_ rather than _content_, substitute prefix
characters so the literal never matches the real pattern:
```php
// Replace prefix characters so the literal never matches the real pattern.
// Real pattern: sk_live_[a-zA-Z0-9]{24}
// Fake live key: use a clearly-fake prefix that doesn't match the scanner regex
$stripeFakeKey = 'XX_live_XXXXXXXXXXXXXXXXXXXXXXXX';
// Real AWS: AKIA[0-9A-Z]{16}
// Fake: Use ZZIA prefix — not a valid AWS key ID prefix
$fakeAwsKey = 'ZZIAIOSFODNN7EXAMPLE';
```
Only use this approach when your detector tests format/entropy, not specific prefixes.
## Summary
| Technique | Safe from push-protection | Safe from no_useless_concat |
|---|---|---|
| `'sk' . '_live_X'` | No | No (fixer collapses it) |
| `implode('', ['sk', '_live_', 'X'])` | Yes | Yes |
| `$make('sk', '_live_', 'X')` (closure) | Yes | Yes |
| Clearly fake prefix (e.g. `ZZIA...`) | Yes | Yes |
| `str_split` + `implode` gymnastics | Yes (but ugly) | Yes |
Always use `implode()` or a closure factory as the default approach. Document in
the test file that the fragmentation is intentional:
```php
// Fragmented via implode() to avoid GitHub push-protection on fake secret
// literals and php-cs-fixer's no_useless_concat_operator rule.
$fixture = implode('', ['sk', '_live_', 'XXXXXXXXXXXXXXXXXXXXXXXX']);
```
references/tdd-discipline.md
# TDD Discipline
The strict loop used for bug fixes and new features. Prevents "tested and verified" claims that turn out to be wishful thinking.
## The Non-Negotiable Loop
For every bug fix, follow this sequence. Do not deviate. Do not ask the user for input mid-loop until the loop is green or until you have tried 5 distinct implementation approaches.
1. **Reproduce** — write a failing test that captures the bug. Commit it on the working branch under a conventional-commit message like `test: reproduce <bug>`.
2. **Confirm the test fails for the expected reason** — not a setup error, not a bootstrapping error, not a missing fixture. The failure message must describe the actual bug.
3. **Implement the minimal fix.** Scope it narrowly to the smallest diff that turns the failing test green.
4. **Run the specific test** via `Build/Scripts/runTests.sh -s unit -- --filter <TestName>` (or functional, as appropriate). Must pass.
5. **Run the full suite** (`-s unit`, `-s functional`) and linters (`-s phpstan`, `-s cgl`). Must all pass.
6. **If anything fails, iterate.** Do not report the fix as done. Try up to 5 distinct approaches before escalating.
7. **Only then open the PR.** The PR description must include the name of the reproduction test and the one-line verification command.
## Forbidden Phrases Without Evidence
These words are banned from assistant output unless the same turn contains the command output backing the claim:
- "tested"
- "verified"
- "confirmed working"
- "passes"
- "all green"
If you cannot run the verification (no DDEV available, no Docker, sandbox constraints), say so explicitly: "I implemented the fix but did not run the test suite because <reason>. The validating command is: `<command>`."
## Evidence Required
"Evidence" means one of:
- Command output pasted in the same turn (stdout or stderr)
- A CI run URL with visible status
- A gist or artifact link with visible content
- A GitHub Actions summary screenshot (for E2E)
A bare assertion from the assistant is not evidence.
## Playwright Hard Timeout
Playwright sessions have hung for 2+ hours when waiting for a selector that never appeared. Set a hard ceiling. The snippet below shows the timeout-relevant fields only — merge into the project's existing `playwright.config.ts` alongside its `projects`, `webServer`, `use`, and reporter settings:
```typescript
// playwright.config.ts — merge into existing defineConfig({...})
import { defineConfig } from '@playwright/test';
export default defineConfig({
timeout: 120_000, // 2 min per test — HARD CAP
expect: { timeout: 15_000 }, // 15 s per expectation
globalTimeout: 1_800_000, // 30 min total across all tests
workers: 4,
forbidOnly: !!process.env.CI,
// ... keep existing fields: projects, webServer, use, reporter, etc.
});
```
Individual tests that legitimately need more time must document why in a comment and pass `test.setTimeout(...)` explicitly — never raise the global default.
### If a Playwright run hangs
1. Kill it. Do not wait longer than the configured timeout.
2. Re-run with `DEBUG=pw:api` to see which action stalled.
3. Inspect the trace: `npx playwright show-trace test-results/*.zip`.
4. Add an explicit `waitFor` with a short timeout + meaningful error message to the stalling selector.
## Cross-Version Test Worktree Authority
When testing across TYPO3 v11/v12/v13/v14, use a separate worktree per version — never switch branches in place. The `.bare/` bare-clone layout this assumes (setup commands, absolute-path rules, cache safety) is documented in [`typo3-extension-upgrade-skill/references/multi-version-worktrees.md`](https://github.com/netresearch/typo3-extension-upgrade-skill/blob/main/skills/typo3-extension-upgrade/references/multi-version-worktrees.md) — set that up first, then:
```bash
git -C .bare worktree add ../TYPO3_11 TYPO3_11
git -C .bare worktree add ../TYPO3_12 TYPO3_12
git -C .bare worktree add ../main main
# Run tests in the specific worktree
cd ../TYPO3_11 && Build/Scripts/runTests.sh -s functional -p 8.1
cd ../main && Build/Scripts/runTests.sh -s functional -p 8.4
```
### Before declaring "tested on v14"
Verify the command ran in the correct worktree:
```bash
pwd # must match the intended worktree
# composer.json: typo3/cms-core may be declared in require OR require-dev
# depending on whether the extension treats core as a runtime or a dev
# dependency. typo3/testing-framework is typically require-dev. Search the
# union so both forms work.
jq -r '(.require + (."require-dev" // {}))["typo3/cms-core"]' composer.json
jq -r '(.require + (."require-dev" // {}))["typo3/testing-framework"]' composer.json
# composer.lock: same packages may resolve under .packages or .packages-dev
# per the same runtime/dev split.
jq -r '(.packages + (."packages-dev" // []))[] | select(.name=="typo3/cms-core") | .version' composer.lock
jq -r '(.packages + (."packages-dev" // []))[] | select(.name=="typo3/testing-framework") | .version' composer.lock
```
All four values should agree on the target major. If you ran tests in the v13 worktree but claimed v14, the claim is false.
## Prove the Test Can Fail — Name the Production Change
Step 2 of the loop only works when the test is written first. When the code
already exists — a review finding, a fix you wrote before thinking about the
test, a test added to cover old behaviour — there is no observed red state, and
"the suite is green" then says nothing about whether the test guards anything.
Before calling such a test done, answer one question in writing:
> Which single production change would make this test fail?
Then make that change, run the test, and confirm it fails. Revert. This is a
one-minute mutation probe and it is the only evidence that the test is load-bearing:
```bash
# 1. revert the fix (or invert the condition) in the production file
# 2. run only the test that is supposed to guard it
Build/Scripts/runTests.sh -s unit -- --filter <TestName> # MUST fail
# 3. restore the production file
# 4. run the exact same command again
Build/Scripts/runTests.sh -s unit -- --filter <TestName> # MUST pass
```
If you cannot name a change that breaks it, the test is decorative — delete it
or move it to the level where the behaviour actually lives.
**The failure mode this catches.** A fix moved a value from the wrong source to
the right one in a service, and the accompanying test asserted that the
*downstream setter* stored what it was handed. Green, and worthless: reverting
the actual fix — the caller passing the wrong value again — left the test green,
because it never involved the caller. The whole bug was restorable with a
passing suite. A reviewer found it; the probe above would have found it in a
minute.
Watch for the shape: a test that exercises a collaborator one layer *below* the
line you changed. Assert on the seam you actually moved.
## Anti-Patterns
| Anti-pattern | What it looks like | Why it's wrong |
|--------------|--------------------|----------------|
| "Looks fine, should work" | No command run | Zero evidence |
| Test written after the fix, never seen red | Green suite, no observed failure | Proves nothing; run the mutation probe above |
| Asserting on a setter instead of the changed seam | `assertSame($x, $entity->getX())` for a fix in the *caller* | Reverting the fix leaves the test green |
| "Tests pass locally" | No output pasted | Unverifiable claim |
| Sharing a mock DB in a multi-test setup | One DB fixture across tests | Test pollution; flaky failures |
| Same service instance reused across tests | `private static Service $sharedService` at class scope | State bleed between tests |
| Running tests without `-p <php-version>` | `Build/Scripts/runTests.sh -s unit` on multi-PHP project | Silently uses host PHP, misses compat bugs |
| Declaring green on one TYPO3 version, shipping to all | Single `-s functional` run | Different LTSes break differently |
## Reporting a Fix
After the loop completes, report in this format:
```
Fix summary: <one sentence>
Reproduction test: Tests/Unit/<Name>Test.php::<testMethod>
Verification:
$ Build/Scripts/runTests.sh -s unit -- --filter <Name>
<pasted output, last 10 lines>
Full suite: green on <versions tested>
Not tested: <any versions or suites not exercised, with reason>
```
The "Not tested" line is mandatory — if every suite ran, write "none".
references/test-environment-guards.md
# Test Environment Guards
Patterns for writing robust tests that handle different runtime environments gracefully (CI containers running as root, missing PHP extensions, filesystem permissions).
## Initialise `Environment` in `Tests/bootstrap.php`
Production code that uses `TYPO3\CMS\Core\Http\NormalizedParams::createFromServerParams()` (typically as a CLI / non-request fallback for the deprecated `GeneralUtility::getIndpEnv()` -- deprecated in TYPO3 v14.3, removed in v15.0) will TypeError under PHPUnit unless `Environment` has been initialised:
```
TypeError: TYPO3\CMS\Core\Core\Environment::getCurrentScript():
Return value must be of type string, null returned
```
`createFromServerParams()` calls `Environment::getCurrentScript()` and `Environment::getPublicPath()` to populate the path-related fields of `NormalizedParams`. In unit tests TYPO3's `SystemEnvironmentBuilder` does not run, so `Environment` is uninitialised and those getters return `null`.
**Fix:** initialise `Environment` once in `Tests/bootstrap.php`:
```php
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/.Build/vendor/autoload.php';
$projectPath = \dirname(__DIR__);
\TYPO3\CMS\Core\Core\Environment::initialize(
new \TYPO3\CMS\Core\Core\ApplicationContext('Testing'),
true, // cli
true, // composerMode
$projectPath, // projectPath
$projectPath, // publicPath
$projectPath . '/var', // varPath
$projectPath . '/config', // configPath
__FILE__, // currentScript (this Tests/bootstrap.php file)
'UNIX', // os
);
```
Reference the bootstrap from `phpunit.xml` at the project root, or from `Build/phpunit/UnitTests.xml` (note the relative path differs by config location):
```xml
<!-- phpunit.xml at project root -->
<phpunit bootstrap="Tests/bootstrap.php" ...>
<!-- Build/phpunit/UnitTests.xml (two levels deep) -->
<phpunit bootstrap="../../Tests/bootstrap.php" ...>
```
**Where this matters:**
- Code paths that call `NormalizedParams::createFromServerParams($_SERVER, $sysConf)` from CLI / non-request contexts
- Migrations away from `GeneralUtility::getIndpEnv()` (deprecated v14.3, removed v15.0)
- Any unit test that exercises code touching `Environment::getCurrentScript()` / `Environment::getPublicPath()`
### Define the `LF` constant for TYPO3 v12 unit tests
TYPO3 v12's `PageRenderer` (and a few other v12-only code paths) reference the `LF` global constant that `SystemEnvironmentBuilder::defineBaseConstants()` defines during a normal request bootstrap. Unit tests do not run that bootstrap, so any test that exercises v12 PageRenderer code dies with `Undefined constant "LF"` (PHP 8.x: a fatal `Error`).
Add this guard early in `Tests/bootstrap.php`, next to the `Environment::initialize()` call:
```php
if (!\defined('LF')) {
\define('LF', "\n");
}
```
The constant was effectively retired in v13+ (replaced by `PHP_EOL` / explicit `"\n"` at call sites), but the guard is harmless on v13/v14 and is required while the extension still supports v12.
## PHPUnit `backupGlobals="true"` Resets `$GLOBALS` Between Tests
Many TYPO3 extension `phpunit.xml` files set `backupGlobals="true"`. PHPUnit runs the suite bootstrap once, then snapshots `$GLOBALS` per test (before `setUp()`) and restores the snapshot after the test finishes. Globals set by the suite bootstrap survive that cycle, but globals introduced inside `setUp()` or mutated by a previous test do not -- they are reset to whatever was captured in the snapshot. Combined with CLI / non-request contexts where `$GLOBALS['TYPO3_CONF_VARS']` may simply never have been populated, production code that reads it at runtime can see `null` -- typically resulting in:
```
TypeError: ... must be of type array, null given
```
or PHPStan level 10 `offsetAccess.nonOffsetAccessible` errors when statically analysing array access on `$GLOBALS['TYPO3_CONF_VARS']`.
**Fix:** never assume bootstrap-set globals are present at runtime. Read them defensively with explicit narrowing and a safe default:
```php
use TYPO3\CMS\Core\Http\NormalizedParams;
$confVars = $GLOBALS['TYPO3_CONF_VARS'] ?? null;
$sysConf = \is_array($confVars) && isset($confVars['SYS']) && \is_array($confVars['SYS'])
? $confVars['SYS']
: [];
return NormalizedParams::createFromServerParams($_SERVER, $sysConf);
```
This pattern:
- Survives `backupGlobals="true"` snapshot/restore cycles
- Handles CLI / non-request contexts where `$GLOBALS['TYPO3_CONF_VARS']` may not be populated
- Satisfies PHPStan level 10 strict-mode rules on `$GLOBALS['TYPO3_CONF_VARS']` access
**Alternative:** set `backupGlobals="false"` in `phpunit.xml` if no test relies on global isolation -- but the defensive read pattern above is preferred because it also hardens production code for genuinely uninitialised CLI contexts.
## Transient `HashService`/`encryptionKey` E_WARNING When a Functional Test Builds a DI Container
**Symptom:** a functional test that realises a dependency-injection container which
loads a package (e.g. `dashboard`) fails with `failOnWarning=true` on an
`E_WARNING` — "Undefined array key" / "Trying to access array offset on null" —
raised from core `TYPO3\CMS\Core\Crypto\HashService::hmac()` reading
`$GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey']`. The tell-tale traits:
- It fires only on **one matrix cell** (seen on PHP 8.2 × TYPO3 `^14.3`), only on
the cold CI container — **not reproducible locally**, and the test **passes in
isolation** and in a functional-only run.
- The warning originates inside `parent::setUp()`'s container build, not in your
own test body.
**Cause:** realising the container makes TYPO3 core reset and repopulate
`$GLOBALS['TYPO3_CONF_VARS']` mid-build; a service instantiated during that window
calls `HashService::hmac()` while `['SYS']['encryptionKey']` is momentarily unset.
It is benign — production's error handler suppresses it, and the functional test
runner sets `errorHandler=''`, so only `failOnWarning=true` turns it into a failure.
**Why the obvious fixes don't work:** the build clears the global itself, so pinning
`encryptionKey` or using `#[BackupGlobals(false)]` doesn't help; the warning fires
inside `parent::setUp()`, so an in-body `try` can't catch it; `#[WithoutErrorHandler]`
works but is too broad (an AI reviewer will rightly flag it — it disables *all*
error-to-exception conversion for the test).
**Fix:** wrap the `parent::setUp()` call in a **scoped** `set_error_handler` that
suppresses *only* this specific warning, and restore it **inside the same method**
so the handler stack stays balanced (a set-in-`setUp` / restore-in-`tearDown` split
trips `failOnRisky`):
```php
protected function setUp(): void
{
// Benign core-boot warning on PHP 8.2 × TYPO3 ^14.3 cold CI containers:
// building the DI container transiently unsets ['SYS']['encryptionKey']
// while HashService::hmac() reads it. Suppress ONLY that warning and
// delegate everything else back to the handler that was active (PHPUnit's),
// so failOnWarning still catches unrelated warnings during parent::setUp().
$previous = set_error_handler(
static function (int $errno, string $errstr, string $errfile, int $errline) use (&$previous): bool {
// Normalise separators so the match also holds on Windows (backslash paths).
$isBenignBootWarning = str_contains(str_replace('\\', '/', $errfile), 'Crypto/HashService.php')
&& (str_contains($errstr, 'TYPO3_CONF_VARS')
|| str_contains($errstr, 'array offset') // "…array offset on null"
|| str_contains($errstr, 'Undefined array key'));
if ($isBenignBootWarning) {
return true; // swallow only this one
}
// Not ours: hand back to the previously-registered (PHPUnit) handler so
// real warnings still fail the test; false only if there was none.
return $previous !== null
? (bool) $previous($errno, $errstr, $errfile, $errline)
: false;
},
\E_WARNING,
);
try {
parent::setUp();
} finally {
restore_error_handler();
}
// ... rest of setUp ...
}
```
The delegation is what keeps the guard honest: the matched benign warning returns
`true` (swallowed), but every other warning is handed back to the handler that was
active — PHPUnit's — so `failOnWarning` still catches real issues during
`parent::setUp()`. (Returning `false` there instead would fall through to PHP's
*internal* handler, silently blinding `failOnWarning` for that window.) Capture the
previous handler by reference so the closure can delegate to it, and match on both
`errfile` (`Crypto/HashService.php`) **and** `errstr` to keep the guard narrow.
## GD/Imagick Extension Guard
Tests involving image processing (thumbnails, resizing, format conversion) must check for the GD or Imagick extension. CI environments may not have image libraries installed.
```php
protected function setUp(): void
{
parent::setUp();
if (!extension_loaded('gd') && !extension_loaded('imagick')) {
self::markTestSkipped('GD or Imagick extension required for image processing tests.');
}
}
```
For tests that specifically need GD (e.g., testing GD-specific behavior):
```php
if (!extension_loaded('gd')) {
self::markTestSkipped('GD extension not available.');
}
```
**Where this matters:**
- `ImageService` tests
- Thumbnail generation tests
- Image dimension/metadata extraction
- Any class wrapping `GdImage` or Imagick objects
## Root User Guard for Permission Tests
Tests that use `chmod(0o000)` to simulate unreadable/unwritable files will always pass when running as root (UID 0), because root bypasses filesystem permissions. Docker CI containers often run as root.
```php
if (function_exists('posix_geteuid') && posix_geteuid() === 0) {
self::markTestSkipped('Cannot test unreadable files when running as root.');
}
```
**Full pattern in context:**
```php
#[Test]
public function throwsExceptionForUnreadableFile(): void
{
if (function_exists('posix_geteuid') && posix_geteuid() === 0) {
self::markTestSkipped('Cannot test unreadable files when running as root.');
}
$path = $this->tempDir . '/unreadable.txt';
file_put_contents($path, 'data');
chmod($path, 0o000);
$this->expectException(\RuntimeException::class);
$this->subject->readFile($path);
}
```
**Where this matters:**
- Tests that verify error handling for permission-denied scenarios
- Filesystem security tests
- Configuration file access tests
## Filesystem tearDown Cleanup
Tests that create temporary files or directories MUST clean up in `tearDown()`. Use instance properties (not local variables) so `tearDown()` can always find and remove them, even when a test fails mid-execution.
### Pattern: Temp Directory Cleanup
```php
final class FileProcessorTest extends UnitTestCase
{
private ?string $tempDir = null;
protected function setUp(): void
{
parent::setUp();
$this->tempDir = sys_get_temp_dir() . '/typo3_test_' . uniqid('', true);
mkdir($this->tempDir, 0o777, true);
}
protected function tearDown(): void
{
if ($this->tempDir !== null && is_dir($this->tempDir)) {
$this->removeDirectory($this->tempDir);
}
parent::tearDown();
}
private function removeDirectory(string $path): void
{
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($items as $item) {
// Restore permissions before removal (handles chmod 0o000 tests)
chmod($item->getPathname(), 0o777);
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($path);
}
}
```
### Key Rules
1. **Use instance properties** (`$this->tempDir`) not local variables -- `tearDown()` must access them
2. **Check for null** in `tearDown()` -- `setUp()` may not have completed
3. **Restore permissions** before removal -- files made unreadable with `chmod(0o000)` cannot be deleted otherwise
4. **Always call `parent::tearDown()`** -- TYPO3 testing framework cleanup depends on it
5. **Use `sys_get_temp_dir()`** -- never hardcode `/tmp`, it varies across platforms
### Pattern: Single Temp File Cleanup
```php
private ?string $tempFile = null;
protected function tearDown(): void
{
if ($this->tempFile !== null && file_exists($this->tempFile)) {
// Restore permissions in case chmod tests made it unreadable
chmod($this->tempFile, 0o644);
unlink($this->tempFile);
}
parent::tearDown();
}
```
## Recovering from Root-Owned Test Artifacts (Docker Leftovers)
A functional run inside a Docker container that runs as **root** (no `--user` flag) writes `public/typo3temp/var/tests/` (and `.Build/`, `var/`) as root. A later run on the host (or as a non-root user) then can't remove or recreate those dirs, and every test errors in `setUp()`/bootstrap:
```
TYPO3\TestingFramework\Core\Exception: Can not remove folder:
.../public/typo3temp/var/tests/functional-XXXXXXX
Directory ".../public/typo3temp/var/tests" could not be created
```
This is a **cascade** — one permission fatal aborts the whole class, so you see N identical `setUp()` errors, not N real failures. Read the *first* one. The same cascade shape appears for a PHP **compile** fatal (parse error, `Cannot declare self-referencing constant`): PHPUnit exits **255** ("An error occurred inside PHPUnit"), and the per-test `getcwd`/unique-constraint errors are downstream noise. Treat exit 255 as compile/bootstrap fatal, never as flaky infra — and remember `gh run rerun` replays the ORIGINAL commit SHA, so it "reproduces" a since-fixed break and proves nothing. Classic unit-invisible/functional-fatal trap after literal→constant extraction: a `replace_all` that rewrites the constant's own declaration to `= self::THE_CONST` (grep `const [A-Z_]* = self::` after such sweeps).
**Prevent:** always pass `--user "$(id -u):$(id -g)"` to `docker run` for test containers (the skill's `runTests.sh` does this on Linux; see `test-runners.md`).
**Recover** (the dirs already exist root-owned and the host can't touch them) — delete or chown via a throwaway root container, then re-run:
```bash
# Remove the root-owned test dirs
docker run --rm -v "$PWD:/app" -w /app alpine rm -rf public/typo3temp/var/tests
# ...or hand ownership of the whole tree back to your user
docker run --rm -v "$PWD:/app" -w /app alpine chown -R "$(id -u):$(id -g)" public/typo3temp
```
## PHPUnit Version Compatibility: createMock vs createStub
### AllowMockObjectsWithoutExpectations Is PHPUnit 12 Only
The `#[AllowMockObjectsWithoutExpectations]` attribute does NOT exist in PHPUnit 11, which is used in CI for PHP 8.2. Using it causes a fatal error on PHPUnit 11.
**Never use this attribute** in code that must run on PHPUnit 11 (PHP 8.2 CI environments).
### Solution: Use createStub() Instead
When a test double has no configured expectations (no `expects()` calls), use `createStub()` instead of `createMock()`:
```php
// BAD: createMock without expectations triggers PHPUnit notice
// Adding #[AllowMockObjectsWithoutExpectations] breaks PHPUnit 11
$dependency = $this->createMock(SomeInterface::class);
// GOOD: createStub() is designed for doubles without expectations
$dependency = $this->createStub(SomeInterface::class);
$dependency->method('getValue')->willReturn('test');
```
### When to Use Each
| Method | Use When | Supports expects() |
|--------|----------|-------------------|
| `createMock()` | You need to verify interactions (`expects()`, `with()`) | Yes |
| `createStub()` | You only need return values, no interaction verification | No |
### Decision Guide
```
Need to assert method was called with specific args?
├── Yes → createMock() + expects() + with()
└── No
Need to assert call count?
├── Yes → createMock() + expects(self::once())
└── No → createStub() + method()->willReturn()
```
## Acceptance Tests with DOMDocument
For testing rendered HTML output without a full TYPO3 frontend bootstrap, create acceptance tests that parse HTML with DOMDocument.
### Directory Structure
```
Tests/
├── Unit/ # Pure logic, no TYPO3 bootstrap
├── Functional/ # TYPO3 bootstrap, database
└── Acceptance/ # HTML output verification via DOMDocument
```
### Pattern: DOMDocument-Based HTML Verification
```php
<?php
declare(strict_types=1);
namespace Vendor\Extension\Tests\Acceptance;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class RenderedOutputTest extends TestCase
{
#[Test]
public function renderedHtmlContainsExpectedStructure(): void
{
$html = $this->renderTemplate('EXT:my_ext/Resources/Private/Templates/List.html', [
'items' => [['title' => 'Test Item']],
]);
$doc = new \DOMDocument();
@$doc->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
$xpath = new \DOMXPath($doc);
$items = $xpath->query('//ul[@class="item-list"]/li');
self::assertNotFalse($items);
self::assertSame(1, $items->length);
self::assertSame('Test Item', trim($items->item(0)->textContent));
}
}
```
**When to use acceptance tests:**
- Verifying ViewHelper output structure
- Testing that templates render expected DOM elements
- Checking accessibility attributes in rendered HTML
- Validating SEO meta tags in output
**When NOT to use (use functional tests instead):**
- Tests that need TYPO3 database
- Tests that need TypoScript configuration
- Tests that need site/routing configuration
## Infection Mutation Testing Configuration
When setting up Infection for a TYPO3 extension that uses `.Build/` for vendor dependencies:
```json5
{
"$schema": ".Build/vendor/infection/infection/resources/schema.json",
"source": {
"directories": ["Classes"]
},
"timeout": 30,
"mutators": {
"@default": true
}
}
```
**Key difference from generic setup:** The `$schema` path uses `.Build/vendor/` (TYPO3 convention) rather than `vendor/`. This enables IDE autocompletion and validation for the config file.
See [Mutation Testing](mutation-testing.md) for full configuration with log paths, MSI thresholds, and mutator customization.
references/test-runners.md
# Test Runners and Orchestration
The `runTests.sh` script is the **required** TYPO3 pattern for test orchestration, following TYPO3 core conventions.
## Requirements
Extensions **MUST** have a Docker-based `Build/Scripts/runTests.sh` that:
1. Uses **TYPO3 core-testing images** (`ghcr.io/typo3/core-testing-php*`)
2. Supports **multiple databases** (SQLite default, MariaDB, MySQL, PostgreSQL)
3. Supports **multiple PHP versions** (8.2, 8.3, 8.4, 8.5)
4. Works in **CI environments** (auto-detects non-TTY)
5. Handles **database container orchestration** for functional tests
6. Uses **--user flag** on Linux to prevent root-owned files
## Template
Use `assets/Build/Scripts/runTests.sh` as starting point. Customize:
1. `NETWORK` variable: Replace `my-extension` with your extension key
2. `COMPOSER_ROOT_VERSION`: Set to your extension version
3. `TYPO3_BASE_URL`: Set the TYPO3 URL for E2E tests (default: `http://localhost:8080`)
## Basic Usage
```bash
# Show help
./Build/Scripts/runTests.sh -h
# Run unit tests (default)
./Build/Scripts/runTests.sh -s unit
# Run functional tests with SQLite (fastest, no container)
./Build/Scripts/runTests.sh -s functional
# Run functional tests in parallel (2-3x faster)
./Build/Scripts/runTests.sh -s functionalParallel
# Run functional tests with MariaDB
./Build/Scripts/runTests.sh -s functional -d mariadb
# Run with specific PHP version
./Build/Scripts/runTests.sh -p 8.3 -s unit
# Run E2E tests (PHP built-in server + MySQL container, NOT DDEV)
./Build/Scripts/runTests.sh -s e2e
# Run quality tools
./Build/Scripts/runTests.sh -s lint
./Build/Scripts/runTests.sh -s phpstan
./Build/Scripts/runTests.sh -s cgl
```
## Script Options
| Option | Description | Values |
|--------|-------------|--------|
| `-s` | Test suite | `unit`, `functional`, `functionalParallel`, `e2e`, `lint`, `phpstan`, `cgl`, `rector`, `fuzz`, `mutation`, `composer` (runs a composer command, e.g. `-s composer dump-autoload`) |
| `-d` | Database | `sqlite` (default), `mariadb`, `mysql`, `postgres` |
| `-i` | DB version | mariadb: 11.8 (accepted: 10.11, 11.4, 11.8, 12.3), mysql: 8.0, postgres: 16 |
A MariaDB version that has left support is mapped onto the LTS of its own series and the substitution is printed — `-i 10.5` runs 10.11, `-i 12.2` runs 12.3 — so an old call in a Makefile keeps working instead of failing. `DBMS_VERSION_EXACT=1` runs the requested version verbatim, for reproducing a bug on the engine a customer actually operates.
| `-p` | PHP version | `8.2`, `8.3`, `8.4`, `8.5` |
| `-x` | Enable Xdebug | |
| `-n` | Dry-run | For cgl, rector |
| `-u` | Update images | |
## Test Parallelization
### E2E Tests (Playwright)
Playwright parallelizes by spec file. Configure in `playwright.config.ts`:
```typescript
export default defineConfig({
fullyParallel: false, // Tests within file run sequentially (safer)
workers: process.env.CI ? 4 : undefined, // CI: fixed, Local: half of CPUs
});
```
**Performance**: 3x speedup (3.8min → 1.3min for 111 tests)
**Note**: Workers are capped at the number of spec files when `fullyParallel: false`.
### Functional Tests (functionalParallel)
Uses `xargs -P` to run test files concurrently with SQLite:
```bash
# CI: 4 parallel jobs for predictable resource usage
# Local: half of available CPUs
if [ "${CI}" == "true" ]; then
PARALLEL_JOBS=4
else
PARALLEL_JOBS="$(($(nproc) + 1) / 2)"
fi
find Tests/Functional -name '*Test.php' | xargs -P${PARALLEL_JOBS} ...
```
**Performance**: 2-3x speedup (24s → 10s for 62 tests). On CI's slower shared runners the win is larger — an 8-11min serial functional cell drops to ~2.5min.
**Why one process per file is collision-free (and works on MariaDB too):** the testing-framework derives BOTH the test instance directory AND the database name from the same per-class identifier — `substr(sha1(static::class), 0, 7)` (`FunctionalTestCase::getInstanceIdentifier`), used as `functional-<id>/` for the SQLite file and as `<originalDatabaseName>_ft<id>` for MySQL/MariaDB (`FunctionalTestCase.php`, the non-sqlite branch). So each **file** gets its own database on a shared server — no CREATE race, safe at `-P4` well under a default `max_connections` of 151. Shard by **file**, never by test *method* (`--filter`): several methods of one class share one instance.
**Glob every suite `FunctionalTests.xml` declares, not just `Tests/Functional`.** If the config runs a second testsuite (e.g. an `e2e-backend` directory `Tests/E2E/Backend/`), a sharder that globs only `Tests/Functional` **silently drops it** — the classes never run and CI stays green. Match the config:
```bash
find Tests/Functional Tests/E2E/Backend -name '*Test.php' | xargs -P${PARALLEL_JOBS} ...
```
**Force `XDEBUG_MODE=off` on non-coverage parallel runs.** If setup-php installed Xdebug as the coverage driver, it stays in coverage mode and taxes runtime ~1.6x **even when no coverage is collected** (measured: 61s vs 37.6s on the same 126-test subset). Only enable it on the serial coverage run.
**Requirement**: SQLite with tmpfs (or MySQL/MariaDB, per above) for isolated databases per test file.
### Unit Tests
Unit tests are typically fast enough (<1s) that parallelization overhead would be counterproductive. PHPUnit's native parallelization (ParaTest) doesn't support PHPUnit 12 yet.
## Gotcha: Single-Process PHPUnit OOMs on Large Functional Suites
Running the whole functional suite through plain single-process PHPUnit (`vendor/bin/phpunit -c phpunit.xml.dist`, no parallelization) accumulates every test's compiled DI container and TYPO3 bootstrap in one PHP process. On a few hundred functional tests this exhausts `memory_limit` — typically a fatal deep in Symfony's container dumper:
```
PHP Fatal error: Allowed memory size of 536870912 bytes exhausted ...
in .../symfony/dependency-injection/Dumper/PhpDumper.php
```
The symptom is misleading: progress dots stop part-way with **no PHPUnit summary line**, and a `timeout`/wrapper around the call may still report exit 0 — so it looks like "passed" when it actually died mid-run.
**Fix:** run the suite the way the project's entry point does — in **isolated worker processes**, one slice per worker, so container compilation never accumulates:
```bash
./Build/Scripts/runTests.sh -s functionalParallel # xargs -P, one phpunit process per test file
# or, for a paratest-based project entry point:
composer test
```
Reserve plain `phpunit --filter=SomeClass` for a single class. (This is the concrete reason behind Best Practice "Single Entry Point — all tests via `runTests.sh`, not direct PHPUnit".)
## Database Support
### SQLite (Default)
- **Fastest**: No container startup
- **CI-friendly**: No external services needed
- **Parallelizable**: Each test file gets isolated DB
```bash
./Build/Scripts/runTests.sh -s functional # Uses SQLite
```
### MariaDB/MySQL
- Required for MySQL-specific syntax
- Mark incompatible tests with `#[Group('not-sqlite')]`
```bash
./Build/Scripts/runTests.sh -s functional -d mariadb -i 11.8
./Build/Scripts/runTests.sh -s functional -d mysql -i 8.0
```
### PostgreSQL
- For PostgreSQL compatibility testing
```bash
./Build/Scripts/runTests.sh -s functional -d postgres -i 16
```
## E2E Test Integration
E2E tests use a PHP built-in server + MySQL container. **Do NOT use DDEV for tests.**
In CI, use the reusable `e2e.yml` workflow from `netresearch/typo3-ci-workflows`.
```bash
# Run E2E tests locally (starts PHP server + DB automatically)
./Build/Scripts/runTests.sh -s e2e
# With custom URL (e.g., if TYPO3 is already running)
TYPO3_BASE_URL=http://localhost:8080 ./Build/Scripts/runTests.sh -s e2e
```
### Playwright Docker Image
Use the official Playwright image with pre-installed browsers:
```bash
IMAGE_PLAYWRIGHT="mcr.microsoft.com/playwright:v1.57.0-noble"
```
**Important**: Keep Playwright versions in sync between:
- `package.json`: `"@playwright/test": "^1.57.0"`
- `runTests.sh`: `IMAGE_PLAYWRIGHT="mcr.microsoft.com/playwright:v1.57.0-noble"`
## Helper Functions
### waitFor (TCP port)
Wait for a service to be available on a TCP port:
```bash
waitFor() {
local HOST=${1}
local PORT=${2}
# Uses netcat to check port availability
# Retries up to 10 times with 1 second delay
}
# Usage
waitFor mariadb-container 3306
```
### waitForHttp (HTTP endpoint)
Wait for an HTTP endpoint to respond:
```bash
waitForHttp() {
local URL=${1}
local MAX_ATTEMPTS=${2:-30}
# Uses wget to check HTTP availability
}
# Usage: Wait for mock OAuth server
waitForHttp "http://mock-oauth-container:8080/.well-known/openid-configuration"
```
## Mock Services
### Mock OAuth Server
For testing OAuth integration without real providers:
```bash
IMAGE_MOCK_OAUTH="ghcr.io/navikt/mock-oauth2-server:3.0.1"
${CONTAINER_BIN} run --rm -d --name mock-oauth-${SUFFIX} --network ${NETWORK} \
-e SERVER_PORT=8080 \
-e JSON_CONFIG_PATH=/config/config.json \
-v "${ROOT_DIR}/.ddev/mock-oauth:/config:ro" \
${IMAGE_MOCK_OAUTH}
waitFor mock-oauth-${SUFFIX} 8080
# Pass URL to tests
-e MOCK_OAUTH_URL="http://mock-oauth-${SUFFIX}:8080"
```
## PHP Performance Optimization
Enable opcache and JIT for faster test execution:
```bash
PHP_OPCACHE_OPTS="-d opcache.enable_cli=1 -d opcache.jit=1255 -d opcache.jit_buffer_size=128M"
```
**Note**: Disable JIT for coverage (`-d opcache.jit=off`) as it's incompatible with Xdebug.
**Functional suites: run WITHOUT JIT.** Core-testing container PHP builds (seen on 8.3 and 8.5 images) with `opcache.jit=1255` can segfault **silently** during functional bootstrap — exit 139, no PHP error, dies between PHPUnit's "Configuration:" line and the first test, triggered by the *shape* of perfectly valid source (a plain property+getter on an Extbase entity flipped it). Functional suites are IO-bound, JIT gains nothing: use a separate `PHP_FUNCTIONAL_OPTS="-d opcache.enable_cli=1"` (no JIT) for functional/functionalParallel and keep JIT for phpstan/cgl/unit. Diagnosis pattern: fast probe via `runTests.sh -s functional -- --filter OneTestClass`, stash-bisect per candidate file, cross-check with `-d opcache.jit=off`.
## Permission Handling
### Linux --user Flag
On Linux, containers run as the host user to prevent root-owned files:
```bash
if [ $(uname) != "Darwin" ]; then
USERSET="--user $(id -u)"
fi
```
### Root-owned Files Detection
For E2E tests, detect and warn about root-owned node_modules:
```bash
if [ "$(find node_modules -maxdepth 1 -user root 2>/dev/null | head -1)" ]; then
echo "Error: node_modules contains root-owned files."
echo "Please remove: sudo rm -rf node_modules"
exit 1
fi
```
## Makefile Integration
Create a `Makefile` for convenient shortcuts:
```makefile
RUNTESTS = Build/Scripts/runTests.sh
.PHONY: test unit functional lint phpstan cs fix ci e2e
test: unit
unit:
$(RUNTESTS) -s unit
functional:
$(RUNTESTS) -s functional
functional-fast:
$(RUNTESTS) -s functionalParallel
e2e:
$(RUNTESTS) -s e2e
lint:
$(RUNTESTS) -s lint
phpstan:
$(RUNTESTS) -s phpstan
cs:
$(RUNTESTS) -s cgl -n
fix:
$(RUNTESTS) -s cgl
ci: lint cs phpstan unit functional
```
## CI/CD Integration
### GitHub Actions (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']
include:
- php: '8.4'
suite: 'functional'
database: 'mariadb'
steps:
- uses: actions/checkout@v4
- name: Run ${{ matrix.suite }} tests
run: |
Build/Scripts/runTests.sh \
-s ${{ matrix.suite }} \
-p ${{ matrix.php }} \
-d ${{ matrix.database }}
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: Build/Scripts/runTests.sh -s lint
- run: Build/Scripts/runTests.sh -s phpstan
- run: Build/Scripts/runTests.sh -s cgl -n
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ddev/github-action-setup-ddev@v1
- run: ddev start
- run: Build/Scripts/runTests.sh -s e2e
```
## Fast setup in a fresh worktree (rsync a known-good `.Build`)
A newly created worktree has no `.Build/` (it's gitignored), so `runTests.sh`
fails with `Could not open input file: .Build/bin/phpunit`. A fresh `composer
install` works but is slow, and on WSL2 a fresh resolution can segfault the
full-config PHPUnit run (flaky exit 139). Faster and stable: rsync a **known-good**
`.Build` from a sibling worktree, then regenerate the autoloader so the new
worktree's own classes are registered.
```bash
rsync -a --delete ../<sibling-worktree>/.Build/ ./.Build/
Build/Scripts/runTests.sh -s composer dump-autoload # register new PSR-4 classes
Build/Scripts/runTests.sh -s unit
```
Only reuse a `.Build` whose resolved dependency versions are compatible with this
branch (e.g. the same `nr-llm` minor). If they differ, the reused vendor can carry
a different API than your code targets — a symptom is a value object's constructor
requiring an argument your code/tests omit (signatures drift across minors).
Verify the constructor at the **resolved** version, not the library's `main`, and
run the static analyzer **after** the tests are written (test files are analyzed
too). CI remains authoritative — treat the rsynced `.Build` as a local fast path,
not a substitute for the CI matrix.
### A `.Build` also pins a PHP version, which can make one suite unrunnable
Compatibility is not only about library versions. `composer install` writes
`.Build/vendor/composer/platform_check.php` from the PHP it resolved under, and
that file **fatals** rather than warns when the runtime is older:
```
Fatal error: Uncaught RuntimeException: Composer detected issues in your platform:
Your Composer dependencies require a PHP version ">= 8.4.1". You are running 8.2.30.
```
That matters for any suite whose gate pins a *lower* PHP than the one the
`.Build` was resolved under. The usual case is Rector: its PHPUnit rule set
activates from the phpunit version composer installed, so the gate is pinned low
in CI (`rector-php-version: '8.2'`) — and in a worktree whose `.Build` came from
a PHP 8.4 resolve, `runTests.sh -s rector -n -p 8.2` cannot start at all. The
suite is not failing; it never ran.
Recognise it by the message: a `platform_check.php` fatal is an environment
mismatch, never a code finding. Then pick one:
- keep a second `.Build` resolved at the pinned version for that one gate, or
- accept CI as the only place that gate runs — and **say so** when reporting
which gates were run locally, rather than listing it as green.
Silently skipping it is the failure mode: the gate is then first evaluated in
CI, on a pushed branch, which costs a round-trip per finding.
## Troubleshooting
### TTY Errors
Script auto-detects non-TTY environments. If issues persist:
```bash
CI=true ./Build/Scripts/runTests.sh -s unit
```
### Database Connection Errors
```bash
# Check container is running
docker ps
# Use SQLite to rule out DB issues
./Build/Scripts/runTests.sh -s functional -d sqlite
```
### SQLite functional tests fail with "unable to open database file" (rootless / WSL2)
On **rootless Docker** or **WSL2** hosts, the SQLite functional run can fail with
`unable to open database file` even though the same command works on a standard
Docker-CE host. Cause: `runTests.sh` mounts the SQLite working directory as a `tmpfs`,
but the container process runs as a non-root user that has no write permission on the
default-mode tmpfs.
Fix: add `,mode=1777` (world-writable, sticky — like `/tmp`) to the SQLite `tmpfs`
mount option in `Build/Scripts/runTests.sh` (every occurrence):
```diff
- --tmpfs ${CORE_ROOT}/.Build/Web/typo3temp/var/tests/functional-sqlite-dbs/:rw,noexec,nosuid
+ --tmpfs ${CORE_ROOT}/.Build/Web/typo3temp/var/tests/functional-sqlite-dbs/:rw,noexec,nosuid,mode=1777
```
This is CI-safe (standard Docker hosts are unaffected) and unblocks local functional
testing on rootless/WSL2.
### Root-owned Files
```bash
# Remove root-owned files (requires sudo)
sudo rm -rf node_modules .Build
```
### Update Images
```bash
./Build/Scripts/runTests.sh -u
```
## Best Practices
1. **SQLite First**: Use SQLite for most functional tests (fastest)
2. **Parallel Tests**: Use `functionalParallel` for faster CI
3. **Matrix Testing**: Test all supported PHP versions in CI
4. **Group Incompatible Tests**: Use `#[Group('not-sqlite')]` for DB-specific tests
5. **Single Entry Point**: All tests via `runTests.sh`, not direct PHPUnit
6. **Makefile Shortcuts**: Provide `make test`, `make ci` for convenience
7. **Update Images**: Run `-u` periodically to get latest TYPO3 images
8. **Keep Versions Synced**: Playwright versions in package.json and runTests.sh
## Gotcha: `-s unit` Green ≠ Safe When You Touch a Shared Type
`./Build/Scripts/runTests.sh -s unit` covers **only** `Tests/Unit/`. When you
change a **widely-consumed** type — a shared value object, DTO, enum, or a
method on a public service interface — its consumers and their assertions live
in the **functional** suite (and integration/e2e), which `-s unit` never runs.
A green unit run then hides a real break, and it surfaces later in CI or a
reviewer's comment instead of on your machine. (Real case: a change to a
`ToolSpec` value object passed `-s unit` locally but broke a functional test
asserting the old shape — caught by CI + the PR bot, not the local unit run.)
Before pushing a change to a shared type:
```bash
# Find who depends on it, then run the suites that exercise them.
# Use -e per pattern (portable across GNU/BSD grep; escaped \| is not).
grep -rn -e 'YourValueObject' -e '->yourChangedMethod' Classes/ Tests/
./Build/Scripts/runTests.sh -s unit
./Build/Scripts/runTests.sh -s functional # the assertions on the old shape live here
```
If the extension's CI runs functional tests (it should — a functional job that
is silently skipped is its own bug), treat "did I run the same suites CI will?"
as the pre-push checklist, not "is unit green?".
## Resources
- [TYPO3 Tea Extension](https://github.com/TYPO3BestPractices/tea) - Reference implementation
- [TYPO3 Core Testing](https://github.com/typo3/typo3) - Core approach
- [typo3/core-testing images](https://github.com/typo3/core-testing) - Official images
- [nr-vault](https://github.com/netresearch/t3x-nr-vault) - Reference with all patterns
references/typo3-ci-config-patterns.md
# TYPO3 CI Configuration Patterns
## 1. ext_emconf.php and strict_types
- `ext_emconf.php` must NOT contain `declare(strict_types=1)` — TER cannot parse it
- PHP-CS-Fixer rule `declare_strict_types => true` must exclude ext_emconf.php
- Pattern: `->notPath('ext_emconf.php')` in Finder config
- The shared `typo3-ci-workflows` config already handles this
## 2. Shared PHP-CS-Fixer config factory
Use the shared factory from `netresearch/typo3-ci-workflows` in `Build/.php-cs-fixer.php`:
```php
<?php
declare(strict_types=1);
// Build/.php-cs-fixer.php
$createConfig = require __DIR__ . '/../.Build/vendor/netresearch/typo3-ci-workflows/config/php-cs-fixer/config.php';
return $createConfig(<<<'EOF'
Copyright header here
EOF, __DIR__ . '/..');
```
- Requires `"netresearch/typo3-ci-workflows": "^1.0"` in `require-dev`
- Benefits: centralized rules, ext_emconf.php exclusion, consistent formatting
- **TYPO3 12 compatibility**: `typo3-ci-workflows` may pull in dependencies for TYPO3 13, causing conflicts. For TYPO3 12 extensions, you may need to inline the config or ensure you use compatible dependency versions, such as `saschaegerer/phpstan-typo3: ^2.0`.
## 3. labeler.yml for TYPO3 extensions
Standard labeler config for PR auto-labeling:
```yaml
documentation:
- changed-files:
- any-glob-to-any-file: ['Documentation/**', '*.md']
configuration:
- changed-files:
- any-glob-to-any-file: ['Configuration/**', 'ext_emconf.php', 'composer.json']
tests:
- changed-files:
- any-glob-to-any-file: ['Tests/**', 'phpunit*.xml']
ci:
- changed-files:
- any-glob-to-any-file: ['.github/**']
```
## 4. Composer allow-plugins for CI dependencies
When using `typo3-ci-workflows`, fractor, or infection, add their installers to `allow-plugins`:
```json
{
"config": {
"allow-plugins": {
"a9f/fractor-extension-installer": true,
"infection/extension-installer": true,
"captainhook/hook-installer": true
}
}
}
```
## 5. TYPO3 v14.3 LTS CI matrix
TYPO3 v14.3 LTS (released 2026-04-21) is the current gold standard. Use
`typo3/testing-framework:^9.5` — a single branch that supports **both v13
and v14** cores. PHPUnit constraint: `^11.2.5 || ^12.1.2 || ^13.0.2`.
### Matrix example (GitHub Actions)
```yaml
strategy:
matrix:
include:
# TYPO3 14.3 LTS (default)
- { php: '8.2', typo3: '^14.3' }
- { php: '8.3', typo3: '^14.3' }
- { php: '8.4', typo3: '^14.3' }
- { php: '8.5', typo3: '^14.3' }
# TYPO3 13.4 LTS
- { php: '8.2', typo3: '^13.4' }
- { php: '8.3', typo3: '^13.4' }
- { php: '8.4', typo3: '^13.4' }
- { php: '8.5', typo3: '^13.4' }
# TYPO3 12.4 LTS (ELTS window approaching 2026-04-30)
- { php: '8.2', typo3: '^12.4' }
- { php: '8.3', typo3: '^12.4' }
```
### composer.json (v13 + v14 dual support)
```json
{
"require": {
"php": "^8.2",
"typo3/cms-core": "^13.4 || ^14.3"
},
"require-dev": {
"typo3/testing-framework": "^9.5",
"phpunit/phpunit": "^11.2.5 || ^12.1.2 || ^13.0.2"
}
}
```
### v14-specific testing notes
- **Fluid 5 strict-typing (#108148)**: ViewHelper test doubles now need
typed args + typed `render(): string` return. Untyped custom VHs
raise exceptions in v14 functional tests.
- **Cache interface strict-typing (#107315)**: test doubles for
`BackendInterface`/`FrontendInterface` must match the new typed
signatures.
- **FAL strict-typing (#106427)**: `AbstractFile::getIdentifier()` is
gone; test doubles for `File`/`Folder` must use concrete methods.
- **Extbase argument strict-typing (#107777)**: `Argument` now enforces
strict types; replace `setValue(mixed)` mocks.
- See `typo3-v14-final-classes.md` for the full list of v14 `final` classes
that cannot be mocked (use interface-based doubles instead).
## 6. PHPStan across the supported-version matrix
### Verify every supported TYPO3 version locally — not just the highest installed
A green PHPStan run on the highest installed TYPO3 version can still fail CI on a
lower one. Class existence and deprecation results differ per version, e.g.
`TYPO3\CMS\Backend\Template\Components\ComponentFactory` exists only on **v14+**,
so referencing it produces "unknown class" / "returns mixed" errors on v12/v13;
conversely the `make*` docheader API (`MenuRegistry::makeMenu()`,
`Menu::makeMenuItem()`, `ButtonBar::makeLinkButton()`) is deprecated on **v14**
but not on v12/v13. A single-version local run sees only one side.
Before pushing, re-resolve to each supported version and re-run PHPStan — no
composer.json edit needed, `--with` applies a temporary constraint:
```bash
for V in '^12.4' '^13.4' '^14.3'; do
composer update -W \
--with "typo3/cms-core:$V" --with "typo3/cms-backend:$V" --with "typo3/cms-setup:$V" \
--no-interaction
composer dump-autoload -o
composer ci:test:php:phpstan || echo "PHPStan FAILED on TYPO3 $V"
done
```
### Type-narrowing and core-stub differences: the v13 leg fails on code the v14 leg accepts
Version differences are not limited to class existence — the **resolved PHPStan
version and the core stubs differ per dependency set**, so identical code can
type-check on the `^14.x` leg and fail only on `^13.4`:
- **First-class-callable filters are narrowed inconsistently.**
`array_filter(array_keys($fieldArray), \is_string(...))` is inferred as
`list<string>` by the PHPStan the v14 set resolves, but stays `array` on the
v13 set — so a downstream `implode(', ', $columns)` fails with *"Parameter #2
\$array of function implode expects array<string>, array given"* **only in the
v13 matrix legs**. An explicit loop narrows identically everywhere:
```php
$columns = [];
foreach (array_keys($fieldArray) as $column) {
if (\is_string($column)) {
$columns[] = $column;
}
}
// list<string> on every PHPStan version in the matrix
```
- **Loosely-typed core properties differ between version stubs.**
`DataHandler::$errorLog` is plain `array` in the v13 core, so string
operations over it (`implode(', ', $dataHandler->errorLog)`) fail the v13
legs. In assertions, prefer forms that need no string coercion at all:
```php
$errorLog = $dataHandler->errorLog;
self::assertSame([], $errorLog); // prints the offending entries itself on failure
```
Diagnostic shortcut: when **only the `^13.4` PHPStan legs are red** while
`^14.x` and the local run are green, suspect a narrowing/stub difference before
suspecting the code — and reproduce with the `--with "typo3/cms-core:^13.4"`
loop above rather than pushing blind fixes.
### Inline `@phpstan-ignore` is rejected — use neon `ignoreErrors`
`ergebnis/phpstan-rules` (in the shared `typo3-ci-workflows` config) **bans inline
`@phpstan-ignore` / `@phpstan-ignore-next-line`** — CI fails with *"Errors reported
by phpstan/phpstan should not be ignored via @phpstan-ignore, fix the error or use
the baseline instead."* This rule is NOT active in a bare local `Build/phpstan.neon`
run, so it only surfaces in CI. Put suppressions in the neon `ignoreErrors` block
instead, scoped by `path` and kept SPECIFIC (not a blanket `#Call to deprecated
method#`). For cross-version cases set `reportUnmatched: false` so an entry that
only applies to one TYPO3 version does not error on the others:
```yaml
parameters:
reportUnmatchedIgnoredErrors: false
ignoreErrors:
# v14 only: the v12/v13 fallback docheader make* calls are deprecated there
- message: '#Call to deprecated method (makeMenu|makeMenuItem|makeLinkButton)\(\)#'
path: %currentWorkingDirectory%/Classes/Controller/MyModuleController.php
reportUnmatched: false
# v12/v13 only: ComponentFactory does not exist there
- message: '#TYPO3\\CMS\\Backend\\Template\\Components\\ComponentFactory#'
path: %currentWorkingDirectory%/Classes/Controller/MyModuleController.php
reportUnmatched: false
```
references/typo3-v14-final-classes.md
# Testing TYPO3 v14 Final Classes
TYPO3 v14 introduces many `final` and `readonly` classes that cannot be mocked directly. This guide covers patterns to maintain testability.
## The Problem
TYPO3 v14 follows modern PHP best practices with `final readonly` classes:
```php
// TYPO3 Core - cannot be mocked
final readonly class SiteConfigurationLoadedEvent
{
public function __construct(
private string $siteIdentifier,
private array $configuration,
) {}
}
```
Attempting to mock these classes throws:
```
PHPUnit\Framework\MockObject\Generator\ClassIsFinalException:
Class "TYPO3\CMS\Core\Configuration\Event\SiteConfigurationLoadedEvent" is declared "final" and cannot be mocked.
```
## Pattern 1: Interface Extraction for Dependencies
When your class depends on a final class that you control, extract an interface.
### Before (Untestable)
```php
// Your final class
final class SiteConfigurationVaultProcessor
{
public function processConfiguration(array $configuration): array { }
}
// Consumer - cannot mock the dependency
final readonly class SiteConfigurationVaultListener
{
public function __construct(
private SiteConfigurationVaultProcessor $processor, // Cannot mock!
) {}
}
```
### After (Testable)
**Step 1: Create Interface**
```php
<?php
declare(strict_types=1);
namespace Vendor\Extension\Configuration;
interface SiteConfigurationVaultProcessorInterface
{
/**
* @param array<string, mixed> $configuration
* @return array<string, mixed>
*/
public function processConfiguration(array $configuration): array;
}
```
**Step 2: Implement Interface**
```php
final class SiteConfigurationVaultProcessor implements SiteConfigurationVaultProcessorInterface
{
public function processConfiguration(array $configuration): array
{
// Implementation
}
}
```
**Step 3: Register in Services.yaml**
```yaml
services:
Vendor\Extension\Configuration\SiteConfigurationVaultProcessorInterface:
alias: Vendor\Extension\Configuration\SiteConfigurationVaultProcessor
public: true
```
**Step 4: Inject Interface**
```php
final readonly class SiteConfigurationVaultListener
{
public function __construct(
private SiteConfigurationVaultProcessorInterface $processor, // Mockable!
) {}
}
```
**Step 5: Mock Interface in Tests**
```php
final class SiteConfigurationVaultListenerTest extends UnitTestCase
{
private SiteConfigurationVaultProcessorInterface&MockObject $processor;
private SiteConfigurationVaultListener $listener;
protected function setUp(): void
{
parent::setUp();
$this->processor = $this->createMock(SiteConfigurationVaultProcessorInterface::class);
$this->listener = new SiteConfigurationVaultListener($this->processor);
}
#[Test]
public function processesConfigurationWithVaultReferences(): void
{
$originalConfig = ['apiKey' => '%vault(my_key)%'];
$processedConfig = ['apiKey' => 'resolved_secret'];
$this->processor
->expects($this->once())
->method('processConfiguration')
->with($originalConfig)
->willReturn($processedConfig);
// Test your listener...
}
}
```
## Pattern 2: Real Event Instances for Final Events
TYPO3 PSR-14 events are often final. Create real instances instead of mocks.
### Wrong - Will Fail
```php
#[Test]
public function handlesEvent(): void
{
// ClassIsFinalException!
$event = $this->createMock(SiteConfigurationLoadedEvent::class);
}
```
### Correct - Real Instance
```php
#[Test]
public function handlesEvent(): void
{
// Create real event - it's a simple value object
$config = ['apiKey' => '%vault(my_key)%'];
$event = new SiteConfigurationLoadedEvent('test-site', $config);
// Mock the dependency, not the event
$this->processor
->method('processConfiguration')
->willReturn(['apiKey' => 'resolved']);
($this->listener)($event);
self::assertSame(['apiKey' => 'resolved'], $event->getConfiguration());
}
```
### Complete Test Example
```php
<?php
declare(strict_types=1);
namespace Vendor\Extension\Tests\Unit\EventListener;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use TYPO3\CMS\Core\Configuration\Event\SiteConfigurationLoadedEvent;
use Vendor\Extension\Configuration\SiteConfigurationVaultProcessorInterface;
use Vendor\Extension\EventListener\SiteConfigurationVaultListener;
#[CoversClass(SiteConfigurationVaultListener::class)]
final class SiteConfigurationVaultListenerTest extends TestCase
{
private SiteConfigurationVaultProcessorInterface&MockObject $processor;
private SiteConfigurationVaultListener $listener;
protected function setUp(): void
{
parent::setUp();
$this->processor = $this->createMock(SiteConfigurationVaultProcessorInterface::class);
$this->listener = new SiteConfigurationVaultListener($this->processor);
}
#[Test]
public function skipsProcessingWhenNoVaultReferences(): void
{
$config = [
'base' => 'https://example.com',
'languages' => [],
];
// Real event instance - not mocked
$event = new SiteConfigurationLoadedEvent('test-site', $config);
$this->processor->expects($this->never())->method('processConfiguration');
($this->listener)($event);
self::assertSame($config, $event->getConfiguration());
}
#[Test]
public function processesConfigurationWithVaultReferences(): void
{
$originalConfig = ['apiKey' => '%vault(my_key)%'];
$processedConfig = ['apiKey' => 'resolved_secret'];
// Real event instance
$event = new SiteConfigurationLoadedEvent('test-site', $originalConfig);
$this->processor
->expects($this->once())
->method('processConfiguration')
->with($originalConfig)
->willReturn($processedConfig);
($this->listener)($event);
self::assertSame($processedConfig, $event->getConfiguration());
}
#[Test]
public function handlesEmptyConfiguration(): void
{
$config = [];
$event = new SiteConfigurationLoadedEvent('test-site', $config);
$this->processor->expects($this->never())->method('processConfiguration');
($this->listener)($event);
self::assertSame($config, $event->getConfiguration());
}
}
```
## Pattern 3: Test Suite Organization
Separate tests by bootstrap requirements to avoid skipped tests.
### Directory Structure
```
Tests/
├── Build/
│ ├── phpunit.xml # Unit + Fuzz (no TYPO3 bootstrap)
│ └── FunctionalTests.xml # Functional (requires TYPO3)
├── Unit/ # Fast, isolated, mockable
├── Functional/ # Database, framework integration
└── Fuzz/ # Property-based testing
```
### phpunit.xml (Unit Tests Only)
```xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/12.5/phpunit.xsd"
bootstrap="../bootstrap.php"
colors="true"
failOnRisky="true"
failOnWarning="true"
>
<testsuites>
<testsuite name="Unit">
<directory>../Unit</directory>
</testsuite>
<testsuite name="Fuzz">
<directory>../Fuzz</directory>
</testsuite>
<!-- Functional tests require TYPO3 bootstrap - run separately -->
</testsuites>
</phpunit>
```
### FunctionalTests.xml
```xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/12.5/phpunit.xsd"
bootstrap="FunctionalTestsBootstrap.php"
colors="true"
>
<testsuites>
<testsuite name="Functional">
<directory>../Functional</directory>
</testsuite>
</testsuites>
</phpunit>
```
### composer.json Scripts
```json
{
"scripts": {
"test:unit": "phpunit -c Tests/Build/phpunit.xml",
"test:functional": "phpunit -c Tests/Build/FunctionalTests.xml",
"test:all": ["@test:unit", "@test:functional"]
}
}
```
## Decision Tree: What to Test Where
```
Is the class under test final?
├── Yes → Can you create it directly (simple constructor)?
│ ├── Yes → Create real instance (Pattern 2)
│ └── No → Does it need framework services?
│ ├── Yes → Move to Functional tests
│ └── No → Extract interface for dependency (Pattern 1)
└── No → Mock normally with createMock()
```
## Common TYPO3 v14 Final Classes
| Class | Testing Strategy |
|-------|------------------|
| `SiteConfigurationLoadedEvent` | Create real instance |
| `AfterStdWrapFunctionsExecutedEvent` | Create real instance |
| `ModifyButtonBarEvent` | Create real instance |
| `FlexFormValueContainer` | Move to functional test |
| `DataHandler` (partial) | Mock via interface or functional test |
| `ModuleTemplateFactory` | `newInstanceWithoutConstructor()` + reflection |
| `ModuleTemplate` | `newInstanceWithoutConstructor()` + reflection |
## Pattern 4: ReflectionMethod for Backend Module Controllers
Backend module controllers depend on `ModuleTemplateFactory` and `ModuleTemplate` which are both `final`. When the controller has private methods with testable logic, use reflection:
```php
// Create controller with real (non-final) mocks + uninitialized final dep
$controller = new StatusController(
$this->createMock(DiagnosticService::class),
$this->createMock(BackendUriBuilder::class),
(new \ReflectionClass(ModuleTemplateFactory::class))
->newInstanceWithoutConstructor(),
);
// Test private method via reflection
$method = new \ReflectionMethod(StatusController::class, 'buildFixUrls');
$method->setAccessible(true); // Required for private methods
$result = $method->invoke($controller, $checks);
```
### BackendUriBuilder Return Type
`BackendUriBuilder::buildUriFromRoute()` returns `UriInterface` (not string). Mocks must return a proper Uri object:
```php
// WRONG — causes TypeError since buildUriFromRoute() returns UriInterface
$mock->method('buildUriFromRoute')->willReturn('/typo3/module/path');
// CORRECT
$mock->method('buildUriFromRoute')
->willReturn(new \TYPO3\CMS\Core\Http\Uri('/typo3/module/path'));
```
## Pattern 5: `dg/bypass-finals` for Your Own `final` Classes
Patterns 1-4 cover **TYPO3 framework** classes you cannot change. They do not help when **your own** production classes are `final` (enforced via phpat / architecture tests) and are constructed by factories you would otherwise want to mock.
For that case, use [`dg/bypass-finals`](https://github.com/dg/bypass-finals): it strips the `final` keyword **at PHPUnit runtime only**, leaving production bytecode untouched. Production code keeps the architectural guarantee; tests can `createMock()` your final classes.
### Setup
```bash
composer require --dev dg/bypass-finals
```
In `Tests/bootstrap.php` -- **before** any test class is autoloaded:
```php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../.Build/vendor/autoload.php';
\DG\BypassFinals::enable();
// ... your existing Environment::initialize(), LF, etc.
```
Wire the bootstrap into `phpunit.xml` (`bootstrap="Tests/bootstrap.php"`). Order matters: `BypassFinals::enable()` must run before any `final` class is loaded -- if PHPUnit autoloads a test that references `final class Foo` before the call, the rewrite is too late.
### When to Reach for It
- You have phpat finality rules enforcing `final` on production code (recommended, see `architecture-testing.md`).
- You need to `createMock()` a class you authored (DTOs, services, event listeners) without writing an interface for every one.
- The mocked class is **yours** -- bypassing finals on third-party classes (especially TYPO3 core) is brittle and re-introduces the upstream-change risk that `final` was meant to flag.
### When Not to Reach for It
- The class belongs to TYPO3 core or another vendor library -- use Patterns 1-4 instead. Bypassing finals on third-party code couples your tests to upstream internals.
- The dependency cleanly fits an interface -- extracting the interface (Pattern 1) keeps coupling explicit and works without `bypass-finals`.
### Verification
A quick sanity check that the rewrite is active:
```php
#[Test]
public function bypassFinalsIsEnabled(): void
{
self::assertFalse(
(new \ReflectionClass(\Vendor\Extension\Domain\Dto\SomeFinalDto::class))->isFinal(),
'BypassFinals is not enabled - check Tests/bootstrap.php load order.',
);
}
```
If this assertion fails, the bootstrap is not being loaded or `enable()` runs too late.
## Anti-Patterns to Avoid
### Don't Skip Tests
```php
// BAD - leaves gaps in coverage
#[Test]
public function testSomething(): void
{
$this->markTestSkipped('Cannot mock final class');
}
```
### Don't Use Reflection to Bypass Final
```php
// BAD - fragile and defeats the purpose
$reflection = new ReflectionClass(FinalClass::class);
// ... hack to make it non-final
```
> **Exception:** Using `newInstanceWithoutConstructor()` and `ReflectionMethod` is acceptable when testing controllers that depend on final TYPO3 framework classes where no interface exists. This is different from trying to make a class non-final — you're passing an uninitialized instance as a placeholder for a parameter you won't use.
### Don't Copy TYPO3 Classes
```php
// BAD - maintenance nightmare
namespace Vendor\Extension\Tests\Fixtures;
class SiteConfigurationLoadedEvent { } // Copy of TYPO3's class
```
## Cross-Version `readonly` Trap (13.4 vs 14) — a fixture subclass can fatal on one matrix leg
`readonly` is applied to different classes in different core versions. A class
that is a plain `class` on 13.4 becomes `readonly class` on 14 (and vice versa
for some). This breaks the common "subclass the concrete framework class and
override one method" test-double trick across a `^13.4 || ^14.3` matrix,
because PHP forbids a `readonly` child extending a non-`readonly` parent **and**
a non-`readonly` child extending a `readonly` parent:
```php
// Test double subclassing the concrete class:
final readonly class FakeRequestFactory extends RequestFactory { /* ... */ }
```
`TYPO3\CMS\Core\Http\RequestFactory` is `readonly` on 14.x but a plain `class`
on 13.4. So this fixture:
- compiles on 14.x, and
- **fatals at parse time on 13.4** —
`Readonly class ...FakeRequestFactory cannot extend non-readonly class ...RequestFactory`.
The failure is invisible locally if you only run one PHP/TYPO3 version; it only
appears on the 13.4 legs of the CI matrix (and it's a *fatal*, so PHPStan/unit
locally on 8.4/14 stay green). Two independent gotchas compound it: PHPUnit also
cannot mock a `readonly` class at all (same `ClassIsFinalException` family), so
"just mock it" is not an escape either.
**Fix — don't subclass the concrete class.** Introduce a one-method interface
your production code depends on, wrap the concrete factory in a tiny production
implementation, and have the test double implement the interface directly:
```php
interface HttpFetcherInterface { public function get(string $url): \Psr\Http\Message\ResponseInterface; }
final readonly class HttpFetcher implements HttpFetcherInterface { /* wraps RequestFactory */ }
// Test double: implements the interface, extends nothing version-dependent.
final class FakeHttpFetcher implements HttpFetcherInterface { /* records + replays */ }
```
The interface is version-neutral, so the double compiles on every matrix cell.
This is the same "Interface Extraction" pattern above, applied specifically
because the parent's `readonly`-ness is not stable across the supported versions.
## Summary
1. **Interface Extraction**: For dependencies you control that are final
2. **Real Instances**: For simple value objects like events
3. **Test Suite Separation**: Unit vs Functional based on requirements
4. **Reflection for Controllers**: Use `newInstanceWithoutConstructor()` for final framework deps in controllers
5. **`dg/bypass-finals`**: For your own `final` production classes that you want to mock without extracting an interface
6. **Zero Skipped Tests**: Every test should run - reorganize if needed
7. **Cross-version `readonly`**: Never subclass a concrete core class whose `readonly`-ness differs across the supported versions — a fixture subclass fatals on the leg where parent and child disagree; extract an interface instead
references/unit-testing.md
# 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).
## 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.
#### That property only exists on `UnitTestCase`
`$resetSingletonInstances` is a feature of the testing framework's `UnitTestCase`. A
class extending PHPUnit's own `TestCase` — common for pure unit tests with no TYPO3
bootstrap — inherits no such lever: setting the property does nothing, and every
`GeneralUtility::makeInstance()` call inside it takes and keeps the process-wide
instance.
The symptom is a test that **passes under `--filter` and fails in the full suite**,
usually with an error unrelated to the assertion under test:
```bash
vendor/bin/phpunit --filter MyTest # green
vendor/bin/phpunit # red — someone earlier changed shared state
```
When that happens, do not chase the failing assertion. Find what the class takes from
the global registry and give it its own:
```php
private const NOW = 1767225600; // 2026-01-01T00:00:00Z
// ❌ Shares whatever a previously-run test left in the singleton
$context = GeneralUtility::makeInstance(Context::class);
// ✅ Owned by this test class, and the same instant every run
$context = new Context();
$context->setAspect('date', new DateTimeAspect(new \DateTimeImmutable('@' . self::NOW)));
```
Pin the instant rather than reading `time()`: the clock can cross a second between
`setUp()` and the assertion, and a fixed value makes a failure reproducible instead of
"it passed on my machine". **The same constant has to drive the fixtures** — a context
frozen at a chosen instant while the test data is built from `time()` puts them years
apart and fails everything as expired:
```php
'exp' => \DateTimeImmutable::createFromFormat('U', (string)(self::NOW + 3600)),
```
`Context` is the usual culprit for time-dependent code, because assertions built
relative to `time()` fail the moment an earlier test pins the date aspect elsewhere —
and the resulting error ("token expired", "not yet valid", "record not found") points
at the subject rather than at the pollution. Verify the fix against the **whole**
suite, not the filtered run that was green all along.
### `setSingletonInstance()` vs `addInstance()` for `SingletonInterface`
`GeneralUtility::makeInstance()` honours two registries:
| API | Lifetime | Use for |
|-----|----------|---------|
| `GeneralUtility::addInstance($class, $obj)` | **Drains** -- one `makeInstance($class)` consumes the entry, the next call returns a fresh instance | Non-singleton dependencies, one-shot replacements |
| `GeneralUtility::setSingletonInstance($class, $obj)` | **Persists** -- every subsequent `makeInstance($class)` returns the same registered object until reset | Anything implementing `\TYPO3\CMS\Core\SingletonInterface` |
`PageRenderer`, `BackendUserAuthentication` and `LanguageService` all implement `SingletonInterface`. Registering them with `addInstance()` works for the first call inside the subject under test and silently breaks on the second:
```php
// WRONG -- second makeInstance(PageRenderer::class) returns a real PageRenderer
GeneralUtility::addInstance(PageRenderer::class, $pageRendererMock);
// CORRECT -- mock persists for the whole test
GeneralUtility::setSingletonInstance(PageRenderer::class, $pageRendererMock);
```
Pair this with `protected bool $resetSingletonInstances = true;` so the mock is cleared between tests.
## Time-Dependent Fixtures: Anchor at Local Midday
Pinning the instant (above) is not enough once the code under test groups its result by
calendar day. Two failure modes, both measured on one suite.
**Fixtures built from `time()`.** The grouping they assert holds for most hours of the
day, not all of them:
```php
// ❌ The pair is split for one hour in twenty-four
$currentTime = time();
$day1 = $currentTime + 3600; // these two are meant to share a calendar day
$day1b = $currentTime + 7200;
$day2 = $currentTime + 86400 + 3600; // this one falls on the next
```
Between 22:00 and 23:00 in the zone the code groups in, `+1h` is still on today and `+2h`
is not: the pair that has to share a day is split, and `+2h` joins `+25h` instead. Every
assertion about which entries belong together then fails for the hour of day rather than
for anything in the code. It failed on unmodified `main` while the developer's clock read
00:04, and turned 11 CI matrix cells red. The window is measured in the grouping zone, not
on the wall clock — 22:04 UTC is 00:04 CEST.
Counting the groups is no guard here. Three entries spanning 25 hours land on two calendar
days at every hour of the day, so `assertCount(2, $result)` stays green right through the
window while the entries inside those two days regroup.
**A fixed UTC anchor.** The obvious fix pins the instant but not the calendar day, because
the grouping happens in the ambient timezone. With
`new \DateTimeImmutable('2026-06-15 09:00:00', new \DateTimeZone('UTC'))` as the anchor:
| Ambient timezone | `+1h` | `+2h` |
|---|---|---|
| `UTC` | 2026-06-15 | 2026-06-15 |
| `Pacific/Apia` | 2026-06-15 | 2026-06-16 |
| `Pacific/Midway` | 2026-06-14 | 2026-06-15 |
In both Pacific zones the local midnight splits the pair that has to share a day. The group
count stays 2 there as well, so again only an assertion naming which entries share a day
catches it.
**Anchor at local midday instead**: a wall-clock string with *no* timezone argument, read
in whatever zone the suite runs in, which is the zone the code groups in.
```php
// ✅ Six hours of slack on either side of a day boundary, in every timezone
$anchor = new \DateTimeImmutable('2026-06-15 12:00:00');
$day1 = $anchor->modify('+1 hour');
$day1b = $anchor->modify('+2 hours');
$day2 = $anchor->modify('+1 day +1 hour');
```
The two constructor forms are not interchangeable: `'@<timestamp>'` is a UTC instant, a
bare `'Y-m-d H:i:s'` string is local wall-clock time. Assertions about a point in time
want the first, assertions about a calendar day the second.
### Sweeping such a test across timezones
`TZ=Pacific/Apia phpunit` proves nothing. PHP does not read the `TZ` environment variable;
it resolves `date_default_timezone_set()`, then the `date.timezone` ini, then `UTC`:
```bash
# -n -d pins the ini so the probe shows PHP ignoring TZ, not the local php.ini
TZ=Pacific/Apia php -n -d date.timezone=UTC -r 'echo date_default_timezone_get(), PHP_EOL;' # UTC
```
Set the ini instead, and read the **exit code** rather than grepping the output. With
colours forced, PHPUnit's summary line is `\033[30;42mOK (1 test, 1 assertion)\033[0m`, so
`grep -E '^OK'` finds nothing on a green run and reports the opposite of what happened.
Measured on PHPUnit 12.5.4: `--colors=always` does this even when the output is redirected,
while `colors="true"` in the XML alone (as `assets/UnitTests.xml` sets it) does not — which
is why the trap is invisible in the config.
```bash
# The five zones the local-midday anchor above was verified in
for tz in UTC Pacific/Apia Pacific/Midway Europe/Berlin Asia/Kathmandu; do
php -d date.timezone="$tz" .Build/bin/phpunit \
-c Build/phpunit/UnitTests.xml --filter TheTest >/dev/null 2>&1
echo "$tz: exit $?"
done
```
A bootstrap calling `date_default_timezone_set('UTC')` — including the
`assets/UnitTestsBootstrap.php` and `assets/FunctionalTestsBootstrap.php` templates in
this skill — overrides the ini, so a suite loading one of those can only be swept by
lifting that pin for the run. Such a pin also removes the second failure mode for that
suite. It does nothing about the first: 22:00 arrives in UTC like anywhere else.
## Never mock the class under test with `getAccessibleMock()`
`getAccessibleMock($className)` called **without a method list** doubles *every*
method of the class, the method under test included. Reaching it through
`_call()` therefore never runs your code — it runs the double, which returns
whatever PHPUnit generates for the declared return type (`null` for `?array` or
an untyped method, `[]` for `array`, `false` for `bool`, and so on) or whatever
the test configured. An assertion written around that value holds for every
possible implementation: the test asserts nothing and no mutation can redden it.
```php
// WRONG - parse() is doubled, so $result is the generated value, not your output
$subject = $this->getAccessibleMock(EmConfReader::class);
self::assertNull($subject->_call('parse', $code));
// RIGHT - instantiate and call
$subject = new EmConfReader();
self::assertNull($subject->parse($code));
```
Found in a production extension: three tests written the first way, all green,
all vacuous. On one of them the real call returned `['bar' => 'baz']` for an
input the test asserted `null` for — and that test was the only thing pinning a
behaviour which had therefore never been enforced at all.
`getAccessibleMock()` earns its place when you need to reach a `protected` member
or replace *one* collaborator call on the subject. Then pass the method list
explicitly, so the method under test is not among the doubles:
```php
$subject = $this->getAccessibleMock(MyService::class, ['fetchRemoteData']);
$subject->method('fetchRemoteData')->willReturn($fixture);
self::assertSame('expected', $subject->_call('transform', $input));
```
Note that PHPUnit does not double `static`, `final` or `private` methods at all,
so a subject built around static entry points cannot be partially stubbed this
way — extract an instance method first.
**How to notice:** the trap hides behind assertions that expect the same value
PHPUnit would generate anyway — `null`, `[]`, `false`. Add one case that expects
a real value; if it fails while the implementation plainly produces that value,
your code never ran. A test that no mutation can redden is disconnected, not
strict.
## 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.
>
> **Multi-version dependencies:** When mocking interfaces from dependencies with `^major1 || ^major2` constraints, verify mocked methods exist on the interface in all supported versions. See `mock-validity.md` for patterns including callback signature verification and adapter pattern testing.
## PHPUnit 12 Compatibility
PHPUnit 12 introduces stricter defaults. Follow these patterns to avoid notices and deprecations.
**Static assertions:** call assertions via `self::` (`self::assertSame(...)`), not `$this->assertSame(...)` — PHPUnit 12 treats the assertion methods as static, and the instance form is deprecated. Every example in this file uses `self::`.
### Mock vs Stub Discipline (PHPUnit 12+)
PHPUnit 12 reports notices when mock objects have no expectations configured. The correct fix is to use the right test double for the job.
**Rule:** Use `createStub()` when you only need return values. Use `createMock()` only when you need to verify method calls with `expects()`.
```php
// WRONG - creates a mock but sets no expectations (triggers PHPUnit notice)
$model = $this->createMock(Model::class);
$model->method('getName')->willReturn('test');
// CORRECT - use stub when no expectations needed
$model = $this->createStub(Model::class);
$model->method('getName')->willReturn('test');
// CORRECT - use mock when verifying calls
$logger = $this->createMock(LoggerInterface::class);
$logger->expects(self::once())->method('warning');
```
**Detection:** Run tests with `--display-phpunit-notices` flag. Any "No expectations were configured for the mock object" notice indicates a mock that should be a stub.
**Decision guide:**
| Scenario | Use | Method |
|----------|-----|--------|
| Only need return values (`->method()->willReturn()`) | `createStub()` | No expectations |
| Satisfying a type hint for DI | `createStub()` | No expectations |
| Verifying a method was called | `createMock()` | `->expects(self::once())` |
| Verifying call count or arguments | `createMock()` | `->expects()->with()` |
**Example with stubs and mocks in the same test class:**
```php
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\MockObject\Stub;
#[CoversClass(MyController::class)]
final class MyControllerTest extends UnitTestCase
{
private MyController $subject;
/** @var SomeDependency&Stub */
private SomeDependency $dependencyStub;
/** @var LoggerInterface&MockObject */
private LoggerInterface $loggerMock;
protected function setUp(): void
{
parent::setUp();
// Stub - only provides return values, no expectations
$this->dependencyStub = $this->createStub(SomeDependency::class);
$this->dependencyStub->method('getValue')->willReturn('test');
// Mock - will verify method calls in tests
/** @var LoggerInterface&MockObject $loggerMock */
$loggerMock = $this->createMock(LoggerInterface::class);
$this->loggerMock = $loggerMock;
$this->subject = new MyController($this->dependencyStub, $this->loggerMock);
}
#[Test]
public function processLogsWarningOnEmptyInput(): void
{
$this->loggerMock->expects(self::once())->method('warning');
$this->subject->process('');
}
}
```
> **Note:** Stubs created with `createStub()` do not need `MockObject` intersection types in PHPDoc. The `&MockObject` intersection is only for objects created with `createMock()`. If you want static analysis tools (PHPStan, Psalm) to understand calls like `method()` / `willReturn()` on a stub variable, you can add an explicit intersection with `Stub`, for example:
> `/** @var SomeDependency&\PHPUnit\Framework\MockObject\Stub $dependencyStub */`.
**Fallback - `#[AllowMockObjectsWithoutExpectations]`:**
> **Warning:** The `#[AllowMockObjectsWithoutExpectations]` attribute is only available in PHPUnit 12+. It does **not** exist in PHPUnit 11 (used in CI for PHP 8.2) and will cause a fatal error. Only use this fallback when the project runs PHPUnit 12 exclusively.
When migrating existing test classes with many mocks, you can temporarily suppress the notice with the class-level attribute instead of converting all mocks to stubs at once:
```php
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
#[AllowMockObjectsWithoutExpectations]
final class LegacyControllerTest extends UnitTestCase
{
// Existing mocks without expectations are allowed
// TODO: Migrate createMock() to createStub() where no expects() is used
}
```
This attribute should be treated as **technical debt** and removed once the test class is migrated to use `createStub()` properly.
### Deprecated Type Assertions
PHPUnit 12 deprecates generic `isType()` in favor of specific methods:
| Deprecated | Use Instead |
|------------|-------------|
| `$this->isType('string')` | `$this->isString()` |
| `$this->isType('int')` | `$this->isInt()` |
| `$this->isType('array')` | `$this->isArray()` |
| `$this->isType('bool')` | `$this->isBool()` |
| `$this->isType('float')` | `$this->isFloat()` |
| `$this->isType('null')` | `$this->isNull()` |
| `$this->isType('object')` | `$this->isInstanceOf(ClassName::class)` |
### Constructor Dependency Drift
When a class constructor gains new dependencies, all tests instantiating it will fail with `TypeError`. Use a factory method pattern to centralize instantiation:
```php
final class MyControllerTest extends TestCase
{
private MyController $subject;
private DependencyA&MockObject $depAMock;
private DependencyB&MockObject $depBMock;
private DependencyC&MockObject $depCMock; // Added later
protected function setUp(): void
{
parent::setUp();
$this->depAMock = $this->createMock(DependencyA::class);
$this->depBMock = $this->createMock(DependencyB::class);
$this->depCMock = $this->createMock(DependencyC::class);
// Single point of instantiation - update here when constructor changes
$this->subject = $this->createSubject();
}
/**
* Factory method - single place to update when dependencies change.
*/
private function createSubject(): MyController
{
return new MyController(
$this->depAMock,
$this->depBMock,
$this->depCMock, // Add new dependencies here
);
}
}
```
**Benefits:**
- One place to update when constructor signature changes
- Tests clearly show all dependencies
- Easy to create subject with custom mocks in specific tests
## Coverage Attribution with #[CoversClass]
When `beStrictAboutCoverageMetadata` is enabled (recommended), PHPUnit restricts coverage reporting to classes listed in `#[CoversClass]`. Code executed during a test but not listed in `#[CoversClass]` will not appear in the coverage report for that test.
If your test exercises DTOs or value objects indirectly (e.g., a service test creates DTO instances), add `#[CoversClass]` for ALL exercised classes:
```php
// DiagnosticServiceTest creates DiagnosticCheck and DiagnosticResult
// instances via DiagnosticService — list them all for coverage
#[CoversClass(DiagnosticService::class)]
#[CoversClass(DiagnosticCheck::class)]
#[CoversClass(DiagnosticResult::class)]
#[CoversClass(Severity::class)]
final class DiagnosticServiceTest extends TestCase
```
Without this, coverage tools (e.g., codecov) may report 0% for the DTOs even though they are fully exercised by the service test.
## 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>
```
### Coverage Exclusion Review
When `phpunit.xml` excludes directories from coverage (like `Domain/Model`), verify the exclusion is justified:
1. **Justified exclusions**: Truly trivial getters/setters, pure data containers (DTOs/Value Objects with no logic)
2. **Unjustified exclusions**: Models with business logic, validation, computed properties, or state transitions
3. **Cross-check with mutation testing**: The same exclusion in `infection.json5` / `infection.json.dist` should also be justified
**Audit command:**
```bash
# Check what's excluded from coverage
grep -A 5 '<exclude>' Build/phpunit.xml Build/phpunit/UnitTests.xml 2>/dev/null
# Check what's excluded from mutation testing
grep -A 10 '"excludePaths"' infection.json5 infection.json.dist 2>/dev/null
# Find models with non-trivial logic that might be wrongly excluded
grep -rn 'function [a-z].*(' Classes/Domain/Model/ | grep -v 'get\|set\|is\|has'
```
### `#[CoversClass]` pointing at an excluded class turns CI red
A coverage exclusion and a `#[CoversClass]` attribute are two independent lists, and PHPUnit compares them. Naming a class that `<source>` excludes makes PHPUnit emit **one warning per test in that class**:
```
Class Netresearch\Ext\Exception\FooException is not a valid target for code coverage
```
With `failOnWarning="true"` — the default in the shared TYPO3 CI config, and set in every Netresearch extension — those warnings are a **red build**, even though every assertion passed.
The trap is that the suite you reach for first cannot see it. Coverage targets are only validated when coverage is actually collected, so:
```bash
./Build/Scripts/runTests.sh -s unit # GREEN — no coverage driver, no target check
./Build/Scripts/runTests.sh -s unitCoverage # RED — 37 warnings, the real CI verdict
```
This bites hardest on the classes teams most often exclude *and* most often add narrow tests for: empty exception subclasses, interfaces, enums (PHPUnit 12 cannot attribute coverage to an enum at all), and thin backend controllers.
**When adding a test for a class you suspect is excluded, do one of:**
- Drop the `#[CoversClass]` attribute and rely on `#[CoversNothing]` or no attribute — correct when the class is genuinely trivial and the test exists to pin behaviour, not to claim coverage.
- Remove the class from `<exclude>` — correct when it turned out to carry logic worth covering.
Check before you push, rather than after CI tells you. Parse the XML — a
`grep` for `<directory>` also matches the `<testsuites>` entries and reports
every test directory as an exclusion:
```python
# phpunit.xml <exclude> vs. #[CoversClass] — prints offenders, silent when clean
import glob, re, xml.etree.ElementTree as ET
excluded = {
(node.text or '').strip().lstrip('./').replace('../', '')
for ex in ET.parse('Build/phpunit.xml').getroot().iter('exclude')
for node in ex
}
NS = '\\YourVendor\\YourExt\\' # adjust to the extension namespace
for path in glob.glob('Tests/**/*.php', recursive=True):
for m in re.finditer(r'#\[CoversClass\(\s*\\?([\w\\]+)::class', open(path).read()):
target = 'Classes/' + m.group(1).split(NS, 1)[-1].replace('\\', '/') + '.php'
if any(target == e or target.startswith(e.rstrip('/') + '/') for e in excluded):
print(f'{path}: covers excluded {m.group(1)}')
```
Handles both exclusion shapes — a whole directory (`Classes/Exception`) and a single `<file>` — since teams mix them.
Cost when skipped: a full CI matrix round-trip, red on every PHP cell, with a local `-s unit` run that stayed green throughout.
## Testing PHP Syntax Variants
When testing code that parses or analyzes PHP (like Extension Scanner matchers), test all syntax variants that PHP allows. Different syntaxes may be parsed differently.
### Dynamic Method Calls
PHP supports multiple forms of dynamic method calls:
```php
// DataProvider for testing dynamic call handling
public static function dynamicCallSyntaxDataProvider(): array
{
return [
// Standard dynamic method call - variable holds method name
'dynamic method call with variable' => [
'<?php
$methodName = "someMethod";
$object->$methodName();',
[], // no match expected, must not crash
],
// Expression-based dynamic call - expression evaluated for method name
'dynamic method call with expression' => [
'<?php
$object->{$this->getMethodName()}();',
[], // no match expected, must not crash
],
// Curly brace syntax with variable
'dynamic method call with curly brace variable' => [
'<?php
$object->{$methodName}();',
[], // no match expected, must not crash
],
];
}
```
**Why This Matters**: PhpParser represents these differently:
- `$obj->$var()` → `$node->name` is `PhpParser\Node\Expr\Variable`
- `$obj->{$expr}()` → `$node->name` is `PhpParser\Node\Expr\MethodCall` or other expression
- `$obj->method()` → `$node->name` is `PhpParser\Node\Identifier`
Code assuming `$node->name` is always an `Identifier` will crash on dynamic calls.
### Dynamic Function Calls
```php
'dynamic function call' => [
'<?php
$func = "myFunction";
$func();',
[],
],
'variable function with call_user_func' => [
'<?php
call_user_func($callback, $arg);',
[],
],
```
### Static Method Variants
```php
'dynamic static method call' => [
'<?php
$method = "staticMethod";
SomeClass::$method();',
[],
],
'variable class static call' => [
'<?php
$class = "SomeClass";
$class::staticMethod();',
[],
],
```
### Testing Pattern
Always include regression tests with clear comments:
```php
// Regression test for issue #108413: $object->$var() syntax must not crash
'no match for dynamic method call with variable' => [
[
'Foo->aMethod' => [
'numberOfMandatoryArguments' => 0,
'maximumNumberOfArguments' => 2,
'restFiles' => ['Foo-1.rst'],
],
],
'<?php
$methodName = "someMethod";
$someVar->$methodName();',
[], // no match, must not crash
],
```
## 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
```
### Uninitialised `Environment` / `NormalizedParams::createFromServerParams`
**Problem**: Code migrated from the deprecated `GeneralUtility::getIndpEnv()` to `NormalizedParams::createFromServerParams($_SERVER, $sysConf)` fails with a `TypeError` in unit tests:
```
TypeError: TYPO3\CMS\Core\Core\Environment::getCurrentScript():
Return value must be of type string, null returned
```
**Solution**: Call `Environment::initialize()` once in `Tests/bootstrap.php`. See [Test Environment Guards](test-environment-guards.md#initialise-environment-in-testsbootstrapphp) for the full bootstrap snippet and the matching defensive read pattern needed when `phpunit.xml` has `backupGlobals="true"`.
### 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)
## Exercising a PSR-15 Middleware Without an Instance
Sometimes the question is about a *third-party* middleware in a project you
cannot boot — the database dump is elsewhere, `composer install` cannot
authenticate against a paid package, the environment is simply not yours. The
answer is not to reason about the code and call it verified. A PSR-15 middleware
needs a request, a handler and whatever request attributes it reads; none of that
requires a TYPO3 instance.
Build a scratch project **outside the repository**, so its config cannot pick up
the project's own:
```json
{
"require": { "vendor/the-middleware": "^1.2", "typo3/cms-core": "~12.4" },
"config": {
"allow-plugins": { "typo3/cms-composer-installers": true, "typo3/class-alias-loader": true },
"policy": { "advisories": { "block": false } }
}
}
```
`policy.advisories.block` matters: Composer refuses every TYPO3 release under an
open advisory, which in a throwaway probe blocks the install outright. Acceptable
here and nowhere else.
Then call `process()` with the real configuration:
```php
$config = \Symfony\Component\Yaml\Yaml::parseFile(__DIR__ . '/site-config.yaml');
unset($config['baseVariants']); // resolving these needs the DI container
$site = new Site('my-site', 1, $config);
$handler = new class () implements RequestHandlerInterface {
public function handle(ServerRequestInterface $request): ResponseInterface
{
return (new Response())->withStatus(200);
}
};
$request = (new ServerRequest(new Uri('https://example.com/contact'), 'GET'))
->withAttribute('site', $site)
->withAttribute('routing', new PageArguments(13, '0', []));
$response = (new TheMiddleware())->process($request, $handler);
```
Copy the site configuration from the repository rather than writing a fixture —
the point is to test *your* configuration, and a hand-built one silently answers
a different question. Two limits to state in the report rather than paper over:
anything you strip (`baseVariants` above) is untested, and a middleware
registered after `page-resolver` cannot be shown here to be skipped for
unresolvable paths — that follows from the registration order, not from this
probe. Say which claims came from which.
## Test Patterns for TYPO3 Extensions
### Coverage Attributes: #[CoversClass] and #[CoversNothing]
Every test class MUST declare which production class it covers using `#[CoversClass]`. This is enforced when `beStrictAboutCoverageMetadata` is enabled in PHPUnit configuration.
```php
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
use Vendor\Extension\Domain\Model\Translation;
use Vendor\Extension\Domain\Repository\TranslationRepository;
#[CoversClass(TranslationRepository::class)]
#[UsesClass(Translation::class)]
final class TranslationRepositoryTest extends UnitTestCase
{
// ...
}
```
**`#[CoversNothing]`** is used for tests that do not cover application code -- for example, security-oriented tests that validate PHP/libxml behavior rather than extension logic:
```php
use PHPUnit\Framework\Attributes\CoversNothing;
#[CoversNothing]
final class XxeProtectionTest extends UnitTestCase
{
#[Test]
public function libxmlDisablesExternalEntityLoading(): void
{
// This tests PHP/libxml behavior, not application code
$previousValue = libxml_disable_entity_loader(true);
self::assertTrue($previousValue || true);
}
}
```
### #[UsesClass] for Domain Model Dependencies
When a test exercises domain models indirectly (e.g., a repository test creates model instances), declare them with `#[UsesClass]` to keep coverage reports accurate:
```php
#[CoversClass(TranslationService::class)]
#[UsesClass(Translation::class)]
#[UsesClass(Language::class)]
final class TranslationServiceTest extends UnitTestCase
{
// Translation and Language are used by TranslationService but not the
// primary subject under test — #[UsesClass] prevents coverage gaps
}
```
### Mocking All Repository Dependencies
Always mock repository dependencies with `$this->createMock()`. Repositories interact with the database and cannot function in unit tests:
```php
#[CoversClass(TranslationService::class)]
final class TranslationServiceTest extends UnitTestCase
{
private TranslationService $subject;
/** @var TranslationRepository&MockObject */
private TranslationRepository $translationRepositoryMock;
/** @var LanguageRepository&MockObject */
private LanguageRepository $languageRepositoryMock;
protected function setUp(): void
{
parent::setUp();
/** @var TranslationRepository&MockObject $translationRepositoryMock */
$translationRepositoryMock = $this->createMock(TranslationRepository::class);
/** @var LanguageRepository&MockObject $languageRepositoryMock */
$languageRepositoryMock = $this->createMock(LanguageRepository::class);
$this->translationRepositoryMock = $translationRepositoryMock;
$this->languageRepositoryMock = $languageRepositoryMock;
$this->subject = new TranslationService(
$this->translationRepositoryMock,
$this->languageRepositoryMock,
);
}
}
```
## 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)
- [Symfony Clock Component](https://symfony.com/doc/current/clock.html)
- [PSR-20 Clock Interface](https://www.php-fig.org/psr/psr-20/)
scripts/generate-test.sh
#!/usr/bin/env bash
#
# Generate TYPO3 test class
#
# Usage: ./generate-test.sh <type> <ClassName>
# Example: ./generate-test.sh unit EmailValidator
#
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Parse arguments
TEST_TYPE="$1"
CLASS_NAME="$2"
if [ -z "${TEST_TYPE}" ] || [ -z "${CLASS_NAME}" ]; then
echo "Usage: $0 <type> <ClassName>"
echo
echo "Types:"
echo " unit - Unit test (fast, no database)"
echo " functional - Functional test (with database)"
echo " acceptance - Acceptance test (browser-based)"
echo
echo "Example:"
echo " $0 unit EmailValidator"
echo " $0 functional ProductRepository"
echo " $0 acceptance LoginCest"
exit 1
fi
# Validate test type
case ${TEST_TYPE} in
unit|functional|acceptance)
;;
*)
echo -e "${RED}Error: Invalid test type '${TEST_TYPE}'${NC}"
echo "Valid types: unit, functional, acceptance"
exit 1
;;
esac
# Determine paths
PROJECT_DIR="$(pwd)"
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Check if Tests directory exists
if [ ! -d "${PROJECT_DIR}/Tests" ]; then
echo -e "${RED}Error: Tests directory not found${NC}"
echo "Run setup-testing.sh first"
exit 1
fi
# Set target directory based on test type
case ${TEST_TYPE} in
unit)
TEST_DIR="${PROJECT_DIR}/Tests/Unit"
TEST_SUFFIX="Test"
;;
functional)
TEST_DIR="${PROJECT_DIR}/Tests/Functional"
TEST_SUFFIX="Test"
;;
acceptance)
TEST_DIR="${PROJECT_DIR}/Tests/Acceptance"
TEST_SUFFIX="Cest"
;;
esac
# Extract namespace from composer.json
NAMESPACE=$(php -r '
$composer = json_decode(file_get_contents("composer.json"), true);
foreach ($composer["autoload"]["psr-4"] ?? [] as $ns => $path) {
if (strpos($path, "Classes") !== false) {
echo rtrim($ns, "\\");
break;
}
}
')
if [ -z "${NAMESPACE}" ]; then
echo -e "${RED}Error: Could not determine namespace from composer.json${NC}"
exit 1
fi
# Determine test file path
TEST_FILE="${TEST_DIR}/${CLASS_NAME}${TEST_SUFFIX}.php"
# Check if file already exists
if [ -f "${TEST_FILE}" ]; then
echo -e "${RED}Error: Test file already exists: ${TEST_FILE}${NC}"
exit 1
fi
# Create test file directory if needed
mkdir -p "$(dirname "${TEST_FILE}")"
echo -e "${GREEN}Generating ${TEST_TYPE} test for ${CLASS_NAME}...${NC}"
# Generate test class based on type
case ${TEST_TYPE} in
unit)
cat > "${TEST_FILE}" << EOF
<?php
declare(strict_types=1);
namespace ${NAMESPACE}\\Tests\\Unit;
use TYPO3\\TestingFramework\\Core\\Unit\\UnitTestCase;
use ${NAMESPACE}\\${CLASS_NAME};
/**
* Unit test for ${CLASS_NAME}
*/
final class ${CLASS_NAME}${TEST_SUFFIX} extends UnitTestCase
{
protected ${CLASS_NAME} \$subject;
protected function setUp(): void
{
parent::setUp();
\$this->subject = new ${CLASS_NAME}();
}
/**
* @test
*/
public function canBeInstantiated(): void
{
self::assertInstanceOf(${CLASS_NAME}::class, \$this->subject);
}
}
EOF
;;
functional)
cat > "${TEST_FILE}" << EOF
<?php
declare(strict_types=1);
namespace ${NAMESPACE}\\Tests\\Functional;
use TYPO3\\TestingFramework\\Core\\Functional\\FunctionalTestCase;
use ${NAMESPACE}\\${CLASS_NAME};
/**
* Functional test for ${CLASS_NAME}
*/
final class ${CLASS_NAME}${TEST_SUFFIX} extends FunctionalTestCase
{
protected ${CLASS_NAME} \$subject;
protected array \$testExtensionsToLoad = [
'typo3conf/ext/your_extension',
];
protected function setUp(): void
{
parent::setUp();
\$this->subject = \$this->get(${CLASS_NAME}::class);
}
/**
* @test
*/
public function canBeInstantiated(): void
{
self::assertInstanceOf(${CLASS_NAME}::class, \$this->subject);
}
}
EOF
# Create fixture file
FIXTURE_FILE="${PROJECT_DIR}/Tests/Functional/Fixtures/${CLASS_NAME}.csv"
if [ ! -f "${FIXTURE_FILE}" ]; then
echo "# Fixture for ${CLASS_NAME}${TEST_SUFFIX}" > "${FIXTURE_FILE}"
echo -e "${GREEN}✓ Created fixture: ${FIXTURE_FILE}${NC}"
fi
;;
acceptance)
cat > "${TEST_FILE}" << EOF
<?php
declare(strict_types=1);
namespace ${NAMESPACE}\\Tests\\Acceptance;
use ${NAMESPACE}\\Tests\\Acceptance\\AcceptanceTester;
/**
* Acceptance test for ${CLASS_NAME/Cest/} workflow
*/
final class ${CLASS_NAME}${TEST_SUFFIX}
{
public function _before(AcceptanceTester \$I): void
{
// Setup before each test
}
public function exampleTest(AcceptanceTester \$I): void
{
\$I->amOnPage('/');
\$I->see('Welcome');
}
}
EOF
;;
esac
echo -e "${GREEN}✓ Created: ${TEST_FILE}${NC}"
echo
echo "Run test:"
echo " vendor/bin/phpunit ${TEST_FILE}"
echo
echo "Or via composer:"
echo " composer ci:test:php:${TEST_TYPE}"
scripts/setup-testing.sh
#!/usr/bin/env bash
#
# Setup TYPO3 testing infrastructure
#
# This script initializes testing infrastructure for TYPO3 extensions:
# - Composer dependencies
# - PHPUnit configurations
# - Directory structure
# - Optional: Docker Compose for acceptance tests
#
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Script configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
PROJECT_DIR="$(pwd)"
WITH_ACCEPTANCE=false
# Parse arguments
while getopts ":a" opt; do
case ${opt} in
a)
WITH_ACCEPTANCE=true
;;
\?)
echo "Usage: $0 [-a]"
echo " -a Include acceptance testing setup (Docker Compose, Codeception)"
exit 1
;;
esac
done
echo -e "${GREEN}=== TYPO3 Testing Infrastructure Setup ===${NC}"
echo
# Check if composer.json exists
if [ ! -f "${PROJECT_DIR}/composer.json" ]; then
echo -e "${RED}Error: composer.json not found in current directory${NC}"
echo "Please run this script from your TYPO3 extension root directory"
exit 1
fi
# 1. Install testing framework dependencies
echo -e "${YELLOW}[1/6] Installing testing framework dependencies...${NC}"
if ! grep -q "typo3/testing-framework" "${PROJECT_DIR}/composer.json"; then
composer require --dev "typo3/testing-framework:^8.0 || ^9.0" --no-update
echo -e "${GREEN}✓ Added typo3/testing-framework${NC}"
else
echo -e "${GREEN}✓ typo3/testing-framework already present${NC}"
fi
# Install PHPUnit if not present
if ! grep -q "phpunit/phpunit" "${PROJECT_DIR}/composer.json"; then
composer require --dev "phpunit/phpunit:^10.5 || ^11.0" --no-update
echo -e "${GREEN}✓ Added phpunit/phpunit${NC}"
fi
composer update --no-progress
# 2. Create directory structure
echo -e "${YELLOW}[2/6] Creating directory structure...${NC}"
mkdir -p "${PROJECT_DIR}/Tests/Unit"
mkdir -p "${PROJECT_DIR}/Tests/Functional/Fixtures"
mkdir -p "${PROJECT_DIR}/Build/phpunit"
mkdir -p "${PROJECT_DIR}/Build/Scripts"
echo -e "${GREEN}✓ Directories created${NC}"
# 3. Copy PHPUnit configurations
echo -e "${YELLOW}[3/6] Installing PHPUnit configurations...${NC}"
if [ ! -f "${PROJECT_DIR}/Build/phpunit/UnitTests.xml" ]; then
cp "${SKILL_DIR}/assets/UnitTests.xml" "${PROJECT_DIR}/Build/phpunit/"
echo -e "${GREEN}✓ Created UnitTests.xml${NC}"
else
echo -e "${YELLOW}⚠ UnitTests.xml already exists (skipped)${NC}"
fi
if [ ! -f "${PROJECT_DIR}/Build/phpunit/FunctionalTests.xml" ]; then
cp "${SKILL_DIR}/assets/FunctionalTests.xml" "${PROJECT_DIR}/Build/phpunit/"
echo -e "${GREEN}✓ Created FunctionalTests.xml${NC}"
else
echo -e "${YELLOW}⚠ FunctionalTests.xml already exists (skipped)${NC}"
fi
if [ ! -f "${PROJECT_DIR}/Build/phpunit/FunctionalTestsBootstrap.php" ]; then
cp "${SKILL_DIR}/assets/FunctionalTestsBootstrap.php" "${PROJECT_DIR}/Build/phpunit/"
echo -e "${GREEN}✓ Created FunctionalTestsBootstrap.php${NC}"
else
echo -e "${YELLOW}⚠ FunctionalTestsBootstrap.php already exists (skipped)${NC}"
fi
# 4. Create AGENTS.md templates
echo -e "${YELLOW}[4/6] Creating AGENTS.md templates...${NC}"
for dir in "${PROJECT_DIR}/Tests/Unit" "${PROJECT_DIR}/Tests/Functional"; do
if [ ! -f "${dir}/AGENTS.md" ]; then
cp "${SKILL_DIR}/assets/AGENTS.md" "${dir}/"
echo -e "${GREEN}✓ Created ${dir}/AGENTS.md${NC}"
else
echo -e "${YELLOW}⚠ ${dir}/AGENTS.md already exists (skipped)${NC}"
fi
done
# 5. Setup composer scripts
echo -e "${YELLOW}[5/6] Adding composer test scripts...${NC}"
if ! grep -q "ci:test:php:unit" "${PROJECT_DIR}/composer.json"; then
echo -e "${GREEN}ℹ Add these scripts to your composer.json:${NC}"
cat << 'EOF'
"scripts": {
"ci:test": [
"@ci:test:php:lint",
"@ci:test:php:phpstan",
"@ci:test:php:unit",
"@ci:test:php:functional"
],
"ci:test:php:lint": "phplint",
"ci:test:php:phpstan": "phpstan analyze --configuration Build/phpstan.neon --no-progress",
"ci:test:php:unit": "phpunit -c Build/phpunit/UnitTests.xml",
"ci:test:php:functional": "phpunit -c Build/phpunit/FunctionalTests.xml"
}
EOF
else
echo -e "${GREEN}✓ Test scripts already configured${NC}"
fi
# 6. Setup acceptance testing if requested
if [ "${WITH_ACCEPTANCE}" = true ]; then
echo -e "${YELLOW}[6/6] Setting up acceptance testing...${NC}"
# Install Codeception
if ! grep -q "codeception/codeception" "${PROJECT_DIR}/composer.json"; then
composer require --dev codeception/codeception codeception/module-webdriver --no-update
composer update --no-progress
echo -e "${GREEN}✓ Installed Codeception${NC}"
fi
# Create acceptance test directory
mkdir -p "${PROJECT_DIR}/Tests/Acceptance"
# Copy Docker Compose and Codeception config
if [ ! -f "${PROJECT_DIR}/Build/docker-compose.yml" ]; then
cp "${SKILL_DIR}/assets/docker/docker-compose.yml" "${PROJECT_DIR}/Build/"
echo -e "${GREEN}✓ Created docker-compose.yml${NC}"
fi
if [ ! -f "${PROJECT_DIR}/codeception.yml" ]; then
cp "${SKILL_DIR}/assets/docker/codeception.yml" "${PROJECT_DIR}/"
echo -e "${GREEN}✓ Created codeception.yml${NC}"
fi
# Initialize Codeception
if [ ! -d "${PROJECT_DIR}/Tests/Acceptance/_support" ]; then
vendor/bin/codecept bootstrap
echo -e "${GREEN}✓ Initialized Codeception${NC}"
fi
else
echo -e "${YELLOW}[6/6] Skipping acceptance testing setup (use -a flag to include)${NC}"
fi
echo
echo -e "${GREEN}=== Setup Complete ===${NC}"
echo
echo "Next steps:"
echo "1. Generate your first test:"
echo " ${SKILL_DIR}/scripts/generate-test.sh unit MyClass"
echo
echo "2. Run tests:"
echo " composer ci:test:php:unit"
echo " composer ci:test:php:functional"
echo
echo "3. Add CI/CD workflow (optional):"
echo " cp ${SKILL_DIR}/assets/github-actions-tests.yml .github/workflows/tests.yml"
scripts/validate-setup.sh
#!/usr/bin/env bash
#
# Validate TYPO3 testing infrastructure setup
#
# Checks:
# - Required dependencies
# - PHPUnit configurations
# - Directory structure
# - Docker (for acceptance tests)
#
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
PROJECT_DIR="$(pwd)"
ERRORS=0
WARNINGS=0
echo -e "${GREEN}=== TYPO3 Testing Setup Validation ===${NC}"
echo
# Check composer.json
echo -e "${YELLOW}[1/5] Checking composer.json dependencies...${NC}"
if [ ! -f "${PROJECT_DIR}/composer.json" ]; then
echo -e "${RED}✗ composer.json not found${NC}"
((ERRORS++))
else
if grep -q "typo3/testing-framework" "${PROJECT_DIR}/composer.json"; then
echo -e "${GREEN}✓ typo3/testing-framework installed${NC}"
else
echo -e "${RED}✗ typo3/testing-framework missing${NC}"
((ERRORS++))
fi
if grep -q "phpunit/phpunit" "${PROJECT_DIR}/composer.json"; then
echo -e "${GREEN}✓ phpunit/phpunit installed${NC}"
else
echo -e "${RED}✗ phpunit/phpunit missing${NC}"
((ERRORS++))
fi
fi
# Check PHPUnit configurations
echo -e "${YELLOW}[2/5] Checking PHPUnit configurations...${NC}"
if [ -f "${PROJECT_DIR}/Build/phpunit/UnitTests.xml" ]; then
echo -e "${GREEN}✓ UnitTests.xml present${NC}"
else
echo -e "${RED}✗ UnitTests.xml missing${NC}"
((ERRORS++))
fi
if [ -f "${PROJECT_DIR}/Build/phpunit/FunctionalTests.xml" ]; then
echo -e "${GREEN}✓ FunctionalTests.xml present${NC}"
else
echo -e "${RED}✗ FunctionalTests.xml missing${NC}"
((ERRORS++))
fi
if [ -f "${PROJECT_DIR}/Build/phpunit/FunctionalTestsBootstrap.php" ]; then
echo -e "${GREEN}✓ FunctionalTestsBootstrap.php present${NC}"
else
echo -e "${RED}✗ FunctionalTestsBootstrap.php missing${NC}"
((ERRORS++))
fi
# Check directory structure
echo -e "${YELLOW}[3/5] Checking directory structure...${NC}"
for dir in "Tests/Unit" "Tests/Functional" "Tests/Functional/Fixtures"; do
if [ -d "${PROJECT_DIR}/${dir}" ]; then
echo -e "${GREEN}✓ ${dir}/ exists${NC}"
else
echo -e "${YELLOW}⚠ ${dir}/ missing${NC}"
((WARNINGS++))
fi
done
# Check AGENTS.md files
echo -e "${YELLOW}[4/5] Checking AGENTS.md documentation...${NC}"
for dir in "Tests/Unit" "Tests/Functional"; do
if [ -f "${PROJECT_DIR}/${dir}/AGENTS.md" ]; then
echo -e "${GREEN}✓ ${dir}/AGENTS.md present${NC}"
else
echo -e "${YELLOW}⚠ ${dir}/AGENTS.md missing${NC}"
((WARNINGS++))
fi
done
# Check Docker (optional, for acceptance tests)
echo -e "${YELLOW}[5/5] Checking Docker availability (for acceptance tests)...${NC}"
if command -v docker &> /dev/null; then
echo -e "${GREEN}✓ Docker installed${NC}"
if docker ps &> /dev/null; then
echo -e "${GREEN}✓ Docker daemon running${NC}"
else
echo -e "${YELLOW}⚠ Docker daemon not running${NC}"
((WARNINGS++))
fi
else
echo -e "${YELLOW}⚠ Docker not installed (required for acceptance tests)${NC}"
((WARNINGS++))
fi
# Summary
echo
echo -e "${GREEN}=== Validation Summary ===${NC}"
if [ ${ERRORS} -eq 0 ] && [ ${WARNINGS} -eq 0 ]; then
echo -e "${GREEN}✓ All checks passed!${NC}"
echo
echo "Your testing infrastructure is ready to use."
echo "Generate your first test:"
echo " ~/.claude/skills/typo3-testing/scripts/generate-test.sh unit MyClass"
exit 0
elif [ ${ERRORS} -eq 0 ]; then
echo -e "${YELLOW}⚠ ${WARNINGS} warnings found${NC}"
echo
echo "Basic setup is complete, but some optional components are missing."
exit 0
else
echo -e "${RED}✗ ${ERRORS} errors found${NC}"
if [ ${WARNINGS} -gt 0 ]; then
echo -e "${YELLOW}⚠ ${WARNINGS} warnings found${NC}"
fi
echo
echo "Run setup script to fix errors:"
echo " ~/.claude/skills/typo3-testing/scripts/setup-testing.sh"
exit 1
fi
SKILL.md
---
name: typo3-testing
description: "Use when setting up TYPO3 extension test infrastructure, writing unit/functional/E2E tests, configuring PHPUnit 11/12/13, mutation testing, mocking final classes (v14), CI/CD matrix across TYPO3 12/13/14.3 LTS, dev-dependency consolidation via typo3-ci-workflows meta-package, or debugging CI failures. Also triggers on: testing-framework setup, ensure proper testing, test matrix, integration testing, e2e testing, coverage, test generation."
---
# TYPO3 Testing Skill
## Assessment-First Rule
**When enhancing an existing test suite** (not from scratch), run FIRST:
```bash
automated-assessment typo3-testing
```
> Install `typo3-conformance` and `enterprise-readiness` for broader coverage.
Generates a gap report from 73+ checkpoints (PHPUnit, PHPStan, runTests.sh, architecture, mutation, CI matrix, coverage).
**Use the report as the task list.** Resolve mechanical failures before manual test writing.
### Applies
- "enhance/improve/strengthen tests", "increase coverage/mutation"
- "enterprise grade", "A+ testing"
### Does NOT Apply
- From scratch, writing a specific test, debugging a failure
---
## Test Type Selection
| Type | Use When | Speed |
|------|----------|-------|
| **Unit** | Pure logic, no DB, validators, utilities | Fast |
| **Functional** | DB interactions, repositories, controllers | Medium |
| **Architecture** | Layer constraints, dependency rules (phpat) | Fast |
| **E2E (Playwright)** | User workflows, browser, accessibility | Slow |
| **Integration** | HTTP client, API mocking, OAuth flows | Medium |
| **Mutation** | Test quality, 70%+ coverage | CI/Release |
## runTests.sh - Mandatory
`Build/Scripts/runTests.sh` is mandatory: executable, with `-s` (suite) and `-p` (PHP version).
## Git Hooks
Netresearch default: `Build/captainhook.json` (declared in composer.json `extra.captainhook.config`). Verify: `ls Build/captainhook.json .git/hooks/pre-commit` (see `references/captainhook-setup.md`).
## Commands
```bash
# Setup (from skill dir)
scripts/setup-testing.sh [--with-e2e]
scripts/validate-setup.sh
scripts/generate-test.sh <Type> <Class>
# Run (always via runTests.sh)
Build/Scripts/runTests.sh -s unit|functional|phpstan|cgl|mutation|ci
```
Verify tests fail before fix, pass after. Bug fixes use the strict TDD loop in `references/tdd-discipline.md` — no "tested/verified" claims without pasted output.
## Scoring Requirements
Unit tests required (70%+ coverage). Functional tests required for DB operations. **phpat required** for architecture points. PHPStan level 10.
## References (in `references/`, `.md` implied)
`unit-testing.md` | `functional-testing.md` | `functional-test-patterns.md` | `integration-testing.md` | `e2e-testing.md` | `accessibility-testing.md` | `ddev-testing.md` | `test-runners.md` | `architecture-testing.md` | `ci-debugging.md` | `ci-cd.md` | `quality-tools.md` | `mutation-testing.md` | `fuzz-testing.md` | `performance-testing.md` | `typo3-v14-final-classes.md` | `mock-validity.md` | `javascript-testing.md` | `captainhook-setup.md` | `enforcement-rules.md` | `event-dispatch-testing.md` | `crypto-testing.md` | `test-environment-guards.md` | `sonarcloud.md` | `typo3-ci-config-patterns.md` | `tdd-discipline.md` | `ci-workflows-meta-package.md` | `synthetic-secret-fixtures.md` | `release-workflow-validation.md` | `asset-templates-guide.md` | `backend-module-render-verification.md` | `backend-user-access-testing.md` | `framework-compat-gate.md`
### Content Triggers
- CI failures across TYPO3 versions → `ci-debugging.md`
- Functional tests with TSFE context → `functional-testing.md`
- Mock failures across dependency versions → `mock-validity.md`
- Image/extension tests, `Environment::initialize`, `NormalizedParams` TypeError, `backupGlobals` → `test-environment-guards.md`
- Event dispatcher testing with try/catch → `event-dispatch-testing.md`
- Meta-package, typo3-ci-workflows, no-plugins → `ci-workflows-meta-package.md`
- Fake secrets, push-protection, cs-fixer concat → `synthetic-secret-fixtures.md`
- Burned tag, validate before tagging → `release-workflow-validation.md`
- Backend module 500 / wrong ViewHelper namespace / runaway canvas → `backend-module-render-verification.md`
- Non-admin BE-user access enforcement → `backend-user-access-testing.md`
- Package will not install next to TYPO3 → `framework-compat-gate.md`
## Links
[TYPO3 Testing Docs](https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/Testing/) |
[Tea Extension](https://github.com/TYPO3BestPractices/tea) |
[phpat](https://github.com/carlosas/phpat) |
[Infection](https://infection.github.io/)