A test should test only one thing.
One given behavior is tested in one and only one test.
The tests should be independent of each other, i.e. there is no need to chain them together or run them in any particular order.
A test is incorrectly implemented or the test scenario is meaningless if changes to the HTML structure of the component destroy the test result.
Example: The test fails if an additional input field is added to the form.
it('should check if input fields are rendered on HTML', () => {
const inputFields = element.getElementsByClassName('form-control');
expect(inputFields.length).toBe(4);
expect(inputFields[0]).toBeDefined();
expect(inputFields[1]).toBeDefined();
expect(inputFields[2]).toBeDefined();
});
Instead, use the xdescribe
or xit
feature (just add an x
before the method declaration) to exclude tests.
This way, excluded tests are still visible as skipped and can be repaired later on.
✔️
xdescribe("description", function() {
it("description", function() {
...
});
});
With this approach, the test itself documents the initial behavior of the unit under test.
This is especially true if you are testing whether your action triggers a change: Test for the previous state!
✔️
it('should call the cache when data is available', () => {
// precondition
service.getData();
expect(cacheService.getCachedData).not.toHaveBeenCalled();
<< change cacheService mock to data available >>
// test again
service.getData();
expect(cacheService.getCachedData).toHaveBeenCalled();
});
Testing should not be done for the sake of having tests:
It is easy to always test with toBeTruthy
or toBeFalsy
when you expect something as a return value, but it is better to use stronger assertions like toBeTrue
, toBeNull
or toEqual(12)
.
it('should cache data with encryption', () => {
customCacheService.storeDataToCache('My task is testing', 'task', true);
expect(customCacheService.cacheKeyExists('task')).toBeTruthy();
});
✔️
it('should cache data with encryption', () => {
customCacheService.storeDataToCache('My task is testing', 'task', true);
expect(customCacheService.cacheKeyExists('task')).toBeTrue();
});
Again, do not rely too much on the implementation.
If user customizations can easily break the test code, your assertions are too strong.
it('should test if tags with their text are getting rendered on the HTML', () => {
expect(element.getElementsByTagName('h3')[0].textContent).toContain('We are sorry');
expect(element.getElementsByTagName('p')[0].textContent).toContain(
'The page you are looking for is currently not available'
);
expect(element.getElementsByTagName('h4')[0].textContent).toContain('Please try one of the following:');
expect(element.getElementsByClassName('btn-primary')[0].textContent).toContain('Search');
});
✔️ Same Test in a more Stable Way
it('should test if tags with their text are getting rendered on the HTML', () => {
expect(element.getElementsByClassName('error-text')).toBeTruthy();
expect(element.getElementsByClassName('btn-primary')[0].textContent).toContain('Search');
});
Methods like ngOnInit()
are lifecycle-hook methods which are called by Angular – The test should not call them directly.
When doing component testing, you most likely use TestBed
anyway, so use the detectChanges()
method of your available ComponentFixture
.
it('should call ngOnInit method', () => {
component.ngOnInit();
expect(component.loginForm).toBeDefined();
});
✔️ Test without ngOnInit() Method Call
it('should contain the login form', () => {
fixture.detectChanges();
expect(component.loginForm).not.toBeNull();
});
The test name perfectly describes what the test does.
it ('wishlist test', () => {...})
✔️ Correct Naming
it ('should add a product to an existing wishlist when the button is clicked', () => {...})
Basically, it should read like a documentation of the unit under test, not a documentation of what the test does. Jasmine has named the methods accordingly.
Read it like `I am describing , it should when/if/on <condition/trigger> (because/to )`.
This also applies to assertions.
They should be readable like meaningful sentences.
const result = accountService.isAuthorized();
expect(result).toBeTrue();
✔️
const authorized = accountService.isAuthorized();
expect(authorized).toBeTrue();
or directly
expect(accountService.isAuthorized()).toBeTrue();
Tests should define variables only in the scope where they are needed.
Do not define variables before describe
or respective it
methods.
This increases readability of test cases.
beforeEach
methods.TestBed
, you can handle injection to variables in a separate beforeEach
method.it('should create the app', async(() => {
const fixture = TestBed.createComponent(AppComponent);
const component = fixture.componentInstance;
...
});
it('should have the title "app"', async(() => {
const fixture = TestBed.createComponent(AppComponent);
const component = fixture.componentInstance;
...
});
it('should match the text passed in Header Component', async(() => {
const fixture = TestBed.createComponent(AppComponent);
});
✔️
describe('AppComponent', () => {
let translate: TranslateService;
let fixture: ComponentFixture<AppComponent>;
let component: AppComponent;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ ... ] });
fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
})
it('should create the app', () => { ... });
it(\`should have as title 'app'\`, () => { ... });
it('should match the text passed in Header Component', () => { ... });
});
This increases readability of test cases.
If you do not need the following features, do not use them:
ComponentFixture.debugElement
TestBed
async, fakeAsync
inject
it('should create the app', async(() => {
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
}));
✔️ Same Test - Works Without These Features
it('should be created', () => {
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
The describe
methods in Jasmine are nestable.
You can use this to group various it
methods into a nested describe
where you can also use an additional beforeEach
initialization method.
✔️ Nested describe Methods
describe('AccountLogin Component', () => {
it('should be created', () => { ... });
it('should check if controls are rendered on Login page', () => { ... });
....
describe('Username Field', () => {
it('should be valid when a correct email is assigned', () => { ... });
....
});
});
Declare only what you need.
Unused variables, classes and imports reduce the readability of unit tests.
This way, less code needs to be implemented, which in turn increases the readability of unit tests.
Mocks can also be stubbed on time, depending on the current method.
We decided to use ts-mockito as our test mocking framework.
Use only IDs or definite class names to select DOM elements in tests.
Try to avoid general class names.
const selectedLanguage = element.getElementsByClassName('d-none');
✔️ Correct Selector
// by id
const selectedLanguage = element.querySelector('#language-switch');
// by class
const selectedLanguage = element.getElementsByClassName('language-switch');
Use data-testing-id
via attribute binding to implement an identifier used for testing purpose only.
✔️ Correct Testing ID
*.component.html
<ol class="viewer-nav">
<li *ngFor="let section of sections" [attr.data-testing-id]="section.value">{{ section.text }}</li>
</ol>
*.spec.ts
element.querySelectorAll('[data-testing-id]')[0].innerHTML;
element.querySelectorAll("[data-testing-id='en']").length;
Warning
Do not overuse this feature!
Every component should have a 'should be created' test like the one Angular CLI auto-generates.
This test handles runtime initialization errors.
✔️
it('should be created', () => {
expect(component).toBeTruthy();
expect(element).toBeTruthy();
expect(() => fixture.detectChanges()).not.toThrow();
});
See Three Ways to Test Angular Components for more information.
toBeDefined
Be careful when using toBeDefined
, because a dynamic language like JavaScript has another meaning of defined (see: Is It Defined? toBeDefined, toBeUndefined).
Warning
Do not use toBeDefined
if you really want to check for not null because technically 'null' is defined. Use toBeTruthy
instead.
Jasmine does not automatically reset all your variables for each test like other test frameworks do.
If you initialize directly under describe
, the variable is initialized only once.
Warning
Since tests should be independent of each other, do not do this.
describe('...', () => {
let a = true; // initialized just once
const b = true; // immutable value
let c; // re-initialized in beforeEach
beforeEach(() => {
c = true;
});
it('test1', () => {
a = false;
// b = false; not possible
c = false;
});
it('test2', () => {
// a is still false
// c is back to true
});
});
As shown in the above example, a
shows the wrong way of initializing variables in tests.
If you do not need to change the value, use a const
declaration for primitive variables like b
.
If you need to change the value in some tests, make sure it is reinitialized each time in the beforeEach
method like c
.
A const
declaration like b
should not be used for complex values, as the object behind b
is still mutable and needs to be reinitialized properly:
describe('...', () => {
let a: any;
const b = { value: true };
beforeEach(() => {
a = { value: true };
});
it('test1', () => {
a.value = false;
b.value = false;
});
it('test2', () => {
// a.value is back to true
// b.value is still false
});
});
Testing EventEmitter
firing can be done in several ways, each with advantages and disadvantages.
Consider the following example:
import { EventEmitter } from '@angular/core';
import { anything, capture, deepEqual, spy, verify } from 'ts-mockito';
describe('Emitter', () => {
class Component {
valueChange = new EventEmitter<{ val: number }>();
do() {
this.valueChange.emit({ val: 0 });
}
}
let component: Component;
beforeEach(() => {
component = new Component();
});
it('should detect errors using spy with extract', () => {
// *1*
const emitter = spy(component.valueChange);
component.do();
verify(emitter.emit(anything())).once();
const [arg] = capture(emitter.emit).last();
expect(arg).toEqual({ val: 0 });
});
it('should detect errors using spy with deepEqual', () => {
// *2*
const emitter = spy(component.valueChange);
component.do();
verify(emitter.emit(deepEqual({ val: 0 }))).once();
});
it('should detect errors using subscribe', done => {
// *3*
component.valueChange.subscribe(data => {
expect(data).toEqual({ val: 0 });
done();
});
component.do();
});
});
As EventEmitter
is Observable
, subscribing to it might be the most logical way of testing it.
We, however, would recommend using ts-mockito to increase readability.
Ways 1 and 2 illustrate two options, we would recommend using the first one.
1 (preferred) | 2 | 3 | |
---|---|---|---|
Description | Using ts-mockito spy and then verify it has fired - Then check argument for expected value | Using ts-mockito spy and then verify it has fired with the expected value | Using subscription and asynchronous method safeguard |
Readability | Capturing arguments with ts-mockito might seem tricky and therefore reduces readability, but the test is done in the right order. | ✔️ Right order, fewest lines of code | |
In case it does not emit | ✔️ Correct line number and a missing emission is reported. | ✔️ Correct line number and a missing emission is reported. | |
In case it emits another value | ✔️ Correct line number and an incorrect value is reported. | ✔️ Correct line number and an incorrect value is reported. |