Loading...
Loading...
TYPO3 extension testing (unit, functional, E2E, architecture, mutation). Use when setting up test infrastructure, writing tests, or configuring CI/CD.
npx skill4agent add dirnbauer/webconsulting-skills typo3-testingTYPO3 API First: Always use TYPO3's built-in APIs, core features, and established conventions before creating custom implementations. Do not reinvent what TYPO3 already provides. Always verify that the APIs and methods you use exist and are not deprecated in your target TYPO3 version (v13 or v14) by checking the official TYPO3 documentation.
| Type | Use When | Speed | Framework |
|---|---|---|---|
| Unit | Pure logic, validators, utilities | Fast (ms) | PHPUnit |
| Functional | DB interactions, repositories | Medium (s) | PHPUnit + TYPO3 |
| Architecture | Layer constraints, dependencies | Fast (ms) | PHPat |
| E2E | User workflows, browser | Slow (s-min) | Playwright |
| Mutation | Test quality verification | CI only | Infection |
Tests/
├── Functional/
│ ├── Controller/
│ ├── Repository/
│ └── Fixtures/
├── Unit/
│ ├── Service/
│ └── Validator/
├── Architecture/
│ └── ArchitectureTest.php
└── E2E/
└── playwright/<?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/typo3/testing-framework/Resources/Core/Build/UnitTestsBootstrap.php"
colors="true"
cacheResult="false">
<testsuites>
<testsuite name="Unit">
<directory>Tests/Unit</directory>
</testsuite>
<testsuite name="Functional">
<directory>Tests/Functional</directory>
</testsuite>
<testsuite name="Architecture">
<directory>Tests/Architecture</directory>
</testsuite>
</testsuites>
<coverage>
<report>
<clover outputFile="var/log/coverage.xml"/>
<html outputDirectory="var/log/coverage"/>
</report>
</coverage>
<source>
<include>
<directory>Classes</directory>
</include>
<exclude>
<directory>Classes/Domain/Model</directory>
</exclude>
</source>
</phpunit><!-- FunctionalTests.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/typo3/testing-framework/Resources/Core/Build/FunctionalTestsBootstrap.php"
colors="true">
<testsuites>
<testsuite name="Functional">
<directory>Tests/Functional</directory>
</testsuite>
</testsuites>
</phpunit><?php
declare(strict_types=1);
namespace Vendor\MyExtension\Tests\Unit\Service;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Vendor\MyExtension\Service\PriceCalculator;
final class PriceCalculatorTest extends TestCase
{
private PriceCalculator $subject;
protected function setUp(): void
{
parent::setUp();
$this->subject = new PriceCalculator();
}
#[Test]
public function calculateNetPriceReturnsCorrectValue(): void
{
$grossPrice = 119.00;
$taxRate = 19.0;
$netPrice = $this->subject->calculateNetPrice($grossPrice, $taxRate);
self::assertEqualsWithDelta(100.00, $netPrice, 0.01);
}
#[Test]
public function calculateNetPriceThrowsExceptionForNegativePrice(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionCode(1234567890);
$this->subject->calculateNetPrice(-10.00, 19.0);
}
}<?php
declare(strict_types=1);
namespace Vendor\MyExtension\Tests\Unit\Service;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Vendor\MyExtension\Service\ItemService;
use Vendor\MyExtension\Domain\Repository\ItemRepository;
final class ItemServiceTest extends TestCase
{
private ItemRepository&MockObject $itemRepositoryMock;
private LoggerInterface&MockObject $loggerMock;
private ItemService $subject;
protected function setUp(): void
{
parent::setUp();
$this->itemRepositoryMock = $this->createMock(ItemRepository::class);
$this->loggerMock = $this->createMock(LoggerInterface::class);
$this->subject = new ItemService(
$this->itemRepositoryMock,
$this->loggerMock,
);
}
#[Test]
public function findActiveItemsReturnsFilteredItems(): void
{
$items = [/* mock items */];
$this->itemRepositoryMock
->expects(self::once())
->method('findByActive')
->with(true)
->willReturn($items);
$result = $this->subject->findActiveItems();
self::assertSame($items, $result);
}
}<?php
declare(strict_types=1);
namespace Vendor\MyExtension\Tests\Functional\Repository;
use PHPUnit\Framework\Attributes\Test;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
use Vendor\MyExtension\Domain\Repository\ItemRepository;
final class ItemRepositoryTest extends FunctionalTestCase
{
protected array $testExtensionsToLoad = [
'typo3conf/ext/my_extension',
];
private ItemRepository $subject;
protected function setUp(): void
{
parent::setUp();
$this->importCSVDataSet(__DIR__ . '/Fixtures/Items.csv');
$this->subject = $this->get(ItemRepository::class);
}
#[Test]
public function findByUidReturnsCorrectItem(): void
{
$item = $this->subject->findByUid(1);
self::assertNotNull($item);
self::assertSame('Test Item', $item->getTitle());
}
#[Test]
public function findAllReturnsAllItems(): void
{
$items = $this->subject->findAll();
self::assertCount(3, $items);
}
}# Tests/Functional/Repository/Fixtures/Items.csv
"tx_myext_items"
,"uid","pid","title","active","deleted"
,1,1,"Test Item",1,0
,2,1,"Another Item",1,0
,3,1,"Inactive Item",0,0<?php
declare(strict_types=1);
namespace Vendor\MyExtension\Tests\Functional\Controller;
use PHPUnit\Framework\Attributes\Test;
use TYPO3\CMS\Core\Http\ServerRequest;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
use Vendor\MyExtension\Controller\ItemController;
final class ItemControllerTest extends FunctionalTestCase
{
protected array $testExtensionsToLoad = [
'typo3conf/ext/my_extension',
];
#[Test]
public function listActionReturnsHtmlResponse(): void
{
$this->importCSVDataSet(__DIR__ . '/Fixtures/Pages.csv');
$this->importCSVDataSet(__DIR__ . '/Fixtures/Items.csv');
$request = new ServerRequest('https://example.com/items', 'GET');
$controller = $this->get(ItemController::class);
$response = $controller->listAction();
self::assertSame(200, $response->getStatusCode());
self::assertStringContainsString('text/html', $response->getHeaderLine('Content-Type'));
}
}composer require --dev phpat/phpat<?php
declare(strict_types=1);
namespace Vendor\MyExtension\Tests\Architecture;
use PHPat\Selector\Selector;
use PHPat\Test\Builder\Rule;
use PHPat\Test\PHPat;
final class ArchitectureTest
{
public function testDomainModelsShouldNotDependOnInfrastructure(): Rule
{
return PHPat::rule()
->classes(Selector::inNamespace('Vendor\MyExtension\Domain\Model'))
->shouldNotDependOn()
->classes(
Selector::inNamespace('Vendor\MyExtension\Controller'),
Selector::inNamespace('Vendor\MyExtension\Infrastructure'),
);
}
public function testServicesShouldNotDependOnControllers(): Rule
{
return PHPat::rule()
->classes(Selector::inNamespace('Vendor\MyExtension\Service'))
->shouldNotDependOn()
->classes(Selector::inNamespace('Vendor\MyExtension\Controller'));
}
public function testRepositoriesShouldImplementInterface(): Rule
{
return PHPat::rule()
->classes(Selector::classname('/.*Repository$/', true))
->excluding(Selector::classname('/.*Interface$/', true))
->shouldImplement()
->classes(Selector::classname('/.*RepositoryInterface$/', true));
}
public function testOnlyServicesCanAccessRepositories(): Rule
{
return PHPat::rule()
->classes(Selector::inNamespace('Vendor\MyExtension\Domain\Repository'))
->canOnlyBeAccessedBy()
->classes(
Selector::inNamespace('Vendor\MyExtension\Service'),
Selector::inNamespace('Vendor\MyExtension\Tests'),
);
}
}# phpstan.neon
includes:
- vendor/phpat/phpat/extension.neon
parameters:
level: 9
paths:
- Classes
- Tests# Install Playwright
npm init playwright@latest
# Configure for TYPO3
mkdir -p Tests/E2E/playwright// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './Tests/E2E',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: process.env.BASE_URL || 'https://my-extension.ddev.site',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});// Tests/E2E/item-list.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Item List', () => {
test('displays items correctly', async ({ page }) => {
await page.goto('/items');
await expect(page.locator('h1')).toContainText('Items');
await expect(page.locator('.item-card')).toHaveCount(3);
});
test('filters items by category', async ({ page }) => {
await page.goto('/items');
await page.selectOption('[data-testid="category-filter"]', 'electronics');
await expect(page.locator('.item-card')).toHaveCount(1);
});
test('creates new item', async ({ page }) => {
await page.goto('/items/new');
await page.fill('[name="title"]', 'New Test Item');
await page.fill('[name="description"]', 'Test description');
await page.click('[type="submit"]');
await expect(page).toHaveURL(/\/items\/\d+/);
await expect(page.locator('h1')).toContainText('New Test Item');
});
});composer require --dev infection/infection// infection.json5
{
"$schema": "vendor/infection/infection/resources/schema.json",
"source": {
"directories": ["Classes"],
"excludes": ["Domain/Model"]
},
"logs": {
"text": "var/log/infection.log",
"html": "var/log/infection.html"
},
"mutators": {
"@default": true
},
"minMsi": 70,
"minCoveredMsi": 80
}vendor/bin/infection --threads=4# Unit tests
vendor/bin/phpunit -c Tests/UnitTests.xml
# Functional tests
vendor/bin/phpunit -c Tests/FunctionalTests.xml
# Architecture tests
vendor/bin/phpstan analyse
# All tests with coverage
vendor/bin/phpunit --coverage-html var/log/coverage
# E2E tests
npx playwright test
# Mutation tests
vendor/bin/infection# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: xdebug
- run: composer install
- run: vendor/bin/phpunit -c Tests/UnitTests.xml --coverage-clover coverage.xml
functional:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: test
ports:
- 3306:3306
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- run: composer install
- run: vendor/bin/phpunit -c Tests/FunctionalTests.xml
architecture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- run: composer install
- run: vendor/bin/phpstan analyse| Criterion | Requirement |
|---|---|
| Unit tests | Required, 70%+ coverage |
| Functional tests | Required for DB operations |
| Architecture tests | PHPat required for full conformance |
| PHPStan | Level 9+ (level 10 recommended) |
| E2E tests | Optional, bonus points |
| Mutation | 70%+ MSI for bonus points |