TYPO3

Functional tests for an Extbase repository with CSV fixtures

5 min read

Repository tests with CSV

Hi guys,

Many TYPO3 extensions I open have no tests at all. When I ask why, the answer is nearly always the same: “the testing framework looks heavy”. I understand. The first time I saw typo3/testing-framework, I was also like a snail, reading bootstrap files instead of writing a test.

The truth is that one functional test for a repository needs four small files. This post shows them for an extension that runs on TYPO3 13.4 and 14.3, and how to run the suite inside DDEV.

The thing we want to test

Imagine an events extension with a custom repository method. It returns the next upcoming events, sorted by start date, and ignores the storage page because the plugin shows events from everywhere:

<?php

declare(strict_types=1);

namespace MyVendorMyEventsDomainRepository;

use MyVendorMyEventsDomainModelEvent;
use TYPO3CMSExtbasePersistenceQueryInterface;
use TYPO3CMSExtbasePersistenceQueryResultInterface;
use TYPO3CMSExtbasePersistenceRepository;

/**
 * @extends Repository<Event>
 */
final class EventRepository extends Repository
{
    /**
     * @return QueryResultInterface<int, Event>
     */
    public function findUpcoming(DateTimeInterface $now, int $limit = 3): QueryResultInterface
    {
        $query = $this->createQuery();
        $query->getQuerySettings()->setRespectStoragePage(false);

        return $query
            ->matching($query->greaterThanOrEqual('startDate', $now))
            ->setOrderings(['startDate' => QueryInterface::ORDER_ASCENDING])
            ->setLimit($limit)
            ->execute();
    }
}

The table is tx_myevents_domain_model_event with title and start_date (a datetime field stored as integer timestamp). A query like this is exactly where bugs hide: wrong operator, wrong sort direction, a forgotten storage page setting. A unit test cannot catch them, because the real work happens in the database.

Why not a unit test with mocks?

You could mock the query object and assert that greaterThanOrEqual() was called with startDate. That test would pass, and it would prove almost nothing. It tests that the code was written the way it was written. It does not test that Extbase converts the date to the right timestamp, that the column is mapped correctly, or that the sorting does what you think. A functional test boots a real TYPO3 instance with a real database, imports rows, and runs the real query. When it is green, the feature works.

The price is speed: a functional test case needs a second or two to set up its instance. For repository code that is a good deal.

Where the files go

For the example the layout inside the extension looks like this. Fixtures holds the data to import, Assertions the expected database state after a write:

Tests/Functional/Domain/Repository/
├── EventRepositoryTest.php
├── Fixtures/
│   └── Events.csv
└── Assertions/
    └── EventAdded.csv

Step 1: Install the testing framework

Version 9 of the framework supports both TYPO3 13 and 14, with PHPUnit 11 or 12:

ddev composer require --dev "typo3/testing-framework:^9"
mkdir -p Build/phpunit
cp vendor/typo3/testing-framework/Resources/Core/Build/FunctionalTests.xml Build/phpunit/
cp vendor/typo3/testing-framework/Resources/Core/Build/FunctionalTestsBootstrap.php Build/phpunit/

The framework itself says to copy these two files instead of using them from vendor/. In the copied XML, change the test suite directory to your extension, for example ../../Tests/Functional/.

Step 2: The CSV fixture

CSV data sets are the easiest way to describe database rows. The first line names the table, the next line the columns (with a leading comma), then one line per record. One file can hold several tables.

"pages"
,"uid","pid","title"
,1,0,"Root"
,5,1,"Events"
"tx_myevents_domain_model_event"
,"uid","pid","title","start_date","hidden","deleted"
,1,5,"TYPO3 meetup",1924984800,0,0
,2,5,"Old barcamp",1577872800,0,0
,3,5,"Code sprint",1893448800,0,0

Record 2 lies in the past and must not appear. Records 3 and 1 lie in the future, and 3 comes first. I like to use timestamps far away from today, so the test does not break next year.

Step 3: The test case

<?php

declare(strict_types=1);

namespace MyVendorMyEventsTestsFunctionalDomainRepository;

