Testing Guide
How to test code that uses derafu/translation — translatable exceptions,
ICU formatting, and resource registration.
Testing Untranslated Behavior
Since getMessage() is fully formatted at construction time, most tests
don’t need a translator at all:
use PHPUnit\Framework\TestCase;
final class ValidationExceptionTest extends TestCase
{
public function testPlainStringMessage(): void
{
$exception = new ValidationException('Email is invalid.');
$this->assertSame('Email is invalid.', $exception->getMessage());
}
public function testIcuParametersAreSubstitutedEagerly(): void
{
$exception = new ValidationException([
'The value {value} is not a valid email.',
'value' => 'test@example',
]);
$this->assertSame(
'The value test@example is not a valid email.',
$exception->getMessage(),
);
}
}
Testing with a Stub Translator
For unit tests, a TranslatorInterface stub configured with
willReturnCallback() is usually enough — it doesn’t need to verify how
many times it was called, just what it returns:
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
final class OrderExceptionTest extends TestCase
{
public function testTranslation(): void
{
$translator = $this->createStub(TranslatorInterface::class);
$translator->method('trans')->willReturnCallback(
fn (string $id, array $parameters = []): string => match ($id) {
'Not enough stock for "{product}".' => sprintf(
'No hay stock de "%s".',
$parameters['product'],
),
default => $id,
},
);
$exception = OrderException::insufficientStock('Widget');
$this->assertSame(
'No hay stock de "Widget".',
$exception->trans($translator, 'es'),
);
}
}
Use createMock() instead of createStub() only when you actually need to
assert on the number of calls (e.g. expects($this->once())) — PHPUnit
flags createMock() used purely for return-value stubbing as unnecessary.
Testing with a Real Translator
For integration tests, use the real TranslatorFactory +
TranslationResourceRegistrar against fixture translation files —
verifying the actual behavior end to end, ICU formatting included.
Fixtures for the examples below (tests/fixtures/translations/):
// errors+intl-icu.es.php
return [
'The value {value} is not a valid email.' => 'El valor {value} no es un correo electrónico válido.',
];
// messages+intl-icu.en.php
return [
'welcome' => 'Welcome!',
];
And an extra directory used only by the precedence test below,
tests/fixtures/translations-override/:
// messages+intl-icu.en.php
return [
'welcome' => 'Overridden message.',
];
use PHPUnit\Framework\TestCase;
use Derafu\Translation\TranslationResourceRegistrar;
use Derafu\Translation\TranslatorFactory;
use Symfony\Component\Translation\Translator;
final class TranslationIntegrationTest extends TestCase
{
private Translator $translator;
protected function setUp(): void
{
$this->translator = TranslatorFactory::create('en', ['en', 'es']);
$registrar = new TranslationResourceRegistrar($this->translator);
$registrar->registerDirectory(__DIR__ . '/../fixtures/translations');
}
public function testValidationErrorIsTranslated(): void
{
$exception = new ValidationException([
'The value {value} is not a valid email.',
'value' => 'test@example',
]);
$this->assertSame(
'El valor test@example no es un correo electrónico válido.',
$exception->trans($this->translator, 'es'),
);
}
}
Testing Resource Registration
Test TranslationResourceRegistrar directly against a fixtures directory
to verify discovery and precedence, without needing any specific exception
class:
use PHPUnit\Framework\TestCase;
use Derafu\Translation\TranslationResourceRegistrar;
use Derafu\Translation\TranslatorFactory;
final class TranslationResourceRegistrarTest extends TestCase
{
public function testDiscoversDomainsAndLocales(): void
{
$translator = TranslatorFactory::create('en');
$registrar = new TranslationResourceRegistrar($translator);
$registrar->registerDirectory(__DIR__ . '/../fixtures/translations');
sort($domains = $registrar->getRegisteredDomains());
sort($locales = $registrar->getRegisteredLocales());
$this->assertSame(['errors+intl-icu', 'messages+intl-icu'], $domains);
$this->assertSame(['en', 'es'], $locales);
}
public function testLaterDirectoriesOverridePreviousOnesForTheSameKey(): void
{
$translator = TranslatorFactory::create('en');
$registrar = new TranslationResourceRegistrar($translator);
$registrar->registerDirectories([
__DIR__ . '/../fixtures/translations',
__DIR__ . '/../fixtures/translations-override',
]);
$this->assertSame(
'Overridden message.',
$translator->trans('welcome', [], 'messages+intl-icu'),
);
}
}
Testing That a Package Is Translated
Use TranslationAudit (see Lint): one test that checks that every
message has its translation, that the catalogue has nothing that is not
used, and that every exception is translatable. It reads the code, so it
needs the conventions of that page; a message or an exception that can not
be read is reported and the test fails until the code is written so that
it can be.
use Derafu\Translation\Lint\TranslationAudit;
use PHPUnit\Framework\Attributes\CoversNothing;
use PHPUnit\Framework\TestCase;
#[CoversNothing]
final class TranslationMessagesTest extends TestCase
{
public function testThePackageIsTranslated(): void
{
$report = (new TranslationAudit())->audit(
dirname(__DIR__, 2) . '/src',
new PackageTranslationResourceProvider(),
);
$this->assertFalse($report->nothingFound);
$this->assertSame([], $report->describe($report->dynamicMessages));
$this->assertSame([], $report->describe($report->missingTranslations));
$this->assertSame([], $report->describe($report->notUsedBySources));
$this->assertSame([], $report->describe($report->notTranslatable));
}
}
A failing test must stay failing until the code is fixed: do not skip it or exclude files from it to make it pass. A case that can not have a literal by nature is fixed by its identity, as Lint explains.
Testing Custom Resource Providers
use PHPUnit\Framework\TestCase;
use Derafu\Translation\Contract\TranslationResourceProviderInterface;
final class PackageTranslationResourceProviderTest extends TestCase
{
public function testReturnsExpectedDirectories(): void
{
$provider = new PackageTranslationResourceProvider();
$directories = iterator_to_array($provider->getDirectories());
$this->assertNotEmpty($directories);
foreach ($directories as $directory) {
$this->assertDirectoryExists($directory);
}
}
}
Testing ICU Edge Cases
use PHPUnit\Framework\TestCase;
use Derafu\Translation\TranslatableMessage;
final class IcuFormattingTest extends TestCase
{
public function testMissingOtherCategoryFallsBackToRawMessage(): void
{
$message = new TranslatableMessage(
'{gender, select, male{He} female{She}}',
['gender' => 'unknown'],
);
$this->assertSame(
'{gender, select, male{He} female{She}}',
(string) $message,
);
}
public function testUnmatchedBraceFallsBackToRawMessage(): void
{
$message = new TranslatableMessage('Hello {name', ['name' => 'John']);
$this->assertSame('Hello {name', (string) $message);
}
public function testMissingParameterLeavesPlaceholderUnexpanded(): void
{
$message = new TranslatableMessage('{count} items');
$this->assertSame('{count} items', (string) $message);
}
}
Remember:
- Test both the untranslated (
getMessage()) and translated (trans()) paths. - Prefer
createStub()overcreateMock()for aTranslatorInterfacedouble unless you’re actually asserting call counts. - Use real fixture files and a real
Translatorfor integration-level coverage of ICU formatting and resource discovery — it’s fast and catches naming-convention mistakes that a stub translator can’t. - Always test with the
othercase missing and with missing parameters: both fail silently, falling back to the raw message, not by throwing.