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() over createMock() for a TranslatorInterface double unless you’re actually asserting call counts.
  • Use real fixture files and a real Translator for 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 other case missing and with missing parameters: both fail silently, falling back to the raw message, not by throwing.
On this page

Last updated on 10/10/2026 by Anonymous