Angular i18n Testing: Locales, Pipes, and Translation Keys
Angular's approach to internationalization creates a testing challenge: the default compile-time i18n bakes translations into the build, making runtime locale switching in unit tests awkward. This guide covers how to configure Angular TestBed for locale-aware testing, how to unit test locale-sensitive pipes, and how to use component harnesses for cleaner i18n assertions.
Angular i18n Approaches and Their Testing Implications
Angular supports two i18n approaches:
- Compile-time i18n (the Angular CLI default) — translations are extracted from templates with
ng extract-i18n, compiled withng build --localize. Each locale produces a separate build artifact. Unit tests run against the default locale. - Runtime i18n with @angular/localize — translations load at runtime, enabling locale switching without rebuilding. This is more testable because you can configure the locale per-test.
For testing purposes, the runtime approach is significantly easier. This guide uses @angular/localize with runtime loading.
Setting Up @angular/localize
ng add @angular/localizeIn main.ts, load translations before bootstrapping:
import '@angular/localize/init';
import { loadTranslations } from '@angular/localize';
async function bootstrap() {
const locale = navigator.language.split('-')[0] || 'en';
if (locale !== 'en') {
const translations = await import(`./assets/i18n/${locale}.json`);
loadTranslations(translations.default);
}
const { AppModule } = await import('./app/app.module');
const { platformBrowserDynamic } = await import('@angular/platform-browser-dynamic');
await platformBrowserDynamic().bootstrapModule(AppModule);
}
bootstrap();Configuring TestBed with LOCALE_ID
For any test that exercises locale-sensitive behavior, provide LOCALE_ID in TestBed:
import { TestBed } from '@angular/core/testing';
import { LOCALE_ID } from '@angular/core';
import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import localeFr from '@angular/common/locales/fr';
// Register locale data before using it
registerLocaleData(localeDe);
registerLocaleData(localeFr);
describe('MyComponent with German locale', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [MyComponent],
providers: [
{ provide: LOCALE_ID, useValue: 'de' }
],
}).compileComponents();
});
it('displays content in German locale', () => {
const fixture = TestBed.createComponent(MyComponent);
fixture.detectChanges();
// assertions...
});
});Register all locale data in a shared test-setup.ts file to avoid repeating the import in every test file:
// src/test-setup.ts
import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import localeFr from '@angular/common/locales/fr';
import localeAr from '@angular/common/locales/ar';
registerLocaleData(localeDe);
registerLocaleData(localeFr);
registerLocaleData(localeAr);Reference this file in angular.json or jest.config.js under setupFilesAfterFramework.
Unit Testing DatePipe
DatePipe respects LOCALE_ID. Always inject it with an explicit locale in tests:
import { DatePipe } from '@angular/common';
describe('DatePipe locale formatting', () => {
it('formats US dates as MM/DD/YY', () => {
const pipe = new DatePipe('en-US');
const date = new Date('2026-03-15T00:00:00Z');
expect(pipe.transform(date, 'shortDate')).toBe('3/15/26');
});
it('formats German dates as DD.MM.YY', () => {
const pipe = new DatePipe('de-DE');
const date = new Date('2026-03-15T00:00:00Z');
expect(pipe.transform(date, 'shortDate')).toBe('15.03.26');
});
it('formats French dates as DD/MM/YY', () => {
const pipe = new DatePipe('fr-FR');
const date = new Date('2026-03-15T00:00:00Z');
expect(pipe.transform(date, 'shortDate')).toBe('15/03/2026');
});
});Important: Use fixed dates in tests, not new Date(). Tests that use the current date will fail when the year changes.
Unit Testing CurrencyPipe
import { CurrencyPipe } from '@angular/common';
describe('CurrencyPipe locale formatting', () => {
it('formats USD in en-US', () => {
const pipe = new CurrencyPipe('en-US');
const result = pipe.transform(1234.5, 'USD');
expect(result).toBe('$1,234.50');
});
it('formats EUR in de-DE', () => {
const pipe = new CurrencyPipe('de-DE');
const result = pipe.transform(1234.5, 'EUR', 'symbol', '1.2-2', 'de-DE');
expect(result).toBe('1.234,50 €');
});
it('formats GBP in en-GB', () => {
const pipe = new CurrencyPipe('en-GB');
const result = pipe.transform(1234.5, 'GBP');
expect(result).toBe('£1,234.50');
});
});Unit Testing DecimalPipe
import { DecimalPipe } from '@angular/common';
describe('DecimalPipe locale formatting', () => {
it('uses period as decimal separator in en-US', () => {
const pipe = new DecimalPipe('en-US');
expect(pipe.transform(1234567.89, '1.2-2')).toBe('1,234,567.89');
});
it('uses comma as decimal separator in de-DE', () => {
const pipe = new DecimalPipe('de-DE');
expect(pipe.transform(1234567.89, '1.2-2')).toBe('1.234.567,89');
});
});Testing Components with Locale-Sensitive Templates
When a component uses pipes in its template, the pipe inherits LOCALE_ID from the module. Test it through the component:
// order-summary.component.ts
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-order-summary',
template: `
<div class="order-summary">
<p class="total">{{ total | currency:currency }}</p>
<p class="date">{{ orderDate | date:'longDate' }}</p>
</div>
`
})
export class OrderSummaryComponent {
@Input() total = 0;
@Input() currency = 'USD';
@Input() orderDate = new Date();
}// order-summary.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CommonModule } from '@angular/common';
import { LOCALE_ID } from '@angular/core';
import { OrderSummaryComponent } from './order-summary.component';
describe('OrderSummaryComponent', () => {
let fixture: ComponentFixture<OrderSummaryComponent>;
let component: OrderSummaryComponent;
const testDate = new Date('2026-01-15T12:00:00Z');
describe('English locale', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CommonModule],
declarations: [OrderSummaryComponent],
providers: [{ provide: LOCALE_ID, useValue: 'en-US' }],
}).compileComponents();
fixture = TestBed.createComponent(OrderSummaryComponent);
component = fixture.componentInstance;
component.total = 99.99;
component.currency = 'USD';
component.orderDate = testDate;
fixture.detectChanges();
});
it('formats total in USD', () => {
expect(fixture.nativeElement.querySelector('.total').textContent.trim())
.toBe('$99.99');
});
it('formats date in English long format', () => {
expect(fixture.nativeElement.querySelector('.date').textContent.trim())
.toBe('January 15, 2026');
});
});
describe('German locale', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CommonModule],
declarations: [OrderSummaryComponent],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
}).compileComponents();
fixture = TestBed.createComponent(OrderSummaryComponent);
component = fixture.componentInstance;
component.total = 99.99;
component.currency = 'EUR';
component.orderDate = testDate;
fixture.detectChanges();
});
it('formats total in EUR with German conventions', () => {
const text = fixture.nativeElement.querySelector('.total').textContent.trim();
expect(text).toContain('99,99');
expect(text).toContain('€');
});
it('formats date in German long format', () => {
expect(fixture.nativeElement.querySelector('.date').textContent.trim())
.toBe('15. Januar 2026');
});
});
});Testing with Angular Component Harnesses
Component harnesses provide a more maintainable way to query elements without relying on CSS selectors:
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatInputHarness } from '@angular/material/input/testing';
describe('LocaleFormComponent harness', () => {
let loader: HarnessLoader;
let fixture: ComponentFixture<LocaleFormComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ReactiveFormsModule, MatInputModule],
declarations: [LocaleFormComponent],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
}).compileComponents();
fixture = TestBed.createComponent(LocaleFormComponent);
loader = TestbedHarnessEnvironment.loader(fixture);
fixture.detectChanges();
});
it('displays placeholder in German', async () => {
const input = await loader.getHarness(MatInputHarness.with({ selector: '#amount' }));
const placeholder = await input.getPlaceholder();
expect(placeholder).toBe('Betrag eingeben'); // "Enter amount" in German
});
});Testing @ngx-translate (Third-Party Library)
Many Angular projects use @ngx-translate instead of the built-in i18n. The testing approach is different:
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { of } from 'rxjs';
const enTranslations = {
'nav.home': 'Home',
'nav.about': 'About Us',
};
const deTranslations = {
'nav.home': 'Startseite',
'nav.about': 'Über uns',
};
class FakeTranslateLoader implements TranslateLoader {
getTranslation(lang: string) {
return of(lang === 'de' ? deTranslations : enTranslations);
}
}
describe('NavigationComponent with ngx-translate', () => {
let translateService: TranslateService;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot({
loader: { provide: TranslateLoader, useClass: FakeTranslateLoader },
}),
],
declarations: [NavigationComponent],
}).compileComponents();
translateService = TestBed.inject(TranslateService);
});
it('renders English navigation', async () => {
translateService.use('en');
const fixture = TestBed.createComponent(NavigationComponent);
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('Home');
expect(fixture.nativeElement.textContent).toContain('About Us');
});
it('renders German navigation', async () => {
translateService.use('de');
const fixture = TestBed.createComponent(NavigationComponent);
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('Startseite');
expect(fixture.nativeElement.textContent).toContain('Über uns');
});
});Common Mistakes in Angular i18n Testing
Not calling registerLocaleData: Angular throws a "Missing locale data" error if you use a locale without registering it first. Always call registerLocaleData before the locale is used in any test.
Testing compile-time i18n with unit tests: Compile-time translations are not present in unit tests—the template strings remain in English regardless of the locale. Switch to runtime i18n for testability.
Not detecting changes after locale switch: After translateService.use('de'), call fixture.detectChanges() and await fixture.whenStable() before asserting. Async translation loading requires waiting for the observable to resolve.
Asserting exact currency strings: Currency formatting output can vary by Node.js version and ICU data version. Use toContain for the numeric portion rather than toBe for the full formatted string.
Summary
Angular i18n testing works best with the runtime approach using LOCALE_ID injection. Test pipes directly by instantiating them with an explicit locale string. Test components by providing LOCALE_ID in TestBed.configureTestingModule. Register all locale data in a shared test setup file. For @ngx-translate, use a FakeTranslateLoader that returns synchronous observables to avoid async timing issues.