
Typo3 Testing
- 43 installs
- 4 repo stars
- Updated August 4, 2026
- netresearch/typo3-testing-skill
Helps with testing & qa tasks.
About
typo3-testing is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted coding.
- typo3-testing
- Testing & QA
- AI-coding skill
Typo3 Testing by the numbers
- 43 all-time installs (skills.sh)
- +2 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,253 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/typo3-testing-skill --skill typo3-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 43 |
|---|---|
| repo stars | ★ 4 |
| Last updated | August 4, 2026 |
| Repository | netresearch/typo3-testing-skill ↗ |
What it does
Helps with testing & qa tasks.
Files
TYPO3 Testing Skill
Assessment-First Rule
When enhancing an existing test suite (not from scratch), run FIRST:
automated-assessment typo3-testingInstall other skills (e.g.typo3-conformance,enterprise-readiness) for broader coverage.
Generates a gap report from 73+ checkpoints (PHPUnit, PHPStan, runTests.sh, CaptainHook, 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", "fix all findings"
Does NOT Apply
- From scratch, writing a specific test, debugging a failure
---
References for TYPO3 extension testing.
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 verification, 70%+ coverage | CI/Release |
runTests.sh - Mandatory
Build/Scripts/runTests.sh is mandatory. Must be executable, support -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 2>/dev/null (see references/captainhook-setup.md).
Commands
# 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|ciVerify 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
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,NormalizedParamsTypeError,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
Links
<?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);
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)
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 booleansself::assertSame()for strict equalityself::assertInstanceOf()for type checksself::assertCount()for arrays/collections
Fixtures (Functional Tests Only)
$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)
use Prophecy\PhpUnit\ProphecyTrait;
$repository = $this->prophesize(UserRepository::class);
$repository->findByEmail('test@example.com')->willReturn($user);Page Objects (E2E Tests Only)
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
# 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.tsFixtures 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:installto install browsers - Check
playwright.config.tsbaseURL matches your environment - Use
npx playwright test --debugfor 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
- Functional Testing Guide
- E2E Testing Guide
- Accessibility Testing Guide
- TYPO3 Testing Documentation
- Playwright Documentation
<?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);
}
22.18
{
"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"
}
}
/**
* 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',
},
},
],
});
/**
* 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();
});
});
/**
* 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',
},
};
/**
* 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();
});
});
/**
* 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 };
/**
* 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' });
});
#!/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="10.11"
if ! [[ ${DBMS_VERSION} =~ ^(10.5|10.6|10.11|11.0|11.4)$ ]]; then
echo "Invalid combination -d ${DBMS} -i ${DBMS_VERSION}" >&2
exit 1
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: 10.11, 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"
# 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_OPCACHE_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_OPCACHE_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
# 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"
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
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
<?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');
}
}
<?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());
}
}
<?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'],
];
}
}
"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
"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
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.phpImport fixtures in your test:
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:
$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:
"uid","pid","title","custom_field","tstamp","crdate"
1,0,"Record 1","value1",1700000000,1700000000
2,0,"Record 2","value2",1700000000,1700000000Ensure the table is imported in your extension's ext_tables.sql.
"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
"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
<?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>
<?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');
});
# 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@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
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@v4
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@v4
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@v4
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@v4
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.
# =============================================================================
# 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@v4
- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
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@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
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@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
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@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
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@v4
- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
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@v5
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@v4
- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
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@v4
#
# - name: Setup PHP
# uses: shivammathur/setup-php@v2
# 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@v4
#
# - name: Setup PHP
# uses: shivammathur/setup-php@v2
# 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@v4
# with:
# name: mutation-report
# path: .Build/infection/
# retention-days: 7
{
// 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
}
# 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
# 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
<?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');
}
}
# 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: []
# 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>}'
<?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,
]);
};
<?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>
<?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);
}
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
Requirements
// 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 accessibilityBasic Accessibility Test
// 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
// 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
const results = await new AxeBuilder({ page })
.include('#main-content') // Only scan this element
.exclude('.third-party-widget') // Skip this element
.analyze();Specific Rules
// 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)
// 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
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:
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:
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
Architecture Testing with phpat
PHP Architecture Tester (phpat) enforces architectural rules through automated tests.
Installation
composer require --dev carlosas/phpatConfiguration
Create phpat.php in project root:
<?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
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
public function testServicesHaveInterface(): Rule
{
return $this->newRule
->classesThat(Selector::haveClassName('*Service'))
->excludingClassesThat(Selector::haveClassName('*Interface'))
->mustImplement()
->classesThat(Selector::haveClassName('*Interface'))
->build();
}Running Tests
# Via PHPUnit
vendor/bin/phpunit --testsuite Architecture
# Via runTests.sh
Build/Scripts/runTests.sh -s architecturePHPUnit Configuration
Add to phpunit.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
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 detectionassets/UnitTestsBootstrap.php- Unit test bootstrap with optional TYPO3 stub autoloaderassets/FunctionalTestsBootstrap.php- Functional test bootstrap for TYPO3 testing framework
PHPUnit Configuration
To configure PHPUnit, copy and customize:
assets/UnitTests.xml- Unit test suite configurationassets/FunctionalTests.xml- Functional test suite configuration
Code Quality Tools
To set up static analysis and code style, use:
assets/phpstan.neon- PHPStan level 10 configurationassets/phpstan-baseline.neon- Baseline template for legacy code migrationassets/phpat.php- Architecture test rules for layer enforcementassets/phpat.neon- PHPat PHPStan extension configurationassets/.php-cs-fixer.dist.php- PHP-CS-Fixer code style rulesassets/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 dependenciesplaywright.config.ts- Playwright configurationtests/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 configurationcodeception.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 assertionsExampleFunctionalTest.php- Functional test with fixturesExampleAcceptanceCest.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 hashespages.csv- Page tree structurett_content.csv- Content elementssys_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.
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 — two complementary layers
1. StandaloneView (functional, no browser)
For ViewHelper-level correctness, render the template through StandaloneView in a functional test (see functional-testing.md). This catches namespace registration, argument, and output errors without a browser and runs in CI.
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. 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):
# 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 heightTake the verification screenshot at ≥1440px viewport (narrow viewports hide sidebar/column overflow). The screenshot doubles as documentation evidence.
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
StandaloneViewfunctional test renders each custom template/partial
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:
{
"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
{
"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
# CaptainHook installs automatically with Composer
composer install
# Verify hooks are installed
ls -la .git/hooks/pre-commitMigrating from root captainhook.json
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 pathTroubleshooting
- 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-workflowsREADME for the worktree workaround.
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:
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.,
$GLOBALSpollution)
4. Get detailed errors per version:
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 -20Common Pitfalls
$GLOBALS['TYPO3_REQUEST'] Pollution
Setting $GLOBALS['TYPO3_REQUEST'] in setUp() affects ALL tests in the class:
- v14: Requires
applicationTypeattribute — missing it causesRuntimeExceptionin PageRenderer/DI container resolution (63+ errors) - v13: Enables additional processing paths — existing test assertions may no longer match (7+ failures)
Fix: Set the global only in specific test methods that need it, with try/finally cleanup:
$GLOBALS['TYPO3_REQUEST'] = $this->request
->withAttribute('applicationType', ApplicationType::FRONTEND);
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.
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:
// ❌ 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:
# Build/phpstan/phpstan.neon
parameters:
ignoreErrors:
-
message: '#Call to an undefined static method .+::(assert|fail|mark)#'
reportUnmatched: falsereportUnmatched: 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:
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/:
// 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:
// 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.
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:
composer require --dev netresearch/typo3-ci-workflowsWhat 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:
// 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:
composer install --no-pluginsThis 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:
composer-install-local:
composer install --no-pluginsBuild/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:
# 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 neonDo 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:
# .github/workflows/ci.yml
jobs:
ci:
uses: netresearch/typo3-ci-workflows/.github/workflows/extension-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.