Unit testing with i18next and Angular

We recently set up a new Angular project that involves internationalization using i18next. Testing is an essential part of any project, and so it is in this project. However, unit testing components that use internationalization features can be a bit tricky when done the first time. Therefore, we put this post as part of our i18n series together for you and us as a guideline.

Setup i18n in your Angular application

We chose i18next to handle translations in our project. We use angular-i18next as a wrapper for the library. We also use some form of language detection, and we use the i18next-http-backend to load the translations because they are contained in separate JSON files.

We use Jest together with jest-preset-angularjest-environment-jsdom, and @testing-library/angular for unit testing.

Our Angular component

For this article, we use a straightforward component. All it contains is a translation.

import { Component } from '@angular/core';
import { I18NextModule, } from 'angular-i18next';

@Component({
  standalone: true,
  imports: [I18NextModule],
  selector: 'app-root',
  template: `<h1>{{ 'title' | i18next }}</h1>`,
  styles: [],
})
export class AppComponent {}

Yes, that’s it. This is enough to help answer our question: How do we treat internationalization in our unit test? Well, we have a few different options.

Option 1: Ignore internationalization

The initial generated test for a component is always the one where the test verifies that the component is successfully instantiated. If we build that test using Jest and testing library, we end up with something like the following:

import { AppComponent } from './app.component';
import { I18NextModule } from 'angular-i18next';
import { render } from '@testing-library/angular';

describe('AppComponent', () => {
  it('should render component', async () => {
    const renderResult = await render(AppComponent, {
      imports: [I18NextModule.forRoot()],
    });

    const component = renderResult.fixture.componentInstance;
    expect(component).toBeTruthy();
  });
});

In this case, we must take care of our component’s module dependency. However, we are not interested in the translation library doing anything in our component. With the non-existing configuration, the i18next pipe will create no output. The title will be empty. However, our component will be instantiated successfully.

Option 2: Check for the correct translation key

This is useful to see p.ex. whether the correct text is shown. Maybe we want to verify the function of a drop-down box or that the right text is returned from some piece of logic. In order to get there, we need to initialize i18next, so it actually does something. We don’t need actual translations because i18next returns the translation key when it is initialized, but no translation is present.

The following code shows the test extended with the i18next initialization. It verifies that the translation key is displayed.

import { APP_INITIALIZER } from '@angular/core';
import { AppComponent } from './app.component';

import {
  I18NEXT_SERVICE,
  I18NextModule,
  ITranslationService,
} from 'angular-i18next';

import { render } from '@testing-library/angular';

function appInit(i18next: ITranslationService) {
  return () => i18next.init({});
}

describe('AppComponent', () => {
  it('should render title', async () => {
    const renderResult = await render(AppComponent, {
      imports: [I18NextModule.forRoot()],
      providers: [
        {
          provide: APP_INITIALIZER,
          useFactory: appInit,
          deps: [I18NEXT_SERVICE],
          multi: true,
        }
      ]
    });

    
    const element = renderResult.fixture.nativeElement as HTMLElement;

    expect(element.querySelector('h1')?.textContent).toContain(
      'title'
    );
  });
});

Here, we added the initializer function, which we call appInit. For this use case, it works with no options at all. Therefore, the options that are given to i18next.init() are empty. The initializer function must be provided as a factory function for the APP_INITIALIZER token.

Now, we can already verify that we used the correct translation key. We can do that by querying the element that contains the translation (remember the <h1>{{ ‘title’ | i18next }}</h1> in our component) and checking that the contained text is the translation key.

Option 3: Check for the correct rendering of the text

Sometimes, checking for the translation key is not enough. For instance, we may want to verify that specific data is shown correctly, but it is part of a translation. For this example, we will first adjust our component to require a translation with basic interpolation.

@Component({
  standalone: true,
  imports: [I18NextModule],
  selector: 'app-root',
  template: `<h1>{{ 'title' | i18next }}</h1>
<p>{{ 'loggedIn' | i18next: {name: username} }}</p>`,
  styles: [],
})
export class AppComponent {
  username = 'John Travolta';
}

