Example repo
The repo can be found here.
Please note that this isn't a complete example, it is merely there to show issues around testing $localize
.
There are 2 branches:
1. master
1. enable-localize-unit-tests
- this changes the test.ts
and polyfills.ts
to prevent the @angular/localize/init
from being imported, the $localize
global function is also spied
Overview
I have upgraded a project from Angular 8 to 9 (following the Angular update guide), replacing any usage the I18n
service (from the ngx-translation/i18n-polyfill) with Angular's new $localize
function (documentation on this is pretty limited, here is the best I could find). I can run localised builds and serve the application in a particular locale again. However, I've hit a bit of a roadblock when it comes to unit testing.
Previously, when using the i18n-polyfill
, the I18n
service could be injected into components, etc. as follows (see I18nPolyfillComponent
):
@Component({
selector: "app-i18-polyfill",
template: `<h4>{{ title }}</h4>
})
export class I18nPolyfillComponent {
readonly title: string = this.i18n({
id: "title",
value: "Hello World!"
});
constructor(private i18n: I18n) {}
}
This could easily tested by injecting a spy into the component:
describe("I18nPolyfillComponent", () => {
let component: I18nPolyfillComponent;
let fixture: ComponentFixture<I18nPolyfillComponent>;
let mockI18n: Spy;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ I18nPolyfillComponent ],
providers: [
{
provide: I18n,
useValue: jasmine.createSpy("I18n"),
},
],
})
.compileComponents().then(() => {
mockI18n = TestBed.inject(I18n) as Spy;
});
}));
beforeEach(() => {
fixture = TestBed.createComponent(I18nPolyfillComponent);
component = fixture.componentInstance;
mockI18n.and.callFake((def: I18nDef) => def.value);
});
it("should call i18n once", () => {
expect(mockI18n).toHaveBeenCalledTimes(1);
});
});
However, I am unsure if similar unit tests could be written to test the usage of $localize
as it is a global function rather than an injectable service.
For completeness the component would look like this using $localize
(see I18nLocalizeComponent
):
@Component({
selector: "app-i18n-localize",
template: `<h4>{{ title }}</h4>
})
export class I18nLocalizeComponent {
readonly title: string = $localize `:@@title:Hello World!`;
}
Testing rationale
I would like to ensure that my applications are interacting with I18n
/$localize
appropriately (called the correct number of times, with correct parameters, etc.). This just prevent silly errors if someone accidentally changes a trans-unit ID or the base translations value.
What I've tried
I have tried to replace the global $localize
function with a spy in test.ts
and avoiding importing @angular/localize/init
:
import Spy = jasmine.Spy;
import createSpy = jasmine.createSpy;
const _global: any = typeof global !== "undefined" && global;
_global.$localize = createSpy("$localize");
declare global {
const $localize: Spy;
}
And then using the spied $localize
in the tests (see :
describe("I18nLocalizeComponent", () => {
let component: I18nLocalizeComponent;
let fixture: ComponentFixture<I18nLocalizeComponent>;
let mockI18n: Spy;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ I18nLocalizeComponent ],
})
.compileComponents();
}));
beforeEach(() => {
$localize.calls.reset();
$localize.and.returnValue("Hello World!);
fixture = TestBed.createComponent(I18nLocalizeComponent);
component = fixture.componentInstance;
});
it("should call $localize once", () => {
expect($localize).toHaveBeenCalledTimes(1);
});
});
The spy does work but tests will fail if the component, or another component, uses the i18n
directives in it's template, for example (I18nLocalizeTemplateComponent
):
<p i18n>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean consequat.</p>
This will fail with the following error:
TypeError: Cannot read property 'substr' of undefined
at <Jasmine>
at removeInnerTemplateTranslation (http://localhost:9877/_karma_webpack_/node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js:34560:1)
at getTranslationForTemplate (http://localhost:9877/_karma_webpack_/node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js:34582:1)
at i18nStartFirstPass (http://localhost:9877/_karma_webpack_/node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js:34771:1)
at ɵɵi18nStart (http://localhost:9877/_karma_webpack_/node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js:34718:1)
at ɵɵi18n (http://localhost:9877/_karma_webpack_/node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js:35450:1)
at I18nLocalizeTemplateComponent_Template (ng:///I18nLocalizeTemplateComponent.js:15:9)
at executeTemplate (http://localhost:9877/_karma_webpack_/node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js:11949:1)
at renderView (http://localhost:9877/_karma_webpack_/node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js:11735:1)
at renderComponent (http://localhost:9877/_karma_webpack_/node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js:13244:1)
at renderChildComponents (http://localhost:9877/_karma_webpack_/node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js:11538:1)
Error: Expected undefined to be truthy.
at <Jasmine>
at UserContext.<anonymous> (http://localhost:9877/_karma_webpack_/src/app/i18n-localize-template/i18n-localize-template.component.spec.ts:23:23)
at ZoneDelegate.invoke (http://localhost:9877/_karma_webpack_/node_modules/zone.js/dist/zone-evergreen.js:364:1)
at ProxyZoneSpec.push../node_modules/zone.js/dist/zone-testing.js.ProxyZoneSpec.onInvoke (http://localhost:9877/_karma_webpack_/node_modules/zone.js/dist/zone-testing.js:292:1)
Does anyone have any advice on how to handle this use case or is it currently not supported? I haven't seen any documentation, tutorials or articles which help with this so any assistance would be greatly appreciated.
To recreate the issue above in the example repo you'd have to:
test.ts
@angular/localize/init
in polyfills.ts
I18nLocalizeTemplateComponent
The tests could be executed by using npm run test -- --include src/app/i18n-localize-template/i18n-localize-template.component.spec.ts
Alternatively, use enable-localize-unit-tests
branch then follow step 3.
Notes
polyfills.ts
for the project as per Angular upgrade guide:
import "@angular/localize/init";
4.4.1
) and Jasmine (3.5.9
) for unit testingEnvironment details
_ _ ____ _ ___
/ \ _ __ __ _ _ _| | __ _ _ __ / ___| | |_ _|
/ △ \ | '_ \ / _` | | | | |/ _` | '__| | | | | | |
/ ___ \| | | | (_| | |_| | | (_| | | | |___| |___ | |
/_/ \_\_| |_|\__, |\__,_|_|\__,_|_| \____|_____|___|
|___/
Angular CLI: 9.0.6
Node: 13.2.0
OS: darwin x64
Angular: 9.0.6
... animations, cli, common, compiler, compiler-cli, core, forms
... language-service, localize, platform-browser
... platform-browser-dynamic, router
Ivy Workspace: Yes
Package Version
------------------------------------------------------------
@angular-devkit/architect 0.900.6
@angular-devkit/build-angular 0.900.6
@angular-devkit/build-ng-packagr 0.900.6
@angular-devkit/build-optimizer 0.900.6
@angular-devkit/build-webpack 0.900.6
@angular-devkit/core 9.0.6
@angular-devkit/schematics 9.0.6
@angular/cdk 9.1.3
@ngtools/webpack 9.0.6
@schematics/angular 9.0.6
@schematics/update 0.900.6
ng-packagr 9.0.3
rxjs 6.5.4
typescript 3.7.5
webpack 4.41.2
The solution that I found was to spy on the translate
function of $localize
rather than $localize
itself:
test.ts
const _global: any = typeof global !== "undefined" && global;
const defaultFakedLocalizeTranslate: (messageParts: TemplateStringsArray,
substitutions: readonly any[]) => [TemplateStringsArray, readonly any[]] =
(messageParts: TemplateStringsArray, substitutions: readonly any[]) => [messageParts, substitutions];
_global.mockLocalize = createSpy("mockLocalize") as Spy;
declare global {
const mockLocalize: Spy;
}
$localize.translate = mockLocalize.and.callFake(defaultFakedLocalizeTranslate);
Make sure you have imported @angular/localize/init
in test.ts
.
The I18nLocalizeComponent
unit tests can be updated as follows:
describe('I18nLocalizeComponent', () => {
let component: I18nLocalizeComponent;
let fixture: ComponentFixture<I18nLocalizeComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ I18nLocalizeComponent ],
})
.compileComponents();
}));
beforeEach(() => {
mockLocalize.calls.reset();
fixture = TestBed.createComponent(I18nLocalizeComponent);
component = fixture.componentInstance;
});
it('should call $localize once', () => {
expect(mockLocalize).toHaveBeenCalledTimes(1);
});
});
This change will also allow unit tests of component's with i18n
tags in the template to run successfully as well.
To check out the changes see the fix-localize-unit-tests
branch of the example repo.