Skip to content

Commit ce2c366

Browse files
bmacksbuerk
authored andcommitted
[FEATURE] Add CSV dataset export for functional tests
Introduce exportCSVDataSet() to write current database state to CSV files matching the existing fixture format. This completes the import → assert → export workflow for functional tests. Usage example: $this->exportCSVDataSet( $this->instancePath . '/export.csv', ['pages' => ['uid', 'pid', 'title'], 'tt_content'] ); Tables can specify explicit fields or export all columns. Additionally, assertCSVDataSet() now auto-exports the actual database state to a _actual.csv file when assertions fail and the TYPO3_TESTING_EXPORT_DATASETS environment variable is set.
1 parent dbd0357 commit ce2c366

4 files changed

Lines changed: 381 additions & 1 deletion

File tree

Build/phpunit/UnitTests.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
<?xml version="1.0"?>
22
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3-
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.2/phpunit.xsd"
3+
xsi:noNamespaceSchemaLocation="./.Build/vendor/phpunit/phpunit/phpunit.xsd"
44
cacheDirectory=".phpunit.cache"
55
cacheResult="false"
66
colors="true"
77
displayDetailsOnTestsThatTriggerDeprecations="true"
88
displayDetailsOnTestsThatTriggerErrors="true"
99
displayDetailsOnTestsThatTriggerNotices="true"
1010
displayDetailsOnTestsThatTriggerWarnings="true"
11+
displayDetailsOnPhpunitDeprecations="true"
1112
failOnDeprecation="true"
1213
failOnNotice="true"
1314
failOnRisky="true"

Classes/Core/Functional/FunctionalTestCase.php

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@
1717
* The TYPO3 project - inspiring people to share!
1818
*/
1919

20+
use Doctrine\DBAL\Types\BooleanType;
21+
use Doctrine\DBAL\Types\DateTimeImmutableType;
22+
use Doctrine\DBAL\Types\DateTimeType;
23+
use Doctrine\DBAL\Types\DateType;
24+
use Doctrine\DBAL\Types\EnumType;
25+
use Doctrine\DBAL\Types\JsonType;
26+
use Doctrine\DBAL\Types\StringType;
27+
use Doctrine\DBAL\Types\TextType;
28+
use Doctrine\DBAL\Types\TimeImmutableType;
29+
use Doctrine\DBAL\Types\TimeType;
30+
use Doctrine\DBAL\Types\Type;
2031
use PHPUnit\Framework\ExpectationFailedException;
2132
use Psr\Container\ContainerInterface;
2233
use Psr\Http\Message\ResponseInterface;
@@ -29,6 +40,10 @@
2940
use TYPO3\CMS\Core\Core\Environment;
3041
use TYPO3\CMS\Core\Database\Connection;
3142
use TYPO3\CMS\Core\Database\ConnectionPool;
43+
use TYPO3\CMS\Core\Database\Schema\Types\DateTimeType as Typo3DateTimeType;
44+
use TYPO3\CMS\Core\Database\Schema\Types\DateType as Typo3DateType;
45+
use TYPO3\CMS\Core\Database\Schema\Types\SetType as Typo3SetType;
46+
use TYPO3\CMS\Core\Database\Schema\Types\TimeType as Typo3TimeType;
3247
use TYPO3\CMS\Core\Http\NormalizedParams;
3348
use TYPO3\CMS\Core\Http\ServerRequest;
3449
use TYPO3\CMS\Core\Http\Stream;
@@ -715,10 +730,135 @@ protected function assertCSVDataSet(string $fileName): void
715730
}
716731

717732
if (!empty($failMessages)) {
733+
if ((bool)((int)getenv('TYPO3_TESTING_EXPORT_DATASETS_ON_FAILED_ASSERTION'))) {
734+
$exportTables = [];
735+
foreach ($dataSet->getTableNames() as $tableName) {
736+
$exportTables[$tableName] = $dataSet->getFields($tableName) ?? [];
737+
}
738+
$exportPath = preg_replace('/\.csv$/', '_actual.csv', $fileName);
739+
$this->exportCSVDataSet($exportPath, $exportTables);
740+
$failMessages[] = 'Actual database state exported to: ' . $exportPath;
741+
}
718742
self::fail(implode(LF, $failMessages));
719743
}
720744
}
721745

746+
/**
747+
* Export current database state of given tables to a CSV file.
748+
*
749+
* The output format matches the CSV fixture format used by importCSVDataSet()
750+
* and assertCSVDataSet(), so exported files can be used directly as test fixtures.
751+
*
752+
* @param string $path Absolute path for the output CSV file
753+
* @param array $tables Tables to export. Supports two styles:
754+
* - Simple list: ['pages', 'tt_content'] exports all columns
755+
* - With field spec: ['pages' => ['uid', 'pid', 'title']] exports only listed columns
756+
* - Mixed: both styles in one array
757+
*/
758+
protected function exportCSVDataSet(string $path, array $tables): void
759+
{
760+
$targetDirectory = dirname($path);
761+
if (!is_dir($targetDirectory)) {
762+
throw new \RuntimeException(
763+
'Target directory "' . $targetDirectory . '" does not exist.',
764+
1732006381
765+
);
766+
}
767+
768+
// Normalize $tables into ['tableName' => ['field1', ...] | []] structure
769+
$normalizedTables = [];
770+
foreach ($tables as $key => $value) {
771+
if (is_int($key)) {
772+
// Simple list style: ['pages', 'tt_content']
773+
$normalizedTables[$value] = [];
774+
} else {
775+
// With field spec: ['pages' => ['uid', 'pid', 'title']]
776+
$normalizedTables[$key] = $value;
777+
}
778+
}
779+
780+
$output = '';
781+
$firstTable = true;
782+
foreach ($normalizedTables as $tableName => $fields) {
783+
$connection = $this->getConnectionPool()->getConnectionForTable($tableName);
784+
$tableColumns = [];
785+
foreach ($connection->createSchemaManager()->listTableColumns($tableName) as $column) {
786+
$tableColumns[$column->getName()] = $column;
787+
}
788+
$queryBuilder = $connection->createQueryBuilder();
789+
$queryBuilder->getRestrictions()->removeAll();
790+
$statement = $queryBuilder->select('*')->from($tableName)->executeQuery();
791+
$records = $statement->fetchAllAssociative();
792+
793+
// Determine fields to export
794+
if ($fields === []) {
795+
if ($records !== []) {
796+
$fields = array_keys($records[0]);
797+
} else {
798+
// Empty table with no field spec: use schema introspection
799+
$fields = array_keys($tableColumns);
800+
}
801+
}
802+
803+
// Sort records by uid if available, otherwise by hash
804+
if (in_array('uid', $fields, true)) {
805+
usort($records, static fn(array $a, array $b) => $a['uid'] <=> $b['uid']);
806+
} elseif (in_array('hash', $fields, true)) {
807+
usort($records, static fn(array $a, array $b) => $a['hash'] <=> $b['hash']);
808+
}
809+
810+
if (!$firstTable) {
811+
$output .= LF;
812+
}
813+
$firstTable = false;
814+
815+
// Table name line with trailing commas
816+
$output .= '"' . $tableName . '",' . LF;
817+
// Field names line with leading comma
818+
$output .= ',' . implode(',', $fields) . LF;
819+
// Data rows
820+
foreach ($records as $record) {
821+
$values = [];
822+
foreach ($fields as $field) {
823+
$values[] = $this->formatCsvValue($record[$field] ?? null, ($tableColumns[$field] ?? null)?->getType());
824+
}
825+
$output .= ',' . implode(',', $values) . LF;
826+
}
827+
}
828+
829+
file_put_contents($path, $output);
830+
}
831+
832+
/**
833+
* Format a single value for CSV export.
834+
*
835+
* Handles NULL values, quoting of special characters, and plain string casting.
836+
*/
837+
protected function formatCsvValue(mixed $value, ?Type $columnDoctrineType = null): string
838+
{
839+
return match (true) {
840+
// Simply escape NULL value
841+
$value === null => '\\NULL',
842+
// TYPO3 custom types
843+
$columnDoctrineType instanceof Typo3DateTimeType,
844+
$columnDoctrineType instanceof Typo3DateType,
845+
$columnDoctrineType instanceof Typo3TimeType,
846+
$columnDoctrineType instanceof Typo3SetType,
847+
// Native Doctrine DBAL types
848+
$columnDoctrineType instanceof EnumType,
849+
$columnDoctrineType instanceof StringType,
850+
$columnDoctrineType instanceof TextType,
851+
$columnDoctrineType instanceof JsonType,
852+
$columnDoctrineType instanceof DateTimeType,
853+
$columnDoctrineType instanceof DateTimeImmutableType,
854+
$columnDoctrineType instanceof DateType,
855+
$columnDoctrineType instanceof TimeType,
856+
$columnDoctrineType instanceof TimeImmutableType => '"' . str_replace('"', '""', (string)$value) . '"',
857+
$columnDoctrineType instanceof BooleanType => (string)(int)($value),
858+
default => (string)$value,
859+
};
860+
}
861+
722862
/**
723863
* Check if $expectedRecord is present in $actualRecords array
724864
* and compares if all column values from matches

Resources/Core/Build/FunctionalTests.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,13 @@
4242
<php>
4343
<ini name="display_errors" value="1"/>
4444
<env name="TYPO3_CONTEXT" value="Testing"/>
45+
<!--
46+
Comment following line out to export actual database dataset for failed assertions
47+
in functional tests OR set it as environment variable. Do not use provide it here
48+
if you need to control this from the outside.
49+
50+
Default: 0 (false)
51+
-->
52+
<!-- <env name="TYPO3_TESTING_EXPORT_DATASETS_ON_FAILED_ASSERTION" value="1"/> -->
4553
</php>
4654
</phpunit>

0 commit comments

Comments
 (0)