With our option 2 approach, we can not test that we see the username as part of the loggedIn translation because the i18next pipe would only return the translation key, which is “loggedIn.” We need to provide a translation with interpolation so we can test that the username is shown to the user. The following code shows the test with the required configuration.

import { APP_INITIALIZER } from '@angular/core';
import { AppComponent } from './app.component';
import {
  I18NEXT_SERVICE,
  I18NextModule,
  ITranslationService,
} from 'angular-i18next';
import { render } from '@testing-library/angular';
import { ResourceKey } from 'i18next/typescript/options';


function appInit(i18next: ITranslationService) {
  return () =>
    i18next.init({
      fallbackLng: 'en',
      resources: {
        en: {
          translation: {
            loggedIn: 'Logged in as {{name}}',
          },
        },
      },
    });
}

describe('AppComponent', () => {
  it('should render logged in information', async () => {
    const renderResult = await render(AppComponent, {
      imports: [I18NextModule.forRoot()],
      providers: [
        {
          provide: APP_INITIALIZER,
          useFactory: appInit,
          deps: [I18NEXT_SERVICE],
          multi: true,
        }
      ]
    });

    const element = renderResult.fixture.nativeElement as HTMLElement;

    expect(element.querySelector('p')?.textContent).toContain(
      'Logged in as John Travolta'
    );
  });
});

As we see here, we need to provide minimal options to i18next.init(). The first is the definition of fallback language (fallbackLng), which will be used in i18next to look for the translation of this language. The second option is resources, which contains the translations. Inside resources, the key needs to be the language code we defined in fallbackLng, in our case en. In the object of this key, we find the namespace, for which the default is translation. Finally, we insert the required translations in the namespace object: loggedIn: ‘Logged in as {{name}}’.

This allows us to test that the data is shown by querying the element again and checking the text content. It should contain the translation with the inserted data.

Making Angular i18next tests reusable

Of course, we can keep writing our appInit and the provider definition repeatedly, or we can extract them into a separate file and make them reusable, as shown in the following code block.

function appInit(translations: ResourceKey) {
  return (i18next: ITranslationService) => {
    return () =>
      i18next.init({
        fallbackLng: 'en',
        resources: {
          en: {
            translation: translations,
          },
        },
      });
  };
}

export const I18N_MOCK_PROVIDER = (translations: ResourceKey = {}) => ({
  provide: APP_INITIALIZER,
  useFactory: appInit(translations),
  deps: [I18NEXT_SERVICE],
  multi: true,
});

Here, we modified appInit to take the translations as a parameter and now return the factory function. I18N_MOCK_PROVIDER is a function that also takes the translations and constructs the required provider, forwarding the translations to appInit.

Now, we can call I18N_MOCK_PROVIDER in our providers array in any test requiring i18next and insert the translations we need in the translations object. Note that we still need to set the import for the I18NextModule. We can see this in the following code snippet.

const renderResult = await render(AppComponent, {
   imports: [I18NextModule.forRoot()],
   providers: [
      I18N_MOCK_PROVIDER({
          loggedIn: 'Logged in as {{name}}',
      })
   ]
});

What these tests don’t test

In this article, we are looking at unit testing our components, which use translations. It is noteworthy what we can not or should not test in these tests.

We can not / should not verify the actual translations. Firstly, we would need to provide our actual translations in code or read the translation files in our tests to test our actual translations. Additionally, it will be a hassle to update translations if we keep the unit tests, especially if we consider that translations often are not provided by the developers themselves. We’d always need to update the translation and the tests that use it. Tests that use the actual translations are, for instance, integration tests or e2e tests.

Summing up

We looked at working with i18next in unit tests. We started small, ignoring translations, only satisfying the import dependency of our component. Then, we looked into checking for the translation key. Next, we learned how to provide translations for our tests and extracted the translation setup into a separate file to reuse it across all our tests. Finally, we looked at the boundaries of unit testing with translations.

That’s it. That is one way to handle internationalization with i18next in your unit tests. Enjoy testing.