use MyVendorMyEventsDomainModelEvent;
use MyVendorMyEventsDomainRepositoryEventRepository;
use PHPUnitFrameworkAttributesTest;
use TYPO3CMSCoreCoreSystemEnvironmentBuilder;
use TYPO3CMSCoreHttpServerRequest;
use TYPO3CMSExtbaseConfigurationConfigurationManagerInterface;
use TYPO3CMSExtbasePersistenceGenericPersistenceManager;
use TYPO3TestingFrameworkCoreFunctionalFunctionalTestCase;

final class EventRepositoryTest extends FunctionalTestCase
{
    protected array $testExtensionsToLoad = [
        'myvendor/my-events',
    ];

    private EventRepository $subject;

    protected function setUp(): void
    {
        parent::setUp();
        // Extbase reads its configuration from a request; give it a minimal one
        $request = (new ServerRequest())
            ->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE);
        $this->get(ConfigurationManagerInterface::class)->setRequest($request);

        $this->importCSVDataSet(__DIR__ . '/Fixtures/Events.csv');
        $this->subject = $this->get(EventRepository::class);
    }

    #[Test]
    public function findUpcomingReturnsFutureEventsSortedByStartDate(): void
    {
        $now = new DateTimeImmutable('@1735689600');

        $titles = array_map(
            static fn(Event $event): string => $event->getTitle(),
            $this->subject->findUpcoming($now)->toArray()
        );

        self::assertSame(['Code sprint', 'TYPO3 meetup'], $titles);
    }

    #[Test]
    public function addedEventIsWrittenToTheDatabase(): void
    {
        $event = new Event();
        $event->setPid(5);
        $event->setTitle('Release party');
        $event->setStartDate(new DateTimeImmutable('@1956520800'));

        $this->subject->add($event);
        $this->get(PersistenceManager::class)->persistAll();

        $this->assertCSVDataSet(__DIR__ . '/Assertions/EventAdded.csv');
    }
}

A few things are worth explaining:

  • $testExtensionsToLoad accepts the Composer package name when the extension is required in the root composer.json. Its TCA and database schema are set up in a fresh test instance.
  • $this->get() fetches services from the container, including non-public ones, so you test the real repository with real dependencies.
  • The request in setUp() is the same pattern the core uses in its own Extbase tests. Without it, the configuration manager has no context to work with.
  • setPid(5) keeps the new record on the events folder instead of depending on a storage pid from TypoScript, which the test instance does not have.

Step 4: Assert the database state

assertCSVDataSet() compares only the columns you list. That keeps the assertion readable:

"tx_myevents_domain_model_event"
,"uid","pid","title","start_date"
,1,5,"TYPO3 meetup",1924984800
,2,5,"Old barcamp",1577872800
,3,5,"Code sprint",1893448800
,4,5,"Release party",1956520800

If a row is missing or a value differs, PHPUnit prints a table with the difference. Much better than a var_dump in a controller.

Step 5: Run it in DDEV

The framework reads the database connection from environment variables. The quickest start is SQLite, if the pdo_sqlite extension is available in your web container:

ddev exec typo3DatabaseDriver=pdo_sqlite 
    vendor/bin/phpunit -c Build/phpunit/FunctionalTests.xml

Before a release I also run against MariaDB, because that is what production uses. Each test case creates its own database with a suffix, so the user needs the right to create databases. In DDEV the root user can do that:

ddev exec typo3DatabaseDriver=mysqli typo3DatabaseHost=db 
    typo3DatabaseUsername=root typo3DatabasePassword=root typo3DatabaseName=func 
    vendor/bin/phpunit -c Build/phpunit/FunctionalTests.xml

Put both commands into Composer scripts and you will never type them again.

Problems you will probably meet

  • Empty results with no error. Extbase respects the storage page by default. Forget that, and the query quietly returns nothing. A test shows it at once. Hence the explicit setRespectStoragePage(false).
  • Tests that depend on today. Passing $now into the method instead of calling new DateTime() inside makes it testable. Small design change, big win.
  • Column names in CSV. They are database column names (start_date), not Extbase property names (startDate). Mixing them up gives a confusing failure.

Once the first test is green, the second one costs five minutes. That is the whole trick: get the first one running. Cheers!

Further reading

Leave a comment

Your email address will not be published.