Pest test to find missing translations

4/2/2024

A pest test to find translations used in your laravel app but not defined in your default locale files

Have you ever deployed a new feature only to realize you missed adding that single translation and now your users are seeing the string auth.email_verification_needed instead of the intended user friendly label. What if you could add a pest test to check your code and identify all the translation strings in your application that you've not included in your localization files. In this post we'll only check strings against the default locale.

TLDR

Steps

  1. Get all files with potential translated texts. We will only focus on the app folder and resources folder. You can always add more folder to scan.
    collect(File::allFiles(app_path()))->merge(File::allFiles(resource_path()))
  1. Map through all the files and get the translation texts. We assume that all translations will be strings using the helpers __('')' and trans() or the facade Lang::get() or vue i18n helper $t().

Some of the strings we are looking to match include

app.blade.php

<span>{{ __('auth.email') }}</span>
<span>{!! trans('common.status') !!}</span>
<span>{!! __("common.name") !!}</span>
<span>{{ Lang::get('rules.applicable', [name => $name]) }}</span>

app.vue

<span>{{ $t('common.email') }}</span>
<span>{{ $t('message.hello', { msg: 'hello' }) }}<span>

For each file match all strings that matches the regex pattern /(__|trans|Lang::get|\$t)\(['\"]+([\w\d_\s\-.!\/\${}>]+)[,\s\[\]\{\}:=>\$\"'\w]*\)/ and return the matched strings

  ->map(function (SplFileInfo $file) {
        $matches = [];
        preg_match_all("/(__|trans|Lang::get|\$t)\(['\"]+([\w\d_\s\-.!\/\${}>]+)[,\s\[\]\{\}:=>\$\"'\w]*\)/", $file->getContents(), $matches);
        return $matches[2];
    })
  ->filter(fn ($matches) => count($matches))
  1. Next let's go through each translation and check is its defined in our default locale. If not we'll add it to our array of missing translations with the translation string as key and array of files missing it as the value
 ->reduce(fn ($missing, $keys, $file) => collect($keys)
    ->reduce(function ($missing, $key) use ($file, $ignoredKeys) {
        if (isset($missing[$key])) {
            // only add the file if it's unique
            if (!in_array($file, $missing[$key])) {
                $missing[$key][] = $file;
            }
            return $missing;
        }
        if (!Lang::has($key)) {
            $missing[$key][] = $file;
        }
        return $missing;
    }, $missing), []);
  1. Before we can call the assert function we'll format our fail message to include all the missing translations and the files to check them in so we can go add them or change the translation string to the appropriate one.
    $message = collect($missing)->reduce(function ($message, $files, $key) {
        $message .= 'Missing key: '.$key.PHP_EOL;
        $message .= '-----------------------------'.PHP_EOL;
        foreach ($files as $file) {
            $message .= $file.PHP_EOL;
        }
        $message .= '________________________'.PHP_EOL.PHP_EOL;

        return $message;
    }, 'These translations are missing:'.PHP_EOL);
  1. Finally we assert that the array of missing translations is empty otherwise show the message from step 4
expect($missing)->toHaveCount(0, $message);
  1. You'll soon realize there are translations which you want to ignore e.g translation keys with dynamic values. To do this let's add an array to track this and do an early return in the function checking if the locale is present.
 $ignoredKeys = [
    'settings.$status',
    'issues.{$this->reason}',
    'transformer.used_{$encoder}_encoder',
 ];

 ->reduce(fn ($missing, $keys, $file) => collect($keys)
   ->reduce(function ($missing, $key) use ($file, $ignoredKeys) {
      if (in_array($key, $ignoredKeys)) {
        return $missing;
      }

The final test

test('No missing translations', function () {
    $ignoredKeys = [
        'settings.$status',
        'issues.{$this->reason}',
        'transformer.used_{$encoder}_encoder',
    ];

    // get all files from our app and resource folders
    $missing = collect(File::allFiles(app_path()))
        ->merge(File::allFiles(resource_path()))
        // key the array by the file name relative to our base path
        ->keyBy(fn (SplFileInfo $file) => str_replace(base_path(), '', $file->getPathname()))
        // match all local string present in the various files
        ->map(function (SplFileInfo $file) {
            $matches = [];
            preg_match_all("/(__|trans|Lang::get|\$t)\(['\"]+([\w\d_\s\-.!\/\${}>]+)[,\s\[\]\{\}:=>\$\"'\w]*\)/", $file->getContents(), $matches);

            return $matches[2];
        })
        // remove all files without any translations strings
        ->filter(fn ($matches) => count($matches))
        // check if the default translation exists if not add it as a key with file as value
        ->reduce(fn ($missing, $keys, $file) => collect($keys)
            ->reduce(function ($missing, $key) use ($file, $ignoredKeys) {
                // early exit if key should be ignored
                if (in_array($key, $ignoredKeys)) {
                    return $missing;
                }
                if (isset($missing[$key])) {
                    // only add the file if it's unique
                    if (!in_array($file, $missing[$key])) {
                        $missing[$key][] = $file;
                    }

                    return $missing;
                }

                // check if the key is defined in the default locale
                if (!Lang::has($key)) {
                    $missing[$key][] = $file;
                }

                return $missing;
            }, $missing), []);

    // sort the array by its keys
    ksort($missing);

    // generate the error message to show missing translations incase there's any missing
    $message = collect($missing)->reduce(function ($message, $files, $key) {
        $message .= 'Missing key: ' . $key . PHP_EOL;
        $message .= '-----------------------------' . PHP_EOL;
        foreach ($files as $file) {
            $message .= $file . PHP_EOL;
        }
        $message .= '________________________' . PHP_EOL . PHP_EOL;

        return $message;
    }, 'These translations are missing:' . PHP_EOL);

    // Assert  if there are any missing translations and show the list
    expect($missing)->toHaveCount(0, $message);
})

Next steps

This test only checks if the translations string expected in your application files exist in your default localization files. It would be prudent to add a second test to check that translations present in one locale are available in all the others. We'll cover that in the next post. You can follow me on twitter @ralphowino so you don't miss it when it drops.