Login to bookmark this video
Buy Access to Course
15.

TDD `TranslatedObject` Translations

|

Share this awesome video!

|

Lucky you! You found an early release chapter - it will be fully polished and published shortly!

This Chapter isn't quite ready...

Get Notified About this Course!

We will send you messages regarding this course only
and nothing else, we promise.
You can unsubscribe anytime by emailing us at:
privacy@symfonycasts.com

The current logic of our TranslatedObject is solid. It effectively passes all method calls and property access to the underlying object. And... we have the tests to prove it!

We previously used TDD to fix that Twig method call issue. Now, let's use it to bang out a feature!

Our TranslatedObject needs to handle property translations. Before calling the underlying object's method or property, it should check if a translated value exists. If it does, it should return that translated value instead.

Let's get started!

Start with Green Tests

First, before doing anything, confirm our test suite is all green. At your terminal, run:

symfony php vendor/bin/phpunit object-translation-bundle/tests

Green, great! Starting a new feature with TDD is a fools errand if your tests are already failing.

New Test

Back in TranslatedObjectTest, add a new test with: public function testCanTranslateProperties():

// ... lines 1 - 7
class TranslatedObjectTest extends TestCase
{
// ... lines 10 - 27
public function testCanTranslateProperties()
{
// ... lines 30 - 40
}
}
// ... lines 43 - 60

We write this test with the logic we want to see. So, copy the setup phase from the test above and paste here.

Now, for the second argument of the TranslatedObject constructor, this'll be an array of translated properties. We'll translate all properties for our ObjectForTranslationStub below. Inside the array, write 'prop1' => 'translated1', 'prop2' => 'translated2', 'prop3' => 'translated3',:

// ... lines 1 - 7
class TranslatedObjectTest extends TestCase
{
// ... lines 10 - 27
public function testCanTranslateProperties()
{
$object = new TranslatedObject(new ObjectForTranslationStub(), [
'prop1' => 'translated1',
'prop2' => 'translated2',
'prop3' => 'translated3',
]);
// ... lines 35 - 40
}
}
// ... lines 43 - 60

You can see PhpStorm is marking all this as gray since the constructor doesn't accept this parameter yet.

I think this looks pretty good. When you pass an array of translated values, keyed by property name, when accessing these properties, we should get the translated values back.

For the assertions, copy these from the first test and paste here. Now, change all the expected values from value to translated. translated1, translated2, and translated3:

// ... lines 1 - 7
class TranslatedObjectTest extends TestCase
{
// ... lines 10 - 27
public function testCanTranslateProperties()
{
// ... lines 30 - 35
$this->assertSame('translated1', $object->prop1);
$this->assertTrue(isset($object->prop1), 'Public property should be accessible');
$this->assertFalse(isset($object->prop2), 'Private property should not be accessible');
$this->assertSame('translated2', $object->prop2());
$this->assertSame('translated3', $object->getProp3());
}
}
// ... lines 43 - 60

I think we all know this isn't going to work but... let's let the tests tell us that!

At your terminal, run the tests again:

symfony php vendor/bin/phpunit object-translation-bundle/tests

Fail! But totally expected. Failed asserting that "value1" is "translated1" on line 36.

Back in the test, line 36 is where we're accessing the prop1 property. So, let's add the logic to make this test pass!

This test... is driving... our development - get it?!

Injecting Translations

Over in TranslatedObject, add a new property to the constructor: private array $_translations, - remember, the _ prefix is a convention we're using just because this is a mixin.

// ... lines 1 - 9
final class TranslatedObject
{
// ... lines 12 - 14
public function __construct(
// ... line 16
private array $_translations,
) {
}
// ... lines 20 - 40
}

Above, add a doc block @param for this new parameter, type: array. Let's be clever and specify the key and value types of this array. Inside angle brackets, write string,string. The first string is the key type, the property name, and the second string is the value type, the translated value. Finally, write $_translations to finish this doc block.

Translating Property Access

Remember, our test is failing when accessing a property. So, down in the __get() method, before returning the inner property, write $this->_translations[$name] ??:

// ... lines 1 - 9
final class TranslatedObject
{
// ... lines 12 - 31
public function __get(string $name): mixed
{
return $this->_translations[$name] ?? $this->_inner->$name;
}
// ... lines 36 - 40
}

This will check if a translated value exists for this property name. If it does, it'll return that. If not, it'll fall back to returning the inner object property.

Ok, back to the terminal and run the tests again:

symfony php vendor/bin/phpunit object-translation-bundle/tests

Still failing... but look closely - it's now failing for "translated2" and "value2" not matching - and on line 39.

Jump back to the test. Line 39 is where we're calling the prop2() method. That it got this far means our translated property access logic on line 36 is working! Sweet!

Translating Method Calls

Now to handle method calls. Over in TranslatedObject::__call(), at the top, add if (isset($this->_translations[$name])). Inside, return $this->_translations[$name];.

// ... lines 1 - 9
final class TranslatedObject
{
// ... lines 12 - 20
public function __call(string $name, array $arguments): mixed
{
if (isset($this->_translations[$name])) {
return $this->_translations[$name];
}
// ... lines 26 - 33
}
// ... lines 35 - 44
}

This checks if a translated value exists for this exact method name. If it does, it returns that value.

You know what to do! Back in the terminal, run the tests again:

symfony php vendor/bin/phpunit object-translation-bundle/tests

Failing on line 40 now - "translated3" and "value3". Check this line our in our test. Ahh... the getter method... We need to account for this but kind of in the opposite way we did for the Twig method call issue. We need to check if the method name exists as a translated property without the get prefix. Tricky!

Translating Getter Methods

Check back in with TranslatedObject::__call(). This method is getting a bit long, so let's refactor and add our new logic in a private method. Below, write private function translatedValue(string $name): ?string:

// ... lines 1 - 9
final class TranslatedObject
{
// ... lines 12 - 45
private function translatedValue(string $name): ?string
{
// ... lines 48 - 58
}
}

This will accept the method name and return the translated value as a string, or null if it doesn't exist.

Back up in __call(), cut the if (isset(...)) statement and paste it in our new private method:

// ... lines 1 - 9
final class TranslatedObject
{
// ... lines 12 - 45
private function translatedValue(string $name): ?string
{
if (isset($this->_translations[$name])) {
return $this->_translations[$name];
}
// ... lines 51 - 58
}
}

This checks if the exact method name exists as a translated property.

Next, write if (!str_starts_with($name, 'get')). This checks if the method name is not a getter. There's nothing to do in this case, so, return null:

// ... lines 1 - 9
final class TranslatedObject
{
// ... lines 12 - 45
private function translatedValue(string $name): ?string
{
// ... lines 48 - 51
if (!str_starts_with($name, 'get')) {
return null;
}
// ... lines 55 - 58
}
}

Below, write $property = lcfirst(substr($name, 3)). substr chops the first 3 characters off the name - which we know is get. lcfirst lowercases the first character, leaving us with the property name:

// ... lines 1 - 9
final class TranslatedObject
{
// ... lines 12 - 45
private function translatedValue(string $name): ?string
{
// ... lines 48 - 55
$property = lcfirst(substr($name, 3));
// ... lines 57 - 58
}
}

Finally, return $this->_translations[$property] ?? null. Return the translated value for this property if it exists, otherwise, return null:

// ... lines 1 - 9
final class TranslatedObject
{
// ... lines 12 - 45
private function translatedValue(string $name): ?string
{
// ... lines 48 - 57
return $this->_translations[$property] ?? null;
}
}

Back up in __call(), check if a translated value exists with if ($translatedValue = $this->translatedValue($name)). Inside, return $translatedValue:

// ... lines 1 - 9
final class TranslatedObject
{
// ... lines 12 - 20
public function __call(string $name, array $arguments): mixed
{
if ($translatedValue = $this->translatedValue($name)) {
return $translatedValue;
}
// ... lines 26 - 33
}
// ... lines 35 - 59
}

Run tests, run!

symfony php vendor/bin/phpunit object-translation-bundle/tests

Hmm, we have some errors. Scroll up a bit to see the summary. The first two tests errored, but our third test, the translated properties one, is passing. It's the original tests that have issues. This is why it was important to run the tests before starting this feature! We know for sure, we did something that broke existing functionality.

Fixing Existing Functionality

Checkout the error: "Too few arguments to... TranslatedObject::__construct()"

Ahh, we added a new required parameter to this class's constructor. The first two tests aren't passing the $_translations array.

Over in our test class, scroll up to the first two tests. PhpStorm is even warning us about this. In both tests, pass an empty array as the second argument:

// ... lines 1 - 7
class TranslatedObjectTest extends TestCase
{
public function testCanAccessUnderlyingObject()
{
$object = new TranslatedObject(new ObjectForTranslationStub(), []);
// ... lines 13 - 18
}
// ... line 20
public function testCallUsesGetterIfAvailable()
{
$object = new TranslatedObject(new ObjectForTranslationStub(), []);
// ... lines 24 - 25
}
// ... lines 27 - 41
}
// ... lines 43 - 60

Are we done?! Find out by running our tests again:

symfony php vendor/bin/phpunit object-translation-bundle/tests

Woo! All tests are passing! New feature successfully added!

Next, we'll take a side step and look at how we'll mark our app's entities for translation.