Testing Angular
Testing Angular
1. Introduction
2. Target audience
3. Terminology
4. Testing principles
1. What makes a good test
2. What testing can achieve
3. Tailoring your testing approach
4. The right amount of testing
5. Levels of testing
1. End-to-end tests
2. Unit tests
3. Integration tests
5. Example applications
1. The counter Component
2. The Flickr photo search
8. Faking dependencies
1. Equivalence of fake and original
2. Effective faking
3. Faking functions with Jasmine spies
4. Spying on existing methods
9. Debugging tests
1. Test focus
2. Developer tools
3. Debug output and the JavaScript debugger
4. Inspect the DOM
5. Jasmine debug runner
21. Summary
22. Index of example applications
23. References
24. Acknowledgements
25. About
26. License
Flying probes testing a printed circuit board. Photo by genkur from iStock.
Testing Angular
A Guide to Robust Angular
Applications
Does the site allow the user to complete their tasks? Is the
site still functional after new features have been introduced
or internals have been refactored? How does the site react
to usage errors or system failure? Testing gives answers to
these questions.
COST-EFFECTIVE
PREVENT BREAKAGE
A valuable test fails when essential code is
changed or deleted. Design the test to fail if
dependent behavior is changed. It should still pass if
unrelated code is changed.
DISCOVER BUGS
Once you have learned and applied these tools, you should
not stop. A fixed tool chain will only discover certain types of
bugs. You need to try different approaches to find new
classes of bugs. Likewise, an existing test suite needs to be
updated regularly so that it still finds regressions.
International Software Testing Qualifications Board: Certified Tester
Foundation Level Syllabus, Version 2018 V3.1, Page 16: Seven Testing Principles
NORMALIZE TESTING
MEANINGFUL TESTS
Tests differ in their value and quality. Some tests are more
meaningful than others. If they fail, your application is
actually unusable. This means the quality of tests is
more important than their quantity.
If you write tests for the main features of your app from a
user’s perspective, you can achieve a code coverage of 60-
70%. Every extra percent gain takes more and more time
and bears weird and twisted tests that do not reflect the
actual usage of your application.
Levels of testing
We can distinguish automated tests by their perspective
and proximity to the code.
End-to-end tests
SIMULATE REAL USAGE
END-TO-END TESTS
Unit tests
UNIT TESTS
Integration tests
COHESIVE GROUPS
INTEGRATION TESTS
SPEED
What is indisputable is that high-level tests like end-to-end
tests are expensive and slow, while lower-level tests like
integration and unit tests are cheaper and faster.
RELIABILITY
SETUP COSTS
End-to-end tests use a real browser and run against the full
software stack. Therefore the testing setup is immense. You
need to deploy front-end, back-end, databases, caches, etc.
to testing machines and then have machines to run the end-
to-end tests.
DISTRIBUTION
For this reason, some experts argue you should write few
end-to-end test, a fair amount of integration tests and many
unit tests. If this distribution is visualized, it looks like a
pyramid:
End
to
end
Integration
Unit
DESIGN GUIDE
On the one hand, unit tests are precise and cheap. They are
ideal to specify all tiny details of a shared module. They help
developers to design small, composable modules that “do
one thing and do it well”. This level of testing forces
developers to reconsider how the module interacts with
other modules.
CONFIDENCE
MIDDLE GROUND
Inte‐
Level End-to-End Unit
gration
least most
Reliability reliable
reliable reliable
OUTSIDE
Black box
u t
Outp
INSIDE
IRRELEVANT INTERNALS
RELEVANT BEHAVIOR
PUBLIC API
CHALLENGING TO TEST
STATE MANAGEMENT
First, you enter a search term and start the search. The
Flickr search API is queried. Second, the search results with
thumbnails are rendered. Third, you can select a search
result to see the photo details.
Once you are able to write automatic tests for this example
application, you will be able to test most features of a
typical Angular application.
Angular testing principles
LEARNING OBJECTIVES
Testability
In contrast to other popular front-end JavaScript libraries,
Angular is an opinionated, comprehensive framework that
covers all important aspects of developing a JavaScript web
application. Angular provides high-level structure, low-level
building blocks and means to bundle everything together
into a usable application.
TESTABLE ARCHITECTURE
WELL-STRUCTURED CODE
LOOSE COUPLING
ORIGINAL OR FAKE
Testing tools
Angular provides solid testing tools out of the box. When
you create an Angular project using the command line
interface, it comes with a fully-working testing setup for
unit, integration and end-to-end tests.
BALANCED DEFAULTS
ALTERNATIVES
Testing conventions
Angular offers some tools and conventions on testing. By
design, they are flexible enough to support different ways of
testing. So you need to decide how to apply them.
MAKING CHOICES
The command for starting the unit and integration tests is:
ng test
TEST.TS
MAIN.TS
.SPEC.TS
KARMA
TEST RUNNER
LAUNCHERS
As mentioned, the standard configuration opens Chrome. To
run the tests in other browsers, we need to install different
launchers.
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-firefox-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma'),
],
REPORTERS
And finally:
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('karma-junit-reporter'),
require('@angular-devkit/build-angular/plugins/karma'),
],
JASMINE CONFIGURATION
client: {
// leave Jasmine Spec Runner output visible in browser
clearContext: false,
jasmine: {
// Jasmine configuration goes here!
},
},
client: {
// leave Jasmine Spec Runner output visible in browser
clearContext: false,
jasmine: {
failSpecWithNoExpectations: true,
},
},
DESCRIBE: SUITE
NESTING DESCRIBE
Specifications
IT: SPEC
READABLE SENTENCE
The pronoun it refers to the code under test. it should be
the subject of a human-readable sentence that asserts the
behavior of the code under test. The spec code then proves
this assertion. This style of writing specs originates from the
concept of Behavior-Driven Development (BDD).
Ask yourself, what does the code under test do? For
example, in case of a CounterComponent, it increments the
counter value. And it resets the counter to a specific value.
So you could write:
NO “SHOULD”
Some people prefer to write it('should increment the
count', /* … */), but should bears no additional
meaning. The nature of a spec is to state what the code
under test should do. The word “should” is redundant and
just makes the sentence longer. This guide recommends to
simply state what the code does.
Structure of a test
Inside the it block lies the actual testing code. Irrespective
of the testing framework, the testing code typically consists
of three phases: Arrange, Act and Assert.
1. Arrange:
2. Act:
3. Assert:
STRUCTURE A TEST
Expectations
In the Assert phase, the test compares the actual output or
return value to the expected output or return value. If they
are the same, the test passes. If they differ, the test fails.
Let us examine a simple contrived example, an add
function:
A primitive test without any testing tools could look like this:
const expectedValue = 5;
const actualValue = add(2, 3);
if (expectedValue !== actualValue) {
throw new Error(
`Wrong return value: ${actualValue}. Expected:
${expectedValue}`
);
}
EXPECT
const expectedValue = 5;
const actualValue = add(2, 3);
expect(actualValue).toBe(expectedValue);
// Passes, the two objects are not identical but deeply equal
expect({ name: 'Linda' }).toEqual({ name: 'Linda' });
READABLE SENTENCE
The pattern
expect(actualValue).toEqual(expectedValue)
originates from Behavior-Driven Development (BDD) again.
The expect function call and the matcher methods form a
human-readable sentence: “Expect the actual value to
equal the expected value.” The goal is to write a
specification that is as readable as a plain text but can be
verified automatically.
REPETITIVE SETUP
beforeEach(() => {
console.log('Called before each spec is run');
});
afterEach(() => {
console.log('Called after each spec is run');
});
FAKING SAFELY
REPLACEABILITY
TYPE EQUIVALENCE
CALL RECORD
CREATESPY
class TodoService {
constructor(
// Bind `fetch` to `window` to ensure that `window` is the
`this` context
private fetch = window.fetch.bind(window)
) {}
INJECT FAKE
describe('TodoService', () => {
it('gets the to-dos', async () => {
// Arrange
const fetchSpy = jasmine.createSpy('fetch')
.and.returnValue(okResponse);
const todoService = new TodoService(fetchSpy);
// Act
const actualTodos = await todoService.getTodos();
// Assert
expect(actualTodos).toEqual(todos);
expect(fetchSpy).toHaveBeenCalledWith('/todos');
});
});
const todos = [
'shop groceries',
'mow the lawn',
'take the cat to the vet'
];
const okResponse = new Response(JSON.stringify(todos), {
status: 200,
statusText: 'OK',
});
FAKE RESPONSE
// Arrange
const fetchSpy = jasmine.createSpy('fetch')
.and.returnValue(okResponse);
const todoService = new TodoService(fetchSpy);
INJECT SPY
expect(actualTodos).toEqual(todos);
expect(fetchSpy).toHaveBeenCalledWith('/todos');
DATA PROCESSING
Second, we verify that the fetch spy has been called with
the correct parameter, the API endpoint URL. Jasmine offers
several matchers for making expectations on spies. The
example uses toHaveBeenCalledWith to assert that the spy
has been called with the parameter '/todos'.
if (!response.ok) {
throw new Error(
`HTTP error: ${response.status} ${response.statusText}`
);
}
The fake okResponse mimics the success case. For the error
case, we need to define another fake Response. Let us call it
errorResponse with the notorious HTTP status 404 Not
Found:
describe('TodoService', () => {
/* … */
it('handles an HTTP error when getting the to-dos', async () => {
// Arrange
const fetchSpy = jasmine.createSpy('fetch')
.and.returnValue(errorResponse);
const todoService = new TodoService(fetchSpy);
// Act
let error;
try {
await todoService.getTodos();
} catch (e) {
error = e;
}
// Assert
expect(error).toEqual(new Error('HTTP error: 404 Not Found'));
expect(fetchSpy).toHaveBeenCalledWith('/todos');
});
});
CATCHING ERRORS
In the Act phase, we call the method under test but
anticipate that it throws an error. In Jasmine, there are
several ways to test whether a Promise has been rejected
with an error. The example above wraps the getTodos call in
a try/catch statement and saves the error. Most likely, this
is how implementation code would handle the error.
spyOn(window, 'fetch');
spyOn(window, 'fetch')
.and.returnValue(okResponse);
describe('TodoService', () => {
it('gets the to-dos', async () => {
// Arrange
spyOn(window, 'fetch')
.and.returnValue(okResponse);
const todoService = new TodoService();
// Act
const actualTodos = await todoService.getTodos();
// Assert
expect(actualTodos).toEqual(todos);
expect(window.fetch).toHaveBeenCalledWith('/todos');
});
});
Test focus
Some tests require an extensive Arrange phase, the Act
phase calls several methods or simulates complex user
input. These tests are hard to debug.
FDESCRIBE
If you want Jasmine to run only this test suite and skip all
others, change describe to fdescribe:
FIT
Developer tools
The Jasmine test runner is just another web page made with
HTML, CSS and JavaScript. This means you can debug it in
the browser using the developer tools.
VERSATILE CONSOLE.LOG
Did the test call the class, method, function under test
correctly?
DEBUGGER
Some people prefer to use debugger instead of console
output.
ASYNC LOGGING
LOG A SNAPSHOT
ROOT ELEMENT
The Component’s root element is rendered into the last
element in the document, below the Jasmine reporter
output. Make sure to set a focus on a single spec to see the
rendered Component.
COUNTER FEATURES
When the user enters a number into the reset input field
and activates the reset button, the count is set to the
given value.
TestBed
Several chores are necessary to render a Component in
Angular, even the simple counter Component. If you look
into the main.ts and the AppModule of a typical Angular
application, you find that a “platform” is created, a Module
is declared and this Module is bootstrapped.
The Angular compiler translates the templates into
JavaScript code. To prepare the rendering, an instance of the
Component is created, dependencies are resolved and
injected, inputs are set.
TESTBED
In a unit test, add those parts to the Module that are strictly
necessary: the code under test, mandatory dependencies
and fakes. For example, when writing a unit test for
CounterComponent, we need to declare that Component
class. Since the Component does not have dependencies,
does not render other Components, Directives or Pipes, we
are done.
TestBed.configureTestingModule({
declarations: [CounterComponent],
});
TestBed.compileComponents();
TestBed
.configureTestingModule({
declarations: [CounterComponent],
})
.compileComponents();
You will see this pattern in most Angular tests that rely on
the TestBed.
fixture.detectChanges();
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [CounterComponent],
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
fixture.detectChanges();
});
it('…', () => {
/* … */
});
});
ASYNC COMPILATION
If you are using the Angular CLI, which is most likely, the
template files are already included in the test bundle. So
they are available instantly. If you are not using the CLI, the
files have to be loaded asynchronously.
ComponentFixture and
DebugElement
TestBed.createComponent(CounterComponent) returns a
fixture, an instance of ComponentFixture. What is the
fixture and what does it provide?
The term fixture is borrowed from real-world testing of
mechanical parts or electronic devices. A fixture is a
standardized frame into which the test object is mounted.
The fixture holds the object and connects to electrical
contacts in order to provide power and to take
measurements.
COMPONENT FIXTURE
// This is a ComponentFixture<CounterComponent>
const component = fixture.componentInstance;
// Set Input
component.startCount = 10;
// Subscribe to Output
component.countChange.subscribe((count) => {
/* … */
});
We will learn more on testing Inputs and Outputs later.
DEBUGELEMENT
NATIVEELEMENT
Now let us roll up our sleeves and write the first spec! The
main feature of our little counter is the ability to increment
the count. Hence the spec:
BY.CSS
Sometimes the element type and the class are crucial for
the feature under test. But most of the time, they are not
relevant for the feature. The test should better find the
element by a feature that never changes and that bears no
additional meaning: test ids.
TEST IDS
DATA-TESTID
ESTABLISH A CONVENTION
EVENT OBJECT
incrementButton.triggerEventHandler('click', null);
NO BUBBLING
FIND BY TEST ID
In our test, we need to find this element and read its text
content. For this purpose, we add a test id:
TEXT CONTENT
countOutput.nativeElement.textContent
expect(countOutput.nativeElement.textContent).toBe('1');
/* Incomplete! */
describe('CounterComponent', () => {
let fixture: ComponentFixture<CounterComponent>;
let debugElement: DebugElement;
// Arrange
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [CounterComponent],
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
fixture.detectChanges();
debugElement = fixture.debugElement;
});
// Assert
const countOutput = debugElement.query(
By.css('[data-testid="count"]')
);
expect(countOutput.nativeElement.textContent).toBe('1');
});
});
fixture.detectChanges();
// Arrange
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [CounterComponent],
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
fixture.detectChanges();
debugElement = fixture.debugElement;
});
// Assert
const countOutput = debugElement.query(
By.css('[data-testid="count"]')
);
expect(countOutput.nativeElement.textContent).toBe('1');
});
});
Testing helpers
The next CounterComponent feature we need to test is the
decrement button. It is very similar to the increment button,
so the spec looks almost the same.
// Assert
const countOutput = debugElement.query(
By.css('[data-testid="count"]')
);
expect(countOutput.nativeElement.textContent).toBe('-1');
});
There is nothing new here, only the test id, the variable
names and the expected output changed.
REPEATING PATTERNS
Now we have two specs that are almost identical. The code
is repetitive and the signal-to-noise ratio is low, meaning
there is much code that does little. Let us identify the
patterns repeated here:
TESTING HELPERS
FIND BY TEST ID
function findEl<T>(
fixture: ComponentFixture<T>,
testId: string
): DebugElement {
return fixture.debugElement.query(
By.css(`[data-testid="${testId}"]`)
);
}
This function is self-contained. We need to pass in the
Component fixture explicitly. Since ComponentFixture<T>
requires a type parameter – the wrapped Component type –,
findEl also has a type parameter called T. TypeScript will
infer the Component type automatically when you pass a
ComponentFixture.
CLICK
// Assert
expectText(fixture, 'count', '-1');
});
That is much better to read and less to write! You can tell
what the spec is doing at first glance.
The full spec for the reset feature then looks like this:
// Assert
expectText(fixture, 'count', newCount);
});
HELPER FUNCTIONS
You can find the full source code of the involved helper
functions in element.spec-helper.ts.
// Act
setFieldValue(fixture, 'reset-input', newCount);
click(fixture, 'reset-button');
fixture.detectChanges();
// Assert
expectText(fixture, 'count', newCount);
});
INVALID INPUT
// Act
setFieldValue(fixture, 'reset-input', value);
click(fixture, 'reset-button');
fixture.detectChanges();
// Assert
expectText(fixture, 'count', startCount);
});
This is it! We have tested the reset form with both valid and
invalid input.
Testing Inputs
CounterComponent has an Input startCount that sets the
initial count. We need to test that the counter handles the
Input properly.
/* Incomplete! */
beforeEach(async () => {
/* … */
NGONCHANGES
What is wrong here? Did we forget to call detectChanges
again? No, but we forgot to call ngOnChanges!
describe('CounterComponent', () => {
let component: CounterComponent;
let fixture: ComponentFixture<CounterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [CounterComponent],
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
component.startCount = startCount;
// Call ngOnChanges, then re-render
component.ngOnChanges();
fixture.detectChanges();
});
/* … */
Testing Outputs
While Inputs pass data from parent to child, Outputs send
data from child to parent. In combination, a Component can
perform a specific operation just with the required data.
SUBSCRIBE TO OBSERVABLE
// Act
click(fixture, 'increment-button');
});
// Act
click(fixture, 'increment-button');
// Assert
expect(actualCount).toBe(1);
});
The click on the button emits the count and calls the
observer function synchronously. That is why the next line of
code can expect that actualCount has been changed.
You might wonder why we did not put the expect call in the
observer function:
/* Not recommended! */
it('emits countChange events on increment', () => {
// Arrange
component.countChange.subscribe((count: number) => {
// Assert
expect(count).toBe(1);
});
// Act
click(fixture, 'increment-button');
});
// Act
click(fixture, 'decrement-button');
// Assert
expect(actualCount).toBe(-1);
});
// Act
setFieldValue(fixture, 'reset-input', newCount);
click(fixture, 'reset-button');
// Assert
expect(actualCount).toBe(newCount);
});
// Act
click(fixture, 'increment-button');
click(fixture, 'decrement-button');
setFieldValue(fixture, 'reset-input', String(newCount));
click(fixture, 'reset-button');
// Assert
expect(actualCounts).toEqual([1, 0, newCount]);
});
This example requires some RxJS knowledge. We are going
to encounter RxJS Observables again and again when
testing Angular applications. If you do not understand the
example above, that is totally fine. It is just an optional way
to merge three specs into one.
@Input()
public startCount = 0;
@Output()
public countChange = new EventEmitter<number>();
They form the public API. However, there are several more
properties and methods that are public:
public count = 0;
public increment(): void { /* … */ }
public decrement(): void { /* … */ }
public reset(newCount: string): void { /* … */ }
/* Not recommended! */
describe('CounterComponent', () => {
/* … */
it('increments the count', () => {
component.increment();
fixture.detectChanged();
expectText(fixture, 'count', '1');
});
});
The white box spec above calls the increment method, but
does not test the corresponding template code, the
increment button:
RECOMMENDATION
Private properties
No access
and methods
Testing Components with
children
LEARNING OBJECTIVES
PRESENTATIONAL COMPONENTS
CONTAINER COMPONENTS
<app-counter
[startCount]="5"
(countChange)="handleCountChange($event)"
></app-counter>
<!-- … -->
<app-service-counter></app-service-counter>
<!-- … -->
<app-ngrx-counter></app-ngrx-counter>
RENDER CHILDREN
TEST COOPERATION
Unit test
Let us write a unit test for HomeComponent first. The setup
looks familiar to the CounterComponent test suite. We are
using TestBed to configure a testing Module and to render
the Component under test.
describe('HomeComponent', () => {
let fixture: ComponentFixture<HomeComponent>;
let component: HomeComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HomeComponent],
}).compileComponents();
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
SMOKE TEST
This suite has one spec that acts as a smoke test. It checks
the presence of a Component instance. It does not assert
anything specific about the Component behavior yet. It
merely proves that the Component renders without errors.
<app-counter
[startCount]="5"
(countChange)="handleCountChange($event)"
></app-counter>
CHILD PRESENCE
FINDCOMPONENT
CHECK INPUTS
That was quite easy! Last but not least, we need to test the
Output.
OUTPUT EVENT
<app-counter
[startCount]="5"
(countChange)="handleCountChange($event)"
></app-counter>
The handleCountChange method is defined in the
Component class. It simply calls console.log to prove that
the child-parent communication worked:
OUTPUT EFFECT
spyOn(console, 'log');
In the Assert phase, we expect that the spy has been called
with a certain text and the number the Output has emitted.
<app-service-counter></app-service-counter>
<!-- … -->
<app-ngrx-counter></app-ngrx-counter>
CHILD PRESENCE
@Component({
selector: 'app-counter',
template: '',
})
class FakeCounterComponent implements Partial<CounterComponent> {
@Input()
public startCount = 0;
@Output()
public countChange = new EventEmitter<number>();
}
TestBed.configureTestingModule({
declarations: [HomeComponent, FakeCounterComponent],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
FIND BY DIRECTIVE
CHILD PRESENCE
expect(counter).toBeTruthy();
});
CHECK INPUTS
expect(counter.startCount).toBe(5);
});
EMIT OUTPUT
spyOn(console, 'log');
const count = 5;
counter.countChange.emit(5);
expect(console.log).toHaveBeenCalledWith(
'countChange event from CounterComponent',
count,
);
});
@Component({
selector: 'app-counter',
template: '',
})
class FakeCounterComponent implements Partial<CounterComponent> {
@Input()
public startCount = 0;
@Output()
public countChange = new EventEmitter<number>();
}
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HomeComponent, FakeCounterComponent],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
ADVANTAGES
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HomeComponent, MockComponent(CounterComponent)],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HomeComponent,
MockComponent(CounterComponent)],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
/* … */
});
TYPE EQUIVALENCE
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HomeComponent, Mock(CounterComponent)],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ServiceCounterComponent],
providers: [CounterService],
}).compileComponents();
fixture = TestBed.createComponent(ServiceCounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
providers: [CounterService],
PROVIDE SERVICE
class CounterService {
public getCount(): Observable<number> { /* … */ }
public increment(): void { /* … */ }
public decrement(): void { /* … */ }
public reset(newCount: number): void { /* … */ }
private notify(): void { /* … */ }
}
FAKE INSTANCE
TYPE EQUIVALENCE
ERRONEOUS CODE
// Error!
const fakeCounterService: CounterService = {
getCount() {
return of(currentCount);
},
increment() {},
decrement() {},
reset() {},
};
Unfortunately, this does not work. TypeScript complains that
private methods and properties are missing:
const fakeCounterService:
Pick<CounterService, keyof CounterService> = {
getCount() {
return of(currentCount);
},
increment() {},
decrement() {},
reset() {},
};
If the code under test does not use the full API, the fake
does not need to replicate the full API either. Only declare
those methods and properties the code under test actually
uses.
For example, if the code under test only calls getCount, just
provide this method. Make sure to add a type declaration
that picks the method from the original type:
Pick and other mapped types help to bind the fake to the
original type in a way that TypeScript can check the
equivalence.
SPY ON METHODS
Jasmine spies are suitable for this job. A first approach fills
the fake with standalone spies:
const fakeCounterService:
Pick<CounterService, keyof CounterService> = {
getCount:
jasmine.createSpy('getCount').and.returnValue(of(currentCount)),
increment: jasmine.createSpy('increment'),
decrement: jasmine.createSpy('decrement'),
reset: jasmine.createSpy('reset'),
};
CREATESPYOBJ
TYPE EQUIVALENCE
Let us put our fake to work. In the Arrange phase, the fake is
created and injected into the testing Module.
beforeEach(async () => {
// Create fake
fakeCounterService = jasmine.createSpyObj<CounterService>(
'CounterService',
{
getCount: of(currentCount),
increment: undefined,
decrement: undefined,
reset: undefined,
}
);
await TestBed.configureTestingModule({
declarations: [ServiceCounterComponent],
// Use fake instead of original
providers: [
{ provide: CounterService, useValue: fakeCounterService }
],
}).compileComponents();
fixture = TestBed.createComponent(ServiceCounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
/* … */
});
providers: [
{ provide: CounterService, useValue: fakeCounterService }
]
VERIFY SPIES
expect(fakeCounterService.getCount).toHaveBeenCalled();
beforeEach(async () => {
// Create fake
fakeCounterService = jasmine.createSpyObj<CounterService>(
'CounterService',
{
getCount: of(currentCount),
increment: undefined,
decrement: undefined,
reset: undefined,
}
);
await TestBed.configureTestingModule({
declarations: [ServiceCounterComponent],
// Use fake instead of original
providers: [
{ provide: CounterService, useValue: fakeCounterService }
],
}).compileComponents();
fixture = TestBed.createComponent(ServiceCounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
expect(fakeCounterService.reset).toHaveBeenCalledWith(newCount);
});
});
COMPONENT UPDATE
BEHAVIORSUBJECT
beforeEach(async () => {
fakeCount$ = new BehaviorSubject(0);
/* … */
});
/* … */
});
SPY ON METHODS
spyOn(fakeCounterService, 'getCount').and.callThrough();
spyOn(fakeCounterService, 'increment').and.callThrough();
spyOn(fakeCounterService, 'decrement').and.callThrough();
spyOn(fakeCounterService, 'reset').and.callThrough();
fixture.detectChanges();
expectText(fixture, 'count', '…');
beforeEach(async () => {
fakeCount$ = new BehaviorSubject(0);
fakeCounterService = {
getCount(): Observable<number> {
return fakeCount$;
},
increment(): void {
fakeCount$.next(1);
},
decrement(): void {
fakeCount$.next(-1);
},
reset(): void {
fakeCount$.next(Number(newCount));
},
};
spyOn(fakeCounterService, 'getCount').and.callThrough();
spyOn(fakeCounterService, 'increment').and.callThrough();
spyOn(fakeCounterService, 'decrement').and.callThrough();
spyOn(fakeCounterService, 'reset').and.callThrough();
await TestBed.configureTestingModule({
declarations: [ServiceCounterComponent],
providers: [
{ provide: CounterService, useValue: fakeCounterService }
],
}).compileComponents();
fixture = TestBed.createComponent(ServiceCounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
expect(fakeCounterService.reset).toHaveBeenCalledWith(newCount);
});
});
TESTABLE SERVICES
GUIDELINES
SIGN-UP FORM
IMPRACTICAL
REACTIVE FORM
@Component({
selector: 'app-signup-form',
templateUrl: './signup-form.component.html',
styleUrls: ['./signup-form.component.scss'],
})
export class SignupFormComponent {
/* … */
public form = this.formBuilder.group({
plan: ['personal', required],
username: [
null,
[required, pattern('[a-zA-Z0-9.]+'), maxLength(50)],
(control: AbstractControl) =>
this.validateUsername(control.value),
],
email: [
null,
[required, email, maxLength(100)],
(control: AbstractControl) =>
this.validateEmail(control.value),
],
password: [
null,
required,
() => this.validatePassword()
],
tos: [null, requiredTrue],
address: this.formBuilder.group({
name: [null, required],
addressLine1: [null],
addressLine2: [null, required],
city: [null, required],
postcode: [null, required],
region: [null],
country: [null, required],
}),
});
/* … */
constructor(
private signupService: SignupService,
private formBuilder: FormBuilder) {
/* … */
}
/* … */
}
The form controls are declared with their initial values and
their validators. For example, the password control:
password: [
// The initial value (null means empty)
null,
// The synchronous validator
required,
// The asynchronous validator
() => this.validatePassword()
],
FORM SUBMISSION
When the form is filled out correctly and all validations pass,
the user is able to submit to the form. It produces an object
described by the SignupData interface:
const {
email, maxLength, pattern, required, requiredTrue
} = Validators;
For the username, the email and the password, there are
custom asynchronous validators. They check whether the
username and email are available and whether the
password is strong enough.
ERROR RENDERING
ARIA ATTRIBUTES
<ng-template let-errors>
<ng-container *ngIf="errors.required">
Name must be given.
</ng-container>
</ng-template>
Test plan
What are the important parts of the sign-up form that need
to be tested?
1. Form submission
Successful submission
Submission failure
2. Required fields are marked as such and display error
messages
Test setup
Before writing the individual specs, we need to set up the
suite in signup-form.component.spec.ts. Let us start with
the testing Module configuration.
await TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [
SignupFormComponent,
ControlErrorsComponent,
ErrorMessageDirective
],
providers: [
{ provide: SignupService, useValue: signupService }
],
}).compileComponents();
imports: [ReactiveFormsModule],
DEEP RENDERING
declarations: [
SignupFormComponent,
ControlErrorsComponent,
ErrorMessageDirective
],
FAKE SERVICE
providers: [
{ provide: SignupService, useValue: signupService }
],
const signupService:
Pick<SignupService, keyof SignupService> = {
isUsernameTaken() {
return of(false);
},
isEmailTaken() {
return of(false);
},
getPasswordStrength() {
return of(strongPassword);
},
signup() {
return of({ success: true });
},
};
CREATESPYOBJ
SETUP FUNCTION
describe('SignupFormComponent', () => {
let fixture: ComponentFixture<SignupFormComponent>;
let signupService: jasmine.SpyObj<SignupService>;
await TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [
SignupFormComponent,
ControlErrorsComponent,
ErrorMessageDirective
],
providers: [
{ provide: SignupService, useValue: signupService }
],
}).compileComponents();
fixture = TestBed.createComponent(SignupFormComponent);
fixture.detectChanges();
};
/* … */
});
await setup({
// Let the API return that the username is taken
isUsernameTaken: of(true),
});
Such a setup function is just one way to create fakes and
avoid repetition. You might come up with a different solution
that serves the same purpose.
TEST DATA
The first step is to define valid test data we can fill into the
form. We put this in a separate file, signup-data.spec-
helper.ts:
/* … */
});
fillForm();
/* … */
});
Let us try to submit the form immediately after. The form
under test listens for an ngSubmit event at the form
element. This boils down to a native submit event.
SUBMIT FORM
Then we expect the signup spy to have been called with the
entered data.
fillForm();
expect(signupService.signup).toHaveBeenCalledWith(signupData);
});
The spec fails because the form is still in the invalid state
even though we have filled out all fields correctly.
ASYNC VALIDATORS
The cause are the asynchronous validators for username,
email and password. When the user stops typing into these
fields, they wait for one second before sending a request to
the server.
fillForm();
fillForm();
expect(signupService.isUsernameTaken).toHaveBeenCalledWith(username
);
expect(signupService.isEmailTaken).toHaveBeenCalledWith(email);
expect(signupService.getPasswordStrength).toHaveBeenCalledWith(pass
word);
expect(signupService.signup).toHaveBeenCalledWith(signupData);
}));
SUBMIT BUTTON
Next, we make sure that the submit button is disabled
initially. After successful validation, the button is enabled.
(The submit button carries the test id submit.)
STATUS MESSAGE
fillForm();
fixture.detectChanges();
expect(findEl(fixture, 'submit').properties.disabled).toBe(true);
expect(findEl(fixture,
'submit').properties.disabled).toBe(false);
expect(signupService.getPasswordStrength).toHaveBeenCalledWith(pass
word);
expect(signupService.signup).toHaveBeenCalledWith(signupData);
}));
Invalid form
Now that we have tested the successful form submission, let
us check the handling of an invalid form. What happens if
we do not fill out any fields, but submit the form?
expect(signupService.isUsernameTaken).not.toHaveBeenCalled();
expect(signupService.isEmailTaken).not.toHaveBeenCalled();
expect(signupService.getPasswordStrength).not.toHaveBeenCalled();
expect(signupService.signup).not.toHaveBeenCalled();
}));
This spec does less than the previous. We wait for a second
and submit the form without entering data. Finally, we
expect that no SignupService method has been called.
OBSERVABLE
We fill out the form, wait for the validators and submit the
form.
fillForm();
STATUS MESSAGE
expect(signupService.isUsernameTaken).toHaveBeenCalledWith(username
);
expect(signupService.getPasswordStrength).toHaveBeenCalledWith(pass
word);
expect(signupService.signup).toHaveBeenCalledWith(signupData);
fillForm();
expect(signupService.isUsernameTaken).toHaveBeenCalledWith(username
);
expect(signupService.getPasswordStrength).toHaveBeenCalledWith(pass
word);
expect(signupService.signup).toHaveBeenCalledWith(signupData);
}));
Required fields
A vital form logic is that certain fields are required and that
the user interface conveys the fact clearly. Let us write a
spec that checks whether required fields as marked as such.
REQUIREMENTS
The requirements are:
const requiredFields = [
'username',
'email',
'name',
'addressLine2',
'city',
'postcode',
'country',
'tos',
];
MARK AS TOUCHED
We can now write the Arrange and Act phases of the spec:
/* … */
});
A forEach loop walks through the required field test ids,
finds the element and marks the field as touched. We call
detectChanges afterwards so the error messages appear.
ARIA-REQUIRED
requiredFields.forEach((testId) => {
const el = findEl(fixture, testId);
/* … */
});
ARIA-ERRORMESSAGE
The next part tests the error message with three steps:
1. Read the aria-errormessage attribute. Expect that it is
set.
TYPE ASSERTIONS
ERROR MESSAGE
requiredFields.forEach((testId) => {
const el = findEl(fixture, testId);
Asynchronous validators
The sign-up form features asynchronous validators for
username, email and password. They are asynchronous
because they wait for a second and make an HTTP request.
Under the hood, they are implemented using RxJS
Observables.
/* … */
};
The rest is the same for all three specs. Here is the first
spec:
fillForm();
expect(findEl(fixture, 'submit').properties.disabled).toBe(true);
expect(signupService.isUsernameTaken).toHaveBeenCalledWith(username
);
expect(signupService.isEmailTaken).toHaveBeenCalledWith(email);
expect(signupService.getPasswordStrength).toHaveBeenCalledWith(pass
word);
expect(signupService.signup).not.toHaveBeenCalled();
}));
We fill out the form, wait for the async validators and try to
submit the form.
/* … */
});
expect(addressLine1El.attributes['aria-required']).toBe('true');
expect(addressLine1El.classes['ng-invalid']).toBe(true);
expect(addressLine1El.attributes['aria-required']).toBe('true');
expect(addressLine1El.classes['ng-invalid']).toBe(true);
expect(addressLine1El.attributes['aria-required']).toBe('true');
expect(addressLine1El.classes['ng-invalid']).toBe(true);
expect(addressLine1El.attributes['aria-required']).toBe('true');
expect(addressLine1El.classes['ng-invalid']).toBe(true);
});
<button
type="button"
(click)="showPassword = !showPassword"
>
{{ showPassword ? ' Hide password' : ' Show password' }}
</button>
/* … */
});
Initially, the field has the password type so the entered text
is obfuscated. Let us test this baseline.
Now we click on the toggle button for the first time. We let
Angular update the DOM and check the input type again.
click(fixture, 'show-password');
fixture.detectChanges();
expect(passwordEl.attributes.type).toBe('text');
click(fixture, 'show-password');
fixture.detectChanges();
expect(passwordEl.attributes.type).toBe('password');
click(fixture, 'show-password');
fixture.detectChanges();
expect(passwordEl.attributes.type).toBe('text');
click(fixture, 'show-password');
fixture.detectChanges();
expect(passwordEl.attributes.type).toBe('password');
});
ACCESSIBLE FORM
pa11y
TESTS IN CHROME
CLI VS. CI
pa11y http://localhost:4200/
For the sign-up form, pa11y does not report any errors:
Welcome to Pa11y
No issues found!
ERROR REPORT
If one of the form fields did not have a proper label, pa11y
would complain:
pa11y-ci
{
"defaults": {
"runner": [
"axe",
"htmlcs"
]
},
"urls": [
"http://localhost:4200"
]
}
TEST MULTIPLE URLS
npx pa11y-ci
NPM SCRIPTS
{
"scripts": {
"a11y": "start-server-and-test start http-get://localhost:4200/
pa11y-ci",
"pa11y-ci": "pa11y-ci"
},
}
start-server-and-test: Starts server, waits for URL, then runs test command
Form accessibility: Summary
pa11y is a powerful set of tools with many options. We have
barely touched on its features.
STRUCTURAL WEAKNESSES
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [FullPhotoComponent],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(FullPhotoComponent);
component = fixture.componentInstance;
component.photo = photo1;
fixture.detectChanges();
});
COMPONENT FACTORY
/* … */
});
CREATE COMPONENT
beforeEach(() => {
spectator = createComponent({ props: { photo: photo1 } });
});
/* … */
});
SPECTATOR
First, the spec finds the element with the test id full-
photo-title and expects it to contain the photo’s title.
expect(
spectator.query(byTestId('full-photo-title'))
).toHaveText(photo1.title);
SPECTATOR.QUERY
spectator.query(byTestId('full-photo-title'))
JASMINE MATCHERS
expect(
spectator.query(byTestId('full-photo-title'))
).toHaveText(photo1.title);
import {
byTestId, createComponentFactory, Spectator
} from '@ngneat/spectator';
beforeEach(() => {
spectator = createComponent({ props: { photo: photo1 } });
});
it('renders the photo information', () => {
expect(
spectator.query(byTestId('full-photo-title'))
).toHaveText(photo1.title);
expect(
spectator.query(byTestId('full-photo-ownername'))
).toHaveText(photo1.ownername);
expect(
spectator.query(byTestId('full-photo-datetaken'))
).toHaveText(photo1.datetaken);
expect(
spectator.query(byTestId('full-photo-tags'))
).toHaveText(photo1.tags);
<app-search-form (search)="handleSearch($event)"></app-search-form>
<div class="photo-list-and-full-photo">
<app-photo-list
[title]="searchTerm"
[photos]="photos"
(focusPhoto)="handleFocusPhoto($event)"
class="photo-list"
></app-photo-list>
<app-full-photo
*ngIf="currentPhoto"
[photo]="currentPhoto"
class="full-photo"
data-testid="full-photo"
></app-full-photo>
</div>
@Component({
selector: 'app-flickr-search',
templateUrl: './flickr-search.component.html',
styleUrls: ['./flickr-search.component.css'],
})
export class FlickrSearchComponent {
public searchTerm = '';
public photos: Photo[] = [];
public currentPhoto: Photo | null = null;
CHILD COMPONENTS
3. The search term and the photo list are passed down to
the PhotoListComponent via Inputs.
describe('FlickrSearchComponent', () => {
let fixture: ComponentFixture<FlickrSearchComponent>;
let component: FlickrSearchComponent;
let fakeFlickrService: Pick<FlickrService, keyof FlickrService>;
beforeEach(async () => {
fakeFlickrService = {
searchPublicPhotos: jasmine
.createSpy('searchPublicPhotos')
.and.returnValue(of(photos)),
};
await TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
declarations: [FlickrSearchComponent],
providers: [
{ provide: FlickrService, useValue: fakeFlickrService }
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(FlickrSearchComponent);
component = fixture.debugElement.componentInstance;
fixture.detectChanges();
searchForm = findComponent(fixture, 'app-search-form');
photoList = findComponent(fixture, 'app-photo-list');
});
it('renders the search form and the photo list, not the full
photo', () => {
expect(searchForm).toBeTruthy();
expect(photoList).toBeTruthy();
expect(photoList.properties.title).toBe('');
expect(photoList.properties.photos).toEqual([]);
expect(() => {
findComponent(fixture, 'app-full-photo');
}).toThrow();
});
expect(fakeFlickrService.searchPublicPhotos).toHaveBeenCalledWith(s
earchTerm);
expect(photoList.properties.title).toBe(searchTerm);
expect(photoList.properties.photos).toBe(photos);
});
fixture.detectChanges();
WITH SPECTATOR
import {
createComponentFactory, mockProvider, Spectator
} from '@ngneat/spectator';
/* … */
});
MOCKPROVIDER
import {
createComponentFactory, mockProvider, Spectator
} from '@ngneat/spectator';
beforeEach(() => {
spectator = createComponent();
spectator.inject(FlickrService).searchPublicPhotos.and.returnValue(
of(photos));
searchForm = spectator.query(SearchFormComponent);
photoList = spectator.query(PhotoListComponent);
fullPhoto = spectator.query(FullPhotoComponent);
});
/* … */
});
FIND CHILDREN
it('renders the search form and the photo list, not the full
photo', () => {
if (!(searchForm && photoList)) {
throw new Error('searchForm or photoList not found');
}
expect(photoList.title).toBe('');
expect(photoList.photos).toEqual([]);
expect(fullPhoto).not.toExist();
});
expect(() => {
findComponent(fixture, 'app-full-photo');
}).toThrow();`.
spectator.detectChanges();
expect(flickrService.searchPublicPhotos).toHaveBeenCalledWith(searc
hTerm);
expect(photoList.title).toBe(searchTerm);
expect(photoList.photos).toBe(photos);
});
if (!photoList) {
throw new Error('photoList not found');
}
photoList.focusPhoto.emit(photo1);
spectator.detectChanges();
fullPhoto = spectator.query(FullPhotoComponent);
if (!fullPhoto) {
throw new Error('fullPhoto not found');
}
expect(fullPhoto.photo).toBe(photo1);
});
SYNTHETIC EVENTS
SPECTATOR.CLICK
spectator.click(byTestId('photo-item-link'));
expect(photo).toBe(photo1);
});
/* … */
});
SPECTATOR. TYPEINELEMENT
spectator.component.search.subscribe((otherSearchTerm: string)
=> {
actualSearchTerm = otherSearchTerm;
});
spectator.typeInElement(searchTerm, byTestId('search-term-
input'));
spectator.dispatchFakeEvent(byTestId('form'), 'submit');
expect(actualSearchTerm).toBe(searchTerm);
});
});
DISPATCH NGSUBMIT
The spec simulates typing the search term into the search
field. Then it simulates an ngSubmit event at the form
element. We use the generic method
spectator.dispatchFakeEvent for this end.
Spectator: Summary
Spectator is a mature library that addresses the practical
needs of Angular developers. It offers solutions for common
Angular testing problems. The examples above presented
only a few of Spectator’s features.
Test code should be both concise and easy to understand.
Spectator provides an expressive, high-level language for
writing Angular tests. Spectator makes simple tasks simple
without losing any power.
Once you are familiar with the standard tools, you should try
out alternatives like Spectator and ng-mocks. Then decide
whether you stick with isolated testing helpers or switch to
more comprehensive testing libraries.
SINGLETON
INJECTABLE
RESPONSIBILITIES
class CounterService {
private count: number;
private subject: BehaviorSubject<number>;
public getCount(): Observable<number> { /* … */ }
public increment(): void { /* … */ }
public decrement(): void { /* … */ }
public reset(newCount: number): void { /* … */ }
private notify(): void { /* … */ }
}
WHAT IT DOES
describe('CounterService', () => {
/* … */
});
describe('CounterService', () => {
it('returns the count', () => { /* … */ });
it('increments the count', () => { /* … */ });
it('decrements the count', () => { /* … */ });
it('resets the count', () => { /* … */ });
});
describe('CounterService', () => {
let counterService: CounterService;
beforeEach(() => {
counterService = new CounterService();
});
STATE CHANGE
The two remaining specs work almost the same. We just call
the respective methods.
REPEATING PATTERNS
The pattern has one variable bit, the expected count. That is
why the helper function has one parameter.
UNSUBSCRIBE
Now that we have pulled out the code into a central helper
function, there is one optimization we should add. The First
Rule of RxJS Observables states: “Anyone who subscribes,
must unsubscribe as well”.
UNSUBSCRIBE MANUALLY
RXJS OPERATOR
If you are not familiar with this arcane RxJS magic, do not
worry. In the simple CounterService test, unsubscribing is
not strictly necessary. But it is a good practice that avoids
weird errors when testing more complex Services that make
use of Observables.
beforeEach(() => {
counterService = new CounterService();
});
@Injectable()
export class FlickrService {
constructor(private http: HttpClient) {}
INTERCEPT REQUESTS
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [FlickrService],
});
The HttpClientTestingModule provides a fake
implementation of HttpClient. It does not actually send out
HTTP requests. It merely intercepts them and records them
internally.
describe('FlickrService', () => {
let flickrService: FlickrService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [FlickrService],
});
flickrService = TestBed.inject(FlickrService);
});
EXPECTONE
describe('FlickrService', () => {
let flickrService: FlickrService;
let controller: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [FlickrService],
});
flickrService = TestBed.inject(FlickrService);
controller = TestBed.inject(HttpTestingController);
});
flickrService.searchPublicPhotos(searchTerm).subscribe(
(actualPhotos) => {
/* … */
}
);
We expect that the Observable emits a photos array that
equals to the one from the API response:
flickrService.searchPublicPhotos(searchTerm).subscribe(
(actualPhotos) => {
expect(actualPhotos).toEqual(photos);
}
);
Finally, we call:
controller.verify();
describe('FlickrService', () => {
let flickrService: FlickrService;
let controller: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [FlickrService],
});
flickrService = TestBed.inject(FlickrService);
controller = TestBed.inject(HttpTestingController);
});
expect(actualPhotos).toEqual(photos);
});
});
FlickrService: test code
Photo spec helper
UNHAPPY PATH
flickrService.searchPublicPhotos(searchTerm).subscribe(
() => {
/* next handler must not be called! */
},
(error) => {
/*
error handler must be called!
Also, we need to inspect the error.
*/
},
() => {
/* complete handler must not be called! */
},
);
FAIL
flickrService.searchPublicPhotos(searchTerm).subscribe(
() => {
fail('next handler must not be called');
},
(error) => {
actualError = error;
},
() => {
fail('complete handler must not be called');
},
);
After answering the request with a server error, we check
that the error is passed through. The error handler receives
an HttpErrorResponse object that reflects the ErrorEvent
as well as the status information.
if (!actualError) {
throw new Error('Error needs to be defined');
}
expect(actualError.error).toBe(errorEvent);
expect(actualError.status).toBe(status);
expect(actualError.statusText).toBe(statusText);
TYPE GUARD
controller.expectOne(expectedUrl).error(
errorEvent,
{ status, statusText }
);
if (!actualError) {
throw new Error('Error needs to be defined');
}
expect(actualError.error).toBe(errorEvent);
expect(actualError.status).toBe(status);
expect(actualError.statusText).toBe(statusText);
});
controller.expectOne('https://www.example.org')
controller.expectOne({
method: 'GET',
url: 'https://www.example.org'
})
If you need to find one request by looking at its details, you
can pass a function:
controller.expectOne(
(requestCandidate) =>
requestCandidate.method === 'GET' &&
requestCandidate.url === 'https://www.example.org' &&
requestCandidate.headers.get('Accept') === 'application/json',
);
MATCH
@Injectable()
class CommentService() {
constructor(private http: HttpClient) {}
public postTwoComments(firstComment: string, secondComment:
string) {
return combineLatest([
this.http.post('/comments/new', { comment: firstComment }),
this.http.post('/comments/new', { comment: secondComment }),
]);
}
}
The name Pipe originates from the vertical bar “|” that sits
between the value and the Pipe’s name. The concept as well
as the “|” syntax originate from Unix pipes and Unix shells.
{{ user.birthday | date }}
FORMATTING
PURE PIPES
Most Pipes are pure, meaning they merely take a value and
compute a new value. They do not have side effects: They
do not change the input value and they do not change the
state of other application parts. Like pure functions, pure
Pipes are relatively easy to test.
GreetPipe
Let us study the structure of a Pipe first to find ways to test
it. In essence, a Pipe is class with a public transform
method. Here is a simple Pipe that expects a name and
greets the user.
{{ 'Julie' | greet }}
GreetPipe test
The GreetPipe does not have any dependencies. We opt for
the first way and write a unit test that examines the single
instance.
describe('GreetPipe', () => {
let greetPipe: GreetPipe;
beforeEach(() => {
greetPipe = new GreetPipe();
});
@Injectable()
export class TranslateService {
/** The current language */
private currentLang = 'en';
TranslatePipe
{{ 'greeting' | translate }}
@Pipe({
name: 'translate',
pure: false,
})
export class TranslatePipe implements PipeTransform, OnDestroy {
private lastKey: string | null = null;
private translation: string | null = null;
constructor(
private changeDetectorRef: ChangeDetectorRef,
private translateService: TranslateService
) {
this.onTranslationChangeSubscription =
this.translateService.onTranslationChange.subscribe(
() => {
if (this.lastKey) {
this.getTranslation(this.lastKey);
}
}
);
}
public transform(key: string): string | null {
if (key !== this.lastKey) {
this.lastKey = key;
this.getTranslation(key);
}
return this.translation;
}
ASYNC TRANSLATION
TRANSLATION CHANGES
TranslatePipe test
HOST COMPONENT
@Component({
template: '{{ key | translate }}',
})
class HostComponent {
public key = key1;
}
beforeEach(async () => {
translateService = {
onTranslationChange: new EventEmitter<Translations>(),
get(key: string): Observable<string> {
return of(`Translation for ${key}`);
},
};
await TestBed.configureTestingModule({
declarations: [TranslatePipe, HostComponent],
providers: [
{ provide: TranslateService, useValue: translateService }
],
}).compileComponents();
translateService = TestBed.inject(TranslateService);
fixture = TestBed.createComponent(HostComponent);
});
/* … */
});
SIMULATE DELAY
DELAY OBSERVABLE
tick(100);
/* … */
});
tick(100);
fixture.detectChanges();
expectContent(fixture, 'Async translation for key1');
}));
DIFFERENT KEY
When translate is called with a different key, the Pipe
needs to fetch the new translation. We simulate this case by
changing the HostComponent’s key property from key1 to
key2.
TRANSLATION CHANGE
STYLING LOGIC
ThresholdWarningDirective
Note that numbers above the threshold are valid input. The
ThresholdWarningDirective does not add a form control
validator. We merely want to warn the user so they check
the input twice.
import {
Directive, ElementRef, HostBinding, HostListener, Input
} from '@angular/core';
@Directive({
selector: '[appThresholdWarning]',
})
export class ThresholdWarningDirective {
@Input()
public appThresholdWarning: number | null = null;
@HostBinding('class.overThreshold')
public overThreshold = false;
@HostListener('input')
public inputHandler(): void {
this.overThreshold =
this.appThresholdWarning !== null &&
this.elementRef.nativeElement.valueAsNumber >
this.appThresholdWarning;
}
input[type='number'].overThreshold {
background-color: #fe9;
}
Before we write the test for the Directive, let us walk
through the implementation parts.
@Input()
public appThresholdWarning: number | null = null;
INPUT EVENT
@HostListener('input')
public inputHandler(): void {
this.overThreshold =
this.appThresholdWarning !== null &&
this.elementRef.nativeElement.valueAsNumber >
this.appThresholdWarning;
}
READ VALUE
To access the host element, we use the ElementRef
dependency. ElementRef is a wrapper around the host
element’s DOM node. this.elementRef.nativeElement
yields the input element’s DOM node. valueAsNumber
contains the input value as a number.
TOGGLE CLASS
@HostBinding('class.overThreshold')
public overThreshold = false;
ThresholdWarningDirective test
HOST COMPONENT
describe('ThresholdWarningDirective', () => {
let fixture: ComponentFixture<HostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ThresholdWarningDirective, HostComponent],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
});
/* … */
});
@Component({
template: `
<input type="number"
[appThresholdWarning]="10"
data-testid="input" />
`
})
class HostComponent {}
describe('ThresholdWarningDirective', () => {
let fixture: ComponentFixture<HostComponent>;
let input: HTMLInputElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ThresholdWarningDirective, HostComponent],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
CHECK CLASS
The first spec ensures that the Directive does nothing when
the user has not touched the input. Using the element’s
classList, we expect the class overThreshold to be absent.
@Component({
template: `
<input type="number"
[appThresholdWarning]="10"
data-testid="input" />
`
})
class HostComponent {}
describe('ThresholdWarningDirective', () => {
let fixture: ComponentFixture<HostComponent>;
let input: HTMLInputElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ThresholdWarningDirective, HostComponent],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
input = findEl(fixture, 'input').nativeElement;
});
Per default, only ten items are rendered. The user can turn
the pages by clicking on “next” or “previous” buttons.
<ul>
<li *appPaginate="let item of items">
{{ item }}
</li>
</ul>
@Directive({
selector: '[appPaginate]',
})
export class PaginateDirective<T> implements OnChanges {
@Input()
public appPaginateOf: T[] = [];
/* … */
}
DIRECTIVE INPUTS
<ul>
<li *appPaginate="let item of items; perPage: 5">
{{ item }}
</li>
</ul>
<ng-template
appPaginate
let-item
[appPaginateOf]="items"
[appPaginatePerPage]="5">
<li>
{{ item }}
</li>
</ng-template>
@Directive({
selector: '[appPaginate]',
})
export class PaginateDirective<T> implements OnChanges {
@Input()
public appPaginateOf: T[] = [];
@Input()
public appPaginatePerPage = 10;
/* … */
}
<ng-template
#controls
let-previousPage="previousPage"
let-page="page"
let-pages="pages"
let-nextPage="nextPage"
>
<button (click)="previousPage()">
Previous page
</button>
{{ page }} / {{ pages }}
<button (click)="nextPage()">
Next page
</button>
</ng-template>
CONTEXT OBJECT
interface ControlsContext {
page: number;
pages: number;
previousPage(): void;
nextPage(): void;
}
let-previousPage="previousPage"
let-page="page"
let-pages="pages"
let-nextPage="nextPage"
<button (click)="previousPage()">
Previous page
</button>
{{ page }} / {{ pages }}
<button (click)="nextPage()">
Next page
</button>
<ul>
<li *appPaginate="let item of items; perPage: 5; controls:
controls">
{{ item }}
</li>
</ul>
<ng-template
appPaginate
let-item
[appPaginateOf]="items"
[appPaginatePerPage]="5"
[appPaginateControls]="controls">
<li>
{{ item }}
</li>
</ng-template>
@Directive({
selector: '[appPaginate]',
})
export class PaginateDirective<T> implements OnChanges {
@Input()
public appPaginateOf: T[] = [];
@Input()
public appPaginatePerPage = 10;
@Input()
public appPaginateControls?: TemplateRef<ControlsContext>;
/* … */
}
HOST COMPONENT
@Component({
template: `
<ul>
<li
*appPaginate="let item of items; perPage: 3"
data-testid="item"
>
{{ item }}
</li>
</ul>
`,
})
class HostComponent {
public items = items;
}
CONTROLS TEMPLATE
Since we also want to test the custom controls feature, we
need to pass a controls template. We will use the simple
controls discussed above.
@Component({
template: `
<ul>
<li
*appPaginate="let item of items; perPage: 3; controls:
controls"
data-testid="item"
>
{{ item }}
</li>
</ul>
<ng-template
#controls
let-previousPage="previousPage"
let-page="page"
let-pages="pages"
let-nextPage="nextPage"
>
<button
(click)="previousPage()"
data-testid="previousPage">
Previous page
</button>
<span data-testid="page">{{ page }}</span>
/
<span data-testid="pages">{{ pages }}</span>
<button
(click)="nextPage()"
data-testid="nextPage">
Next page
</button>
</ng-template>
`,
})
class HostComponent {
public items = items;
}
describe('PaginateDirective', () => {
let fixture: ComponentFixture<HostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [PaginateDirective, HostComponent],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
});
/* … */
});
The first spec verifies that the Directive renders the items
on the first page, in our case the numbers 1, 2 and 3.
expect(els[0].nativeElement.textContent.trim()).toBe('1');
expect(els[1].nativeElement.textContent.trim()).toBe('2');
expect(els[2].nativeElement.textContent.trim()).toBe('3');
});
Already, the expectations are repetitive and hard to read. So
we introduce a little helper function.
function expectItems(
elements: DebugElement[],
expectedItems: number[],
): void {
elements.forEach((element, index) => {
const actualText = element.nativeElement.textContent.trim();
expect(actualText).toBe(String(expectedItems[index]));
});
}
CHECK CONTROLS
Three more specs deal with the controls for turning pages.
Let us start with the “next” button.
TURN PAGES
STRESS TEST
import {
findEls,
expectText,
click,
} from './spec-helpers/element.spec-helper';
import { PaginateDirective } from './paginate.directive';
@Component({
template: `
<ul>
<li
*appPaginate="let item of items; perPage: 3; controls:
controls"
data-testid="item"
>
{{ item }}
</li>
</ul>
<ng-template
#controls
let-previousPage="previousPage"
let-page="page"
let-pages="pages"
let-nextPage="nextPage"
>
<button (click)="previousPage()" data-testid="previousPage">
Previous page
</button>
<span data-testid="page">{{ page }}</span>
/
<span data-testid="pages">{{ pages }}</span>
<button (click)="nextPage()" data-testid="nextPage">
Next page
</button>
</ng-template>
`,
})
class HostComponent {
public items = items;
}
function expectItems(
elements: DebugElement[],
expectedItems: number[],
): void {
elements.forEach((element, index) => {
const actualText = element.nativeElement.textContent.trim();
expect(actualText).toBe(String(expectedItems[index]));
});
}
describe('PaginateDirective', () => {
let fixture: ComponentFixture<HostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [PaginateDirective, HostComponent],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
});
it('renders the items of the first page', () => {
const els = findEls(fixture, 'item');
expect(els.length).toBe(3);
expectItems(els, [1, 2, 3]);
});
ONLY METADATA
Angular Modules are classes, but most of the time, the class
itself is empty. The essence lies in the metadata set with
@NgModule({ … }).
SMOKE TEST
@NgModule({
declarations: [ExampleComponent],
imports: [CommonModule],
})
export class FeatureModule {}
describe('FeatureModule', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [FeatureModule],
});
});
it('initializes', () => {
const module = TestBed.inject(FeatureModule);
expect(module).toBeTruthy();
});
});
Coverage report
In Angular’s Karma and Jasmine setup, Istanbul is used for
measuring test coverage. Istanbul rewrites the code under
test to record whether a statement, branch, function and
line was called. Then it produces a comprehensive test
report.
ng test --code-coverage
The report for the Flickr search example looks like this:
Istanbul creates an HTML page for every directory and every
file. By following the links, you can descend to reports for
the individual files.
IMPROVE COVERAGE
coverageReporter: {
/* … */
check: {
global: {
statements: 75,
branches: 75,
functions: 75,
lines: 75,
},
},
},
karma-coverage Configuration
End-to-end testing
LEARNING OBJECTIVES
USER PERSPECTIVE
REAL CONDITIONS
DETERMINISTIC ENVIRONMENT
The database needs to be filled with pre-fabricated fake
data. With each run of the end-to-end tests, you need to
reset the database to a defined initial state.
BROWSER AUTOMATION
Click on an element
WebDriver protocol
Protractor: Official web site
Cypress: Official web site
Introducing Protractor
Protractor is an end-to-end testing framework based on
WebDriver, made for Angular applications. Like the Angular
framework itself, Protractor originates from Google.
DEPRECATED
ANGULAR-SPECIFIC FEATURES
NOT RECOMMENDED
Introducing Cypress
Cypress is an end-to-end testing framework that is not
based on WebDriver. There are no Angular-specific features.
Any web site can be tested with Cypress.
REPRODUCIBLE TESTS
TEST RUNNER
TRADE-OFFS
From our perspective, Cypress has a few drawbacks.
RECOMMENDED
Cypress: Trade-offs
Cypress: Key differences
Webdriver.io
Installing Cypress
An easy way to add Cypress to an existing Angular CLI
project is the Cypress Angular Schematic.
ng add @cypress/schematic
TEST SUITES
COMMANDS
CHAINERS
ASSERTIONS
The Chainer has a should method for creating an assertion.
Cypress relays the call to the Chai library to verify the
assertion.
TEST RUNNER
The tests are run once, then the browser is closed and
the shell command finishes. You can see the test results
in the shell output.
You can see the test results the browser window. If you
make changes on the test files, Cypress automatically re-
runs the tests.
This command is typically used in the development
environment.
ng run angular-workshop:cypress-run
ng run angular-workshop:cypress-open
LAUNCH WINDOW
The cypress open command will open the test runner. First,
you need to choose the type of testing, which is “E2E
testing” in our case.
TEST RUNNER
Suppose you run the tests in Chrome and run the test
counter.cy.ts, the in-browser test runner looks like this:
In the “Specs” column, the tests of this test run are listed.
For each test, you can see the specs.
On the right side, the web page under test is seen. The web
page is scaled to fit into the window, but uses a default
viewport width of 1000 pixels.
SPEC LOG
TIME TRAVEL
Asynchronous tests
Every Cypress command takes some time to execute. But
from the spec point of view, the execution happens
instantly.
COMMAND QUEUE
SYNCHRONOUS ASSERTIONS
WAIT AUTOMATICALLY
The retry and waiting timeout can be configured for all tests
or individual commands.
RETRY SPECS
If a spec fails despite these retries and waiting, Cypress can
be configured to retry the whole spec. This is the last resort
if a particular spec produces inconsistent results.
1. Navigate to “/”.
2. Find the element with the current count and read its text
content.
3. Expect that the text is “5”, since this is the start count
for the first counter.
5. Find the element with the current count and read its text
content (again).
Finding elements
The next step is to find an element in the current page.
Cypress provides several ways to find elements. We are
going to use the cy.get method to find an element by CSS
selector.
cy.get('.example')
FIND BY TEST ID
cy.get('[data-testid="example"]')
FIND BY TYPE
cy.get('[data-testid="count"]')
cy.get('[data-testid="count"]').should('have.text', '5');
The have.text assertion compares the text content with the
given string.
CLICK
cy.get('[data-testid="increment-button"]').click();
The Angular code under test handles the click event. Finally,
we verify that the visible count has increased by one. We
repeat the should('have.text', …) command, but expect
a higher number.
describe('Counter', () => {
beforeEach(() => {
cy.visit('/');
});
Last but not least, we test the reset feature. The user can
enter a new count into a form field (test id reset-input)
and click on the reset button (test id reset-button) to set
the new count.
To enter text into the form field, we pass a string to the type
method.
cy.get('[data-testid="reset-input"]').type('123');
Next, we click on the reset button and finally expect the
change.
describe('Counter', () => {
beforeEach(() => {
cy.visit('/');
});
FIRST MATCH
FIND BY TEST ID
CUSTOM COMMANDS
CY.BYTESTID
Cypress.Commands.add(
'byTestId',
(id: string) =>
cy.get(`[data-testid="${id}"]`)
);
import './commands';
Keep in mind that all these first calls are only necessary
since there are multiple counters on the example page
under test. If there is only one element with the given test id
on the page, you do not need them.
1. Navigate to “/”.
2. Find the search input field and enter a search term, e.g.
“flower”.
NONDETERMINISTIC API
This has pros and cons. Testing against the real Flickr API
makes the test realistic, but less reliable. If the Flickr API has
a short hiccup, the test fails although there is no bug in our
code.
Each type of test should do what it does best. The unit tests
already put the different photo Components through their
paces. The end-to-end test does not need to achieve that
level of detail.
beforeEach(() => {
cy.visit('/');
});
The type command does not overwrite the form value with
a new value, but sends keyboard input, key by key.
cy.byTestId('photo-item-link')
.should('have.length', 15)
cy.byTestId('photo-item-link')
.should('have.length', 15)
.each((link) => {
/* Check the link */
});
cy.byTestId('photo-item-link')
.should('have.length', 15)
.each((link) => {
expect(link.attr('href')).to.contain(
'https://www.flickr.com/photos/'
);
});
beforeEach(() => {
cy.visit('/');
});
cy.byTestId('photo-item-link')
.should('have.length', 15)
.each((link) => {
expect(link.attr('href')).to.contain(
'https://www.flickr.com/photos/'
);
});
cy.byTestId('photo-item-image').should('have.length', 15);
});
});
ng run flickr-search:cypress-open
When the user clicks on a link in the result list, the click
event is caught and the full photo details are shown next to
the list. (If the user clicks with the control/command key
pressed or right-clicks, they can follow the link to flickr.com.)
cy.byTestId('search-term-input').first().clear().type(searchTerm);
cy.byTestId('submit-search').first().click();
Then we find all photo item links, but not to inspect them,
but to click on the first on:
cy.byTestId('photo-item-link').first().click();
cy.byTestId('full-photo').should('contain', searchTerm);
Next, we check that a title and some tags are present and
not empty.
cy.byTestId('full-photo-title').should('not.have.text', '');
cy.byTestId('full-photo-tags').should('not.have.text', '');
cy.byTestId('full-photo-image').should('exist');
cy.byTestId('photo-item-link').first().click();
cy.byTestId('full-photo').should('contain', searchTerm);
cy.byTestId('full-photo-title').should('not.have.text', '');
cy.byTestId('full-photo-tags').should('not.have.text', '');
cy.byTestId('full-photo-image').should('exist');
});
Page objects
The Flickr search end-to-end test we have written is fully
functional. We can improve the code further to increase
clarity and maintainability.
HIGH-LEVEL INTERACTIONS
A page object represents the web page that is scrutinized by
an end-to-end test. The page object provides a high-level
interface for interacting with the page.
But if the page logic is complex and there are diverse cases
to test, the test becomes an unmanageable pile of low-level
instructions. It is hard to find the gist of these tests and they
are hard to change.
PLAIN CLASS
A page object is merely an abstract pattern – the exact
implementation is up to you. Typically, the page object is
declared as a class that is instantiated when the test starts.
The class has a visit method that opens the page that the
page object represents.
beforeEach(() => {
page = new FlickrSearch();
page.visit();
});
/* … */
});
SEARCH
ELEMENT QUERIES
HIGH-LEVEL TESTS
You can use the page object pattern when you feel the need
to tidy up complex, repetitive tests. Once you are familiar
with the pattern, it also helps you to avoid writing such tests
in the first place.
import {
photo1,
photo1Link,
photos,
searchTerm,
} from '../../src/app/spec-helpers/photo.spec-helper';
const flickrResponse = {
photos: {
photo: photos,
},
};
beforeEach(() => {
cy.intercept(
{
method: 'GET',
url: 'https://www.flickr.com/services/rest/*',
query: {
tags: searchTerm,
method: 'flickr.photos.search',
format: 'json',
nojsoncallback: '1',
tag_mode: 'all',
media: 'photos',
per_page: '15',
extras: 'tags,date_taken,owner_name,url_q,url_m',
api_key: '*',
},
},
{
body: flickrResponse,
headers: {
'Access-Control-Allow-Origin': '*',
},
},
).as('flickrSearchRequest');
cy.visit('/');
});
ALIAS
cy.wait('@flickrSearchRequest');
/* … */
});
SPECIFIC ASSERTIONS
cy.wait('@flickrSearchRequest');
cy.byTestId('photo-item-link')
.should('have.length', 2)
.each((link, index) => {
expect(link.attr('href')).to.equal(
`https://www.flickr.com/photos/${photos[index].owner}/${photos[inde
x].id}`,
);
});
cy.byTestId('photo-item-image')
.should('have.length', 2)
.each((image, index) => {
expect(image.attr('src')).to.equal(photos[index].url_q);
});
});
cy.wait('@flickrSearchRequest');
cy.byTestId('photo-item-link').first().click();
cy.byTestId('full-photo').should('contain', searchTerm);
cy.byTestId('full-photo-title').should('have.text',
photo1.title);
cy.byTestId('full-photo-tags').should('have.text', photo1.tags);
cy.byTestId('full-photo-image').should('have.attr', 'src',
photo1.url_m);
cy.byTestId('full-photo-link').should('have.attr', 'href',
photo1Link);
});
Testing does not only make your software more reliable, but
also evolves your coding practice in the long run. It requires
to write testable code, and testable code is usually simpler.
Counter Component
Flickr photo search
Sign-up form
TranslatePipe
ThresholdWarningDirective
PaginateDirective
References
Angular: Grundlagen, fortgeschrittene Themen und Best
Practices, Second Edition, Ferdinand Malcher, Johannes
Hoppe, Danny Koppenhagen. dpunkt.verlag, 2019. ISBN
978-3-86490-646-6
Twitter: @molily
The Flickr search example application uses the Flickr API but
is not endorsed or certified by Flickr, Inc. or SmugMug, Inc.
Flickr is a trademark of Flickr, Inc. The displayed photos are
property of their respective owners.