Testing Angular - Cleaned
Testing Angular - Cleaned
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.
Often individual developers are blamed for the lack of tests. The
claim that developers are just too ignorant or lazy to write tests
is simplistic and downright toxic. If testing has an indisputable
value, we need to examine why developers avoid it while being
convinced of the benefits. Testing should be easy, straight-
forward and commonplace.
You try to find weaknesses and loopholes in your own work. You
think through all possible cases and pester your code with “What
if?” questions. What seems frustrating at first sight is an
invaluable strategy to improve your code.
This guide teaches you how to test Angular application parts like
Components and Services. It assumes you know how to
implement them, but not how to test them properly. If you have
questions regarding Angular’s core concepts, please refer to the
official Angular documentation.
If you have not used individual concepts yet, like Directives, that
is fine. You can simply skip the related chapters and pick
chapters you are interested in.
When using these terms in the general sense, this guide uses
lower case:
COST-EFFECTIVE
DESCRIPTIVE
A valuable test clearly describes how the implementation
code should behave. The test uses a proper language to talk
to developers and convey the requirements. The test lists
known cases the implementation has to deal with.
PREVENT BREAKAGE
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.
DEVELOPMENT PROCESS
RETURN ON INVESTMENT
NORMALIZE TESTING
Simultaneously, integrate testing into your team’s workflow:
Writing automated tests should be easy and fun for your team
members. Remove any obstacles that make testing difficult or
inefficient.
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
Some tests have a high-level, bird’s-eye view on the application.
They simulate a user interacting with the application: Navigating
to an address, reading text, clicking on a link or button, filling
out a form, moving the mouse or typing on the keyboard. These
tests make expectations about what the user sees and reads in
the browser.
END-TO-END TESTS
Unit tests
Other tests have a low-level, worm’s-eye view on the application.
They pick a small piece of code and put it through its paces.
From this perspective, implementation details matter. The
developer needs to set up an appropriate testing environment to
trigger all relevant cases.
UNIT TESTS
These tests are called unit tests. A unit is a small piece of code
that is reasonable to test.
Integration tests
COHESIVE GROUPS
INTEGRATION TESTS
These tests are called integration tests since they test how well
the parts integrate into the group. For example, all parts of one
feature may be tested together. An integration test proves that
the parts work together properly.
Distribution of testing efforts
All levels of testing are necessary and valuable. Different types of
tests need to be combined to create a thorough test suite.
SPEED
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.
The crucial question for dividing your testing efforts is: Which
tests yield the most return on investment? How much work is it
to maintain a test in relation to its benefit?
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
On the other hand, unit tests are too low-level to check whether
a certain feature works for the user. They give you little
confidence that your application works. In addition, unit tests
might increase the cost of every code change.
MIDDLE GROUND
Inte‐
Level End-to-End Unit
gration
least most
Reliability reliable
reliable reliable
OUTSIDE
Black box testing does not assume anything about the internal
structure. It puts certain values into the box and expects certain
output values. The test talks to the publicly exposed,
documented API. The inner state and workings are not
examined.
In p u
t
Black box
u t
O u tp
INSIDE
White box testing opens the box, sheds light on the internals
and takes measurements by reaching into the box. For example,
a white box test may call methods that are not part of the public
API, but still technically tangible. Then it checks the internal state
and expects that it has changed accordingly.
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
MAKING CHOICES
The testing tools that ship with Angular are low-level. They
merely provide the basic operations. If you use these tools
directly, your tests become messy, repetitive and hard to
maintain.
The command for starting the unit and integration tests is:
ng test
TEST.TS
MAIN.TS
You might be familiar with the entry point for the application,
src/main.ts. This file also initializes Angular, but then it
typically bootstraps (loads and starts) the AppModule. The
AppModule imports other Modules, Components, Services, etc.
This way, the bundler finds all parts of the application.
The test bundle with the entry point test.ts works differently.
It does not start with one Module and walks through all its
dependencies. It merely imports all files whose name ends with
.spec.ts.
.SPEC.TS
KARMA
TEST RUNNER
TOTAL: 46 SUCCESS
RED-GREEN CYCLE
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:
TOTAL: 46 SUCCESS
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'),
],
When we run the tests with ng test, we will find an XML report
file in the project directory.
JASMINE CONFIGURATION
client: {
clearContext: false,
jasmine: {
},
},
client: {
clearContext: false,
jasmine: {
failSpecWithNoExpectations: true,
},
},
/* … */
});
DESCRIBE: SUITE
NESTING DESCRIBE
/* … */
});
/* … */
});
});
Specifications
IT: SPEC
/* … */
});
/* … more specs … */
});
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.
2. Act is the phase where interaction with the code under test
happens. For example, a method is called or an HTML
element in the DOM is clicked.
3. Assert is the phase where the code behavior is checked and
verified. For example, the actual output is compared to the
expected output.
1. Arrange:
2. Act:
3. Assert:
STRUCTURE A TEST
What is the user input or API call that triggers the behavior I
would like to test? (Act)
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.
A primitive test without any testing tools could look like this:
const expectedValue = 5;
);
EXPECT
const expectedValue = 5;
expect(actualValue).toBe(expectedValue);
MATCHERS
For checking the deep equality of two objects, Jasmine offers the
toEqual matcher. This example illustrates the difference:
// Passes, the two objects are not identical but deeply equal
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.
🔗 Jasmine documentation: Built-in matchers
🔗 Jasmine tutorials: Custom matchers
REPETITIVE SETUP
beforeAll(() => {
});
afterAll(() => {
});
beforeEach(() => {
});
afterEach(() => {
});
console.log('Spec 1');
});
console.log('Spec 2');
});
});
This suite has two specs and defines shared setup and teardown
code. The output is:
Spec 1
Spec 2
FAKING SAFELY
REPLACEABILITY
How can we ensure that the fake is up-to-date with the original?
How can we ensure the equivalence of original and fake in the
long run and prevent any possible divergence?
TYPE EQUIVALENCE
Effective faking
The original dependency code has side effects that need to be
suppressed during testing. The fake needs to effectively prevent
the original code from being executed. Strange errors may
happen if a mix of fake and original code is executed.
CALL RECORD
In its simplest form, a spy is a function that records its calls. For
each call, it records the function parameters. Using this record,
we later assert that the spy has been called with particular input
values.
For example, we declare in a spec: “Expect that the spy has been
called two times with the values mickey and minnie,
respectively.”
CREATESPY
class TodoService {
constructor(
) {}
if (!response.ok) {
);
INJECT FAKE
In our unit test, we do not want the Service to make any HTTP
requests. We pass in a Jasmine spy as replacement for
window.fetch.
const todos = [
'shop groceries',
];
status: 200,
statusText: 'OK',
});
describe('TodoService', () => {
// Arrange
.and.returnValue(okResponse);
// Act
// Assert
expect(actualTodos).toEqual(todos);
expect(fetchSpy).toHaveBeenCalledWith('/todos');
});
});
There is a lot to unpack in this example. Let us start with the fake
data before the describe block:
const todos = [
'shop groceries',
];
status: 200,
statusText: 'OK',
});
First, we define the fake data we want the fetch spy to return.
Essentially, this is an array of strings.
FAKE RESPONSE
describe('TodoService', () => {
/* … */
});
The suite contains one spec that tests the getTodos method:
/* … */
});
// Arrange
.and.returnValue(okResponse);
INJECT SPY
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'.
After having written the first spec for getTodos, we need to ask
ourselves: Does the test fully cover its behavior? We have tested
the success case, also called happy path, but the error case, also
called unhappy path, is yet to be tested. In particular, this error
handling code:
if (!response.ok) {
);
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:
status: 404,
});
Assuming the server does not return JSON in the error case, the
response body is simply the string 'Not Found'.
describe('TodoService', () => {
/* … */
// Arrange
.and.returnValue(errorResponse);
// Act
let error;
try {
await todoService.getTodos();
} catch (e) {
error = e;
// Assert
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.
This installs a spy on the global fetch method. Under the hood,
Jasmine saves the original window.fetch function for later and
overwrites window.fetch with a spy. Once the spec is
completed, Jasmine automatically restores the original function.
spyOn(window, 'fetch')
.and.returnValue(okResponse);
class TodoService {
if (!response.ok) {
);
const todos = [
'shop groceries',
];
status: 200,
statusText: 'OK',
});
describe('TodoService', () => {
// Arrange
spyOn(window, 'fetch')
.and.returnValue(okResponse);
// Act
// 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
});
In this case, you can instruct ng test to consider only the file
you are currently working on. Webpack then includes all its
dependencies, like the Angular framework, but not more.
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
ASYNC LOGGING
console.log(exampleObject);
console.log(exampleObject);
LOG A SNAPSHOT
console.log(JSON.parse(JSON.stringify(exampleObject)));
ROOT ELEMENT
In the developer tools, you can select the iframe window context
(Chrome is pictured):
This way you can access global objects and the DOM of the
document where the tests run.
The debug test runner does not have an iframe, it loads Jasmine
directly. Also it automatically logs spec runs on the shell.
It binds data to form controls and allows the user to edit the
data.
It talks to Services or other state managers.
COUNTER FEATURES
When the user activates the “+” button, the count increments.
When the user activates the “-” button, the count decrements.
When the user enters a number into the reset input field and
activates the reset button, the count is set to the given value.
When the user changes the count, an Output emits the new
count.
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.
Finally, the template is rendered into the DOM. For testing, you
could do all that manually, but you would need to dive deeply
into Angular internals.
TESTBED
Instead, the Angular team provides the TestBed to ease unit
testing. The TestBed creates and configures an Angular
environment so you can test particular application parts like
Components and Services safely and easily.
TestBed.configureTestingModule({
imports: [ /*… */ ],
declarations: [ /*… */ ],
providers: [ /*… */ ],
});
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.
Rendering the Component
Now we have a fully-configured testing Module with compiled
Components. Finally, we can render the Component under test
using createComponent:
fixture.detectChanges();
describe('CounterComponent', () => {
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?
COMPONENT
F IXTURE
// This is a ComponentFixture<CounterComponent>
// Set Input
component.startCount = 10;
// Subscribe to Output
component.countChange.subscribe((count) => {
/* … */
});
DEBUGELEMENT
NATIVEELEMENT
Often it is necessary to unwrap the DebugElement to access the
native DOM element inside. Every DebugElement has a
nativeElement property:
console.log(nativeElement.tagName);
console.log(nativeElement.textContent);
console.log(nativeElement.innerHTML);
/* … */
});
});
BY.CSS
const h1 = debugElement.query(By.css('h1'));
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
By.css('[data-testid="increment-button"]')
);
ESTABLISH A CONVENTION
incrementButton.triggerEventHandler('click', {
/* … Event properties … */
});
EVENT OBJECT
The increment method does not access the event object. The
call is simply increment(), not increment($event).
Therefore, we do not need to pass a fake event object, we can
simply pass null:
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:
By.css('[data-testid="count"]')
);
TEXT CONTENT
The next step is to read the element’s content. In the DOM, the
count is a text node that is a child of strong.
countOutput.nativeElement.textContent
expect(countOutput.nativeElement.textContent).toBe('1');
/* Incomplete! */
describe('CounterComponent', () => {
// Arrange
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [CounterComponent],
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
fixture.detectChanges();
debugElement = fixture.debugElement;
});
// Act
By.css('[data-testid="increment-button"]')
);
incrementButton.triggerEventHandler('click', null);
// Assert
By.css('[data-testid="count"]')
);
expect(countOutput.nativeElement.textContent).toBe('1');
});
});
fixture.detectChanges();
describe('CounterComponent', () => {
// Arrange
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [CounterComponent],
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
fixture.detectChanges();
debugElement = fixture.debugElement;
});
// Act
By.css('[data-testid="increment-button"]')
);
incrementButton.triggerEventHandler('click', null);
fixture.detectChanges();
// Assert
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.
// Act
By.css('[data-testid="decrement-button"]')
);
decrementButton.triggerEventHandler('click', null);
fixture.detectChanges();
// Assert
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:
These tasks are highly generic and they will appear in almost
every Component spec. It is worth writing testing helpers for
them.
TESTING HELPERS
By.css('[data-testid="xyz"]')
);
function findEl<T>(
fixture: ComponentFixture<T>,
testId: string
): DebugElement {
return fixture.debugElement.query(
By.css(`[data-testid="${testId}"]`)
);
CLICK
fixture: ComponentFixture<T>,
testId: string
): void {
element.triggerEventHandler('click', event);
target: EventTarget
): Partial<MouseEvent> {
return {
type: 'click',
target,
currentTarget: target,
bubbles: true,
cancelable: true,
button: 0
};
The click testing helper can be used on every element that has
a (click)="…" event handler. For accessibility, make sure the
element can be focussed and activated. This is already the case
for buttons (button element) and links (a elements).
fixture: ComponentFixture<T>,
testId: string,
text: string,
): void {
expect(actualText).toBe(text);
// Act
click(fixture, 'decrement-button');
fixture.detectChanges();
// Assert
});
That is much better to read and less to write! You can tell what
the spec is doing at first glance.
The answer depends on the field type and value. The generic
answer is: Find the native DOM element and set the value
property to the new value.
By.css('[data-testid="reset-input"]')
);
resetInput.nativeElement.value = '123';
resetInputEl.value = '123';
Reset
</button>
resetInputEl.value = '123';
resetInputEl.dispatchEvent(new Event('input'));
resetInputEl.dispatchEvent(event);
The full spec for the reset feature then looks like this:
// Act
resetInputEl.value = newCount;
resetInputEl.dispatchEvent(event);
click(fixture, 'reset-button');
fixture.detectChanges();
// Assert
});
HELPER FUNCTIONS
The helper function setFieldValue takes a Component fixture,
a test id and a string value. It finds the corresponding element
using findEl. Using another helper, setFieldElementValue,
it sets the value and dispatches an input event.
fixture: ComponentFixture<T>,
testId: string,
value: string,
): void {
setFieldElementValue(
findEl(fixture, testId).nativeElement,
value
);
You can find the full source code of the involved helper functions
in element.spec-helper.ts.
// Act
click(fixture, 'reset-button');
fixture.detectChanges();
// Assert
});
While the reset feature is simple, this is how to test most form
logic. Later, we will learn how to test complex forms.
INVALID INPUT
// Act
click(fixture, 'reset-button');
fixture.detectChanges();
// Assert
});
The small difference in this spec is that we set the field value to
“not a number”, a string that cannot be parsed as a number, and
expect the count to remain unchanged.
This is it! We have tested the reset form with both valid and
invalid input.
🔗 CounterComponent: test code
🔗 Element spec helpers: full code
Testing Inputs
CounterComponent has an Input startCount that sets the
initial count. We need to test that the counter handles the Input
properly.
component.startCount = 10;
this.count = this.startCount;
Let us write a test for the startCount Input. We set the Input in
the beforeEach block, before calling detectChanges. The spec
itself checks that the correct count is rendered.
/* Incomplete! */
beforeEach(async () => {
/* … */
component.startCount = startCount;
fixture.detectChanges();
});
});
NGONCHANGES
describe('CounterComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [CounterComponent],
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
component.startCount = startCount;
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.
/* … */
@Output()
/* … */
SUBSCRIBE TO OBSERVABLE
/* … */
});
// Arrange
component.countChange.subscribe((count) => {
/* … */
});
});
// Arrange
component.countChange.subscribe((count) => {
/* … */
});
// Act
click(fixture, 'increment-button');
});
In the Assert phase, we expect that count has the correct value.
The easiest way is to declare a variable in the spec scope. Let us
name it actualCount. Initially, it is undefined. The observer
function sets a value – or not, if it is never called.
// Arrange
actualCount = count;
});
// 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! */
// Arrange
// Assert
expect(count).toBe(1);
});
// Act
click(fixture, 'increment-button');
});
Per default, Jasmine warns you that the spec has no expectations
but treats the spec as successful (see Configuring Karma and
Jasmine). We want the spec to fail explicitly in this case, so we
make sure the expectation is always run.
// Arrange
actualCount = count;
});
// Act
click(fixture, 'decrement-button');
// Assert
expect(actualCount).toBe(-1);
});
// Arrange
actualCount = count;
});
// Act
click(fixture, 'reset-button');
// Assert
expect(actualCount).toBe(newCount);
});
// Arrange
component.countChange.pipe(
take(3),
toArray()
).subscribe((counts) => {
actualCounts = counts;
});
// Act
click(fixture, 'increment-button');
click(fixture, 'decrement-button');
click(fixture, 'reset-button');
// Assert
expect(actualCounts).toEqual([1, 0, newCount]);
});
These tests are black box tests. We have already talked about
black box vs. white box testing in theory. Both are valid testing
methods. As stated, this guide advises to use black box testing
first and foremost.
This makes sense for Input and Output properties. They need to
be read and written from the outside, from your test. However,
internal properties and methods exist that are public only for
the template.
@Input()
public startCount = 0;
@Output()
They form the public API. However, there are several more
properties and methods that are public:
public count = 0;
/* Not recommended! */
describe('CounterComponent', () => {
/* … */
component.increment();
fixture.detectChanged();
});
});
The white box spec above calls the increment method, but does
not test the corresponding template code, the increment button:
A white box test does not examine the Component strictly from
the DOM perspective. Thereby, it runs the risk of missing crucial
Component behavior. It gives the illusion that all code is tested.
RECOMMENDATION
Private properties
No access
and methods
Testing Components with
children
LEARNING OBJECTIVES
PRESENTATIONAL COMPONENTS
They directly render what the user sees and interacts with.
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', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HomeComponent],
}).compileComponents();
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
expect(component).toBeTruthy();
});
});
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.
If the smoke test fails, you know that something is wrong with
the testing setup.
await TestBed.configureTestingModule({
declarations: [HomeComponent],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
<app-counter
[startCount]="5"
(countChange)="handleCountChange($event)"
></app-counter>
CHILD PRESENCE
/* … */
});
expect(counter).toBeTruthy();
});
debugElement.query(By.css('app-counter')) is not
particularly descriptive. The reader has to think for a moment to
realize that the code tries to find a nested Component.
FINDCOMPONENT
fixture: ComponentFixture<T>,
selector: string,
): DebugElement {
return fixture.debugElement.query(By.css(selector));
expect(counter).toBeTruthy();
});
CHECK INPUTS
/* … */
});
PROPERTIES
expect(counter.properties.startCount).toBe(5);
});
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>
/* … */
});
SIMULATE OUTPUT
/* … */
const count = 5;
counter.triggerEventHandler('countChange', 5);
/* … */
});
OUTPUT EFFECT
spyOn(console, 'log');
spyOn(console, 'log');
const count = 5;
counter.triggerEventHandler('countChange', count);
/* … */
});
In the Assert phase, we expect that the spy has been called with a
certain text and the number the Output has emitted.
spyOn(console, 'log');
const count = 5;
counter.triggerEventHandler('countChange', count);
expect(console.log).toHaveBeenCalledWith(
count,
);
});
<app-service-counter></app-service-counter>
<!-- … -->
<app-ngrx-counter></app-ngrx-counter>
CHILD PRESENCE
Since they do not have Inputs or Outputs, we merely need to test
whether they are mentioned in the template. We add two
additional specs that check the presence of these app-
service-counter and app-ngrx-counter elements,
respectively.
expect(serviceCounter).toBeTruthy();
});
expect(ngrxCounter).toBeTruthy();
});
This is it! We have written a unit test with shallow rendering that
proves that HomeComponent correctly embeds several child
Components.
@Component({
selector: 'app-counter',
template: '',
})
@Input()
public startCount = 0;
@Output()
This fake Component lacks a template and any logic, but has the
same selector, Input and Output.
TestBed.configureTestingModule({
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
We need to adapt the test suite now that child Component are
rendered. Instead of searching for an app-counter element
and inspecting its properties, we explicitly search for a
FakeCounterComponent instance.
FIND BY DIRECTIVE
By.directive(FakeCounterComponent)
);
By.directive(FakeCounterComponent)
);
CHILD PRESENCE
By.directive(FakeCounterComponent)
);
expect(counter).toBeTruthy();
});
CHECK INPUTS
By.directive(FakeCounterComponent)
);
expect(counter.startCount).toBe(5);
});
The third spec checks the Output handling: If the counter emits a
value, the HomeComponent passes it to console.log.
EMIT OUTPUT
By.directive(FakeCounterComponent)
);
spyOn(console, 'log');
const count = 5;
counter.countChange.emit(5);
expect(console.log).toHaveBeenCalledWith(
count,
);
});
We are done! Here is the HomeComponent test suite that vets the
CounterComponent child. To minimize repetition and noise, we
move the query part into the beforeEach block.
@Component({
selector: 'app-counter',
template: '',
})
@Input()
public startCount = 0;
@Output()
beforeEach(async () => {
await TestBed.configureTestingModule({
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
By.directive(FakeCounterComponent)
);
counter = counterEl.componentInstance;
});
expect(counter).toBeTruthy();
});
expect(counter.startCount).toBe(5);
});
spyOn(console, 'log');
const count = 5;
counter.countChange.emit(count);
expect(console.log).toHaveBeenCalledWith(
count,
);
});
});
Let us recap what we have gained with this type of testing the
HomeComponent.
ADVANTAGES
beforeEach(async () => {
await TestBed.configureTestingModule({
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
// Original type!
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HomeComponent,
MockComponent(CounterComponent)],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
// Original class!
By.directive(CounterComponent)
);
counter = counterEl.componentInstance;
});
/* … */
});
beforeEach(async () => {
await TestBed.configureTestingModule({
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
By.directive(CounterComponent)
);
counter = counterEl.componentInstance;
});
expect(counter).toBeTruthy();
});
expect(counter.startCount).toBe(5);
});
spyOn(console, 'log');
const count = 5;
counter.countChange.emit(count);
expect(console.log).toHaveBeenCalledWith(
count,
);
});
});
This was only a glimpse of ng-mocks. The library not only helps
with nested Components, but provides high-level helpers for
setting up the Angular test environment. ng-mocks replaces the
conventional setup with TestBed.configureTestingModule
and helps faking Modules, Components, Directives, Pipes and
Services.
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ServiceCounterComponent],
providers: [CounterService],
}).compileComponents();
fixture = TestBed.createComponent(ServiceCounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
});
click(fixture, 'increment-button');
fixture.detectChanges();
});
click(fixture, 'decrement-button');
fixture.detectChanges();
});
click(fixture, 'reset-button');
fixture.detectChanges();
});
});
providers: [CounterService],
PROVIDE SERVICE
class CounterService {
FAKE INSTANCE
const fakeCounterService = {
getCount() {
return of(currentCount);
},
increment() {},
decrement() {},
reset() {},
};
TYPE EQUIVALENCE
ERRONEOUS CODE
// Error!
getCount() {
return of(currentCount);
},
increment() {},
decrement() {},
reset() {},
};
const fakeCounterService:
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:
getCount() {
return of(currentCount);
},
};
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:
getCount:
jasmine.createSpy('getCount').and.returnValue(of(currentCount)),
increment: jasmine.createSpy('increment'),
decrement: jasmine.createSpy('decrement'),
reset: jasmine.createSpy('reset'),
};
CREATESPYOBJ
'CounterService',
getCount: of(currentCount),
increment: undefined,
decrement: undefined,
reset: undefined,
);
The code above creates an object with four methods, all of them
being spies. They return the given values: getCount returns an
Observable<number>. The other methods return undefined.
TYPE EQUIVALENCE
createSpyObj accepts a TypeScript type variable to declare the
type of the created object. We pass CounterService between
angle brackets so TypeScript checks that the fake matches the
original.
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],
providers: [
],
}).compileComponents();
fixture = TestBed.createComponent(ServiceCounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
/* … */
});
providers: [
The Arrange phase is complete now, let us write the actual specs.
The Act phase is the same as in the other counter Component
tests: We click on buttons and fill out form fields.
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],
providers: [
],
}).compileComponents();
fixture = TestBed.createComponent(ServiceCounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
expect(fakeCounterService.getCount).toHaveBeenCalled();
});
click(fixture, 'increment-button');
expect(fakeCounterService.increment).toHaveBeenCalled();
});
click(fixture, 'decrement-button');
expect(fakeCounterService.decrement).toHaveBeenCalled();
});
click(fixture, 'reset-button');
expect(fakeCounterService.reset).toHaveBeenCalledWith(newCount);
});
});
COMPONENT UPDATE
The fake getCount method returns of(currentCount), an
Observable with the fixed value 123. The Observable completes
immediately and never pushes another value. We need to
change that behavior in order to demonstrate the Component
update.
BEHAVIORSUBJECT
/* … */
beforeEach(async () => {
/* … */
});
/* … */
});
/* … */
fakeCounterService = {
getCount(): Observable<number> {
return fakeCount$;
},
increment(): void {
fakeCount$.next(1);
},
decrement(): void {
fakeCount$.next(-1);
},
reset(): void {
fakeCount$.next(Number(newCount));
},
};
The fake above is an object with plain methods. We are not using
createSpyObj any longer because it does not allow fake
method implementations.
SPY ON METHODS
We have lost the Jasmine spies and need to bring them back.
There are several ways to wrap the methods in spies. For
simplicity, we install spies on all methods using spyOn:
spyOn(fakeCounterService, 'getCount').and.callThrough();
spyOn(fakeCounterService, 'increment').and.callThrough();
spyOn(fakeCounterService, 'decrement').and.callThrough();
spyOn(fakeCounterService, 'reset').and.callThrough();
fixture.detectChanges();
beforeEach(async () => {
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: [
],
}).compileComponents();
fixture = TestBed.createComponent(ServiceCounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
expect(fakeCounterService.getCount).toHaveBeenCalled();
});
click(fixture, 'increment-button');
fakeCount$.next(1);
fixture.detectChanges();
expect(fakeCounterService.increment).toHaveBeenCalled();
});
click(fixture, 'decrement-button');
fakeCount$.next(-1);
fixture.detectChanges();
expect(fakeCounterService.decrement).toHaveBeenCalled();
});
click(fixture, 'reset-button');
fixture.detectChanges();
expect(fakeCounterService.reset).toHaveBeenCalledWith(newCount);
});
});
TESTABLE SERVICES
We have already learned how to fill out form fields when testing
the counter Component. In doing so, we developed the
setFieldValue testing helper.
SIGN-UP FORM
IMPRACTICAL
REACTIVE FORM
@Component({
selector: 'app-signup-form',
templateUrl: './signup-form.component.html',
styleUrls: ['./signup-form.component.scss'],
})
/* … */
username: [
null,
this.validateUsername(control.value),
],
email: [
null,
this.validateEmail(control.value),
],
password: [
null,
required,
() => this.validatePassword()
],
address: this.formBuilder.group({
addressLine1: [null],
region: [null],
}),
});
/* … */
constructor(
/* … */
/* … */
The form controls are declared with their initial values and their
validators. For example, the password control:
password: [
null,
required,
() => this.validatePassword()
],
<form [formGroup]="form">
<fieldset formGroupName="address">
<label>
Full name
</label>
</fieldset>
</form>
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:
plan: Plan;
username: string;
email: string;
password: string;
tos: true;
address: {
name: string;
addressLine1?: string;
addressLine2: string;
city: string;
postcode: string;
region?: string;
country: string;
};
const {
} = Validators;
These validators take the control value, a string most of the time,
and return a ValidationErrors object with potential error
messages. The validation happens synchronously on the client.
ASYNC 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.
Touched means the user has focussed the control but it has
lost the focus again (the blur event fired).
For example, for the name control, the interaction between the
input element and the ControlErrorsComponent looks like
this:
<label>
Full name
<input
type="text"
formControlName="name"
aria-required="true"
appErrorMessage="name-errors"
/>
</label>
<!-- … -->
<ng-template let-errors>
<ng-container *ngIf="errors.required">
</ng-container>
</ng-template>
</app-control-errors>
ARIA ATTRIBUTES
<ng-container *ngIf="errors.required">
</ng-container>
</ng-template>
IMPLEMENTATION DETAILS
1. Form submission
Successful submission
Submission failure
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: [
],
}).compileComponents();
imports: [ReactiveFormsModule],
DEEP RENDERING
declarations: [
SignupFormComponent,
ControlErrorsComponent,
ErrorMessageDirective
],
FAKE SERVICE
The SignupFormComponent depends on the SignupService.
We do not want HTTP requests to the back-end when the tests
run, so we replace the Service with a fake instance.
providers: [
],
const signupService:
isUsernameTaken() {
return of(false);
},
isEmailTaken() {
return of(false);
},
getPasswordStrength() {
return of(strongPassword);
},
signup() {
},
};
This fake implements the success case: the username and email
are available, the password is strong enough and the form
submission was successful.
CREATESPYOBJ
'SignupService',
isUsernameTaken: of(false),
isEmailTaken: of(false),
getPasswordStrength: of(strongPassword),
);
SETUP FUNCTION
describe('SignupFormComponent', () => {
signupServiceReturnValues?:
jasmine.SpyObjMethodNames<SignupService>,
) => {
signupService = jasmine.createSpyObj<SignupService>(
'SignupService',
isUsernameTaken: of(false),
isEmailTaken: of(false),
getPasswordStrength: of(strongPassword),
...signupServiceReturnValues,
);
await TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [
SignupFormComponent,
ControlErrorsComponent,
ErrorMessageDirective
],
providers: [
],
}).compileComponents();
fixture = TestBed.createComponent(SignupFormComponent);
fixture.detectChanges();
};
/* … */
});
await setup({
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:
export const username = 'quickBrownFox';
export const password = 'dog lazy the over jumps fox brown quick
the';
plan: 'personal',
username,
email,
password,
address: {
},
tos: true,
};
For example, the username input gets the test id username, the
email input gets email and so on.
Back in signup-form.component.spec.ts, we create a new
spec that calls the setup function.
await setup();
/* … */
});
Next, we fill out all required fields with valid values. Since we
need to do that in several upcoming specs, let us create a
reusable function.
};
await setup();
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.
await setup();
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 spec above submits the form immediately after filling out
the fields. At this point in time, the asynchronous validators have
been called but have not returned a value yet. They are still
waiting for the debounce period to pass.
In consequence, the test needs to wait one second for the
asynchronous validators. An easy way would be to write an
asynchronous test that uses setTimeout(() => { /* … */},
1000). But this would slow down our specs.
await setup();
fillForm();
tick(1000);
expect(signupService.signup).toHaveBeenCalledWith(signupData);
}));
await setup();
fillForm();
tick(1000);
expect(signupService.isUsernameTaken).toHaveBeenCalledWith(username
);
expect(signupService.isEmailTaken).toHaveBeenCalledWith(email);
expect(signupService.getPasswordStrength).toHaveBeenCalledWith(pass
word);
expect(signupService.signup).toHaveBeenCalledWith(signupData);
}));
SUBMIT BUTTON
STATUS MESSAGE
Also, when the form has been submitted successfully, the status
message “Sign-up successful!” needs to appear. (The status
message carries the test id status.)
await setup();
fillForm();
fixture.detectChanges();
expect(findEl(fixture, 'submit').properties.disabled).toBe(true);
tick(1000);
fixture.detectChanges();
expect(findEl(fixture,
'submit').properties.disabled).toBe(false);
fixture.detectChanges();
expect(signupService.isUsernameTaken).toHaveBeenCalledWith(username
);
expect(signupService.isEmailTaken).toHaveBeenCalledWith(email);
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?
await setup();
tick(1000);
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
When the user submits the form, the Component under tests
calls the SignupService’s signup method.
In the error case, the Observable fails with an error. The form
displays a status message “Sign-up error”.
Let us test the latter case in a new spec. The structure resembles
the spec for the successful submission. But we configure the
fake signup method to return an Observable that fails with an
error.
await setup({
});
/* … */
});
We fill out the form, wait for the validators and submit the form.
fillForm();
tick(1000);
fixture.detectChanges();
STATUS MESSAGE
expect(signupService.isUsernameTaken).toHaveBeenCalledWith(username
);
expect(signupService.getPasswordStrength).toHaveBeenCalledWith(pass
word);
expect(signupService.signup).toHaveBeenCalledWith(signupData);
await setup({
});
fillForm();
tick(1000);
fixture.detectChanges();
expect(signupService.isUsernameTaken).toHaveBeenCalledWith(username
);
expect(signupService.getPasswordStrength).toHaveBeenCalledWith(pass
word);
expect(signupService.signup).toHaveBeenCalledWith(signupData);
}));
REQUIREMENTS
const requiredFields = [
'username',
'email',
'name',
'addressLine2',
'city',
'postcode',
'country',
'tos',
];
MARK AS TOUCHED
dispatchFakeEvent(element.nativeElement, 'blur');
};
We can now write the Arrange and Act phases of the spec:
await setup();
requiredFields.forEach((testId) => {
markFieldAsTouched(findEl(fixture, testId));
});
fixture.detectChanges();
/* … */
});
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
Next, the Assert phase. Again we walk through the required fields
to examine each one of them. Let us start with the aria-
required attribute.
requiredFields.forEach((testId) => {
expect(el.attributes['aria-required']).toBe(
'true',
);
/* … */
});
ARIA-ERRORMESSAGE
The next part tests the error message with three steps:
if (!errormessageId) {
TYPE ASSERTIONS
We need a TypeScript type assertion that rules out the null case
and narrows down the type to string. If the attribute is absent
or empty, we throw an exception. This fails the test with the
given error and ensures that errormessageId is a string for the
rest of the spec.
if (!errormessageEl) {
ERROR MESSAGE
expect(errormessageEl.textContent).toContain(
);
} else {
expect(errormessageEl.textContent).toContain('must be given');
await setup();
requiredFields.forEach((testId) => {
markFieldAsTouched(findEl(fixture, testId));
});
fixture.detectChanges();
requiredFields.forEach((testId) => {
expect(el.attributes['aria-required']).toBe(
'true',
);
if (!errormessageId) {
);
if (!errormessageEl) {
);
expect(errormessageEl.textContent).toContain(
);
} else {
expect(errormessageEl.textContent).toContain('must be
given');
});
});
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.
signupServiceReturnValues?:
jasmine.SpyObjMethodNames<SignupService>,
) => {
signupService = jasmine.createSpyObj<SignupService>(
'SignupService',
isUsernameTaken: of(false),
isEmailTaken: of(false),
getPasswordStrength: of(strongPassword),
...signupServiceReturnValues,
);
/* … */
};
await setup({
isUsernameTaken: of(true),
});
/* … */
}));
await setup({
isEmailTaken: of(true),
});
/* … */
}));
await setup({
getPasswordStrength: of(weakPassword),
});
/* … */
}));
The rest is the same for all three specs. Here is the first spec:
await setup({
isUsernameTaken: of(true),
});
fillForm();
tick(1000);
fixture.detectChanges();
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.
this.addressLine1.setValidators(required);
} else {
this.addressLine1.setValidators(null);
this.addressLine1.updateValueAndValidity();
});
await setup();
/* … */
});
expect('ng-invalid' in addressLine1El.classes).toBe(false);
expect('aria-required' in addressLine1El.attributes).toBe(false);
fixture.detectChanges();
expect(addressLine1El.attributes['aria-required']).toBe('true');
expect(addressLine1El.classes['ng-invalid']).toBe(true);
fixture.detectChanges();
expect(addressLine1El.attributes['aria-required']).toBe('true');
expect(addressLine1El.classes['ng-invalid']).toBe(true);
await setup();
expect('ng-invalid' in addressLine1El.classes).toBe(false);
expect('aria-required' in addressLine1El.attributes).toBe(false);
fixture.detectChanges();
expect(addressLine1El.attributes['aria-required']).toBe('true');
expect(addressLine1El.classes['ng-invalid']).toBe(true);
fixture.detectChanges();
expect(addressLine1El.attributes['aria-required']).toBe('true');
expect(addressLine1El.classes['ng-invalid']).toBe(true);
});
<button
type="button"
(click)="showPassword = !showPassword"
>
</button>
await setup();
/* … */
});
Initially, the field has the password type so the entered text is
obfuscated. Let us test this baseline.
We find the input element by its test id again to check the type
attribute.
expect(passwordEl.attributes.type).toBe('password');
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');
With a second click on the toggle button, the type switches back
to password.
click(fixture, 'show-password');
fixture.detectChanges();
expect(passwordEl.attributes.type).toBe('password');
await setup();
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
In this guide, we will look at pa11y, a Node.js program that
checks the accessibility of a web page.
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:
├── WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.InputText.Name
├── html > body > app-root > main > app-signup-form > form >
fieldset:nth-child(2) > div:nth-child(2) > p > span > input
├── WCAG2AA.Principle1.Guideline1_3.1_3_1.F68
├── html > body > app-root > main > app-signup-form > form >
fieldset:nth-child(2) > div:nth-child(2) > p > span > input
Each error message contains the violated WCAG rule, the DOM
path to the violating element and its HTML code.
pa11y-ci
For comprehensive test runs both during development and on a
build server, we will set up pa11y in the continuous integration
mode.
"defaults": {
"runner": [
"axe",
"htmlcs"
},
"urls": [
"http://localhost:4200"
npx pa11y-ci
NPM SCRIPTS
"scripts": {
"pa11y-ci": "pa11y-ci"
},
🔗 start-server-and-test: Starts server, waits for URL, then runs test command
STRUCTURAL WEAKNESSES
describe('FullPhotoComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [FullPhotoComponent],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(FullPhotoComponent);
component = fixture.componentInstance;
component.photo = photo1;
fixture.detectChanges();
});
expect(img.properties.src).toBe(photo1.url_m);
expect(img.properties.alt).toBe(photo1.title);
expect(link.properties.href).toBe(photo1Link);
expect(link.nativeElement.textContent.trim()).toBe(photo1Link);
});
});
COMPONENT FACTORY
When using Spectator, the Module configuration and the
Component creation looks different. In the scope of the test
suite, we create a Component factory:
/* … */
component: FullPhotoComponent,
shallow: true,
});
/* … */
});
CREATE COMPONENT
component: FullPhotoComponent,
shallow: true,
});
beforeEach(() => {
});
/* … */
});
SPECTATOR
createComponent returns a Spectator object. This is the
powerful interface we are going to use in the specs.
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);
expect(img).toHaveAttribute('src', photo1.url_m);
expect(img).toHaveAttribute('alt', photo1.title);
The full test suite using Spectator (only imports from Spectator
are shown):
import {
} from '@ngneat/spectator';
component: FullPhotoComponent,
shallow: true,
});
beforeEach(() => {
});
expect(
spectator.query(byTestId('full-photo-title'))
).toHaveText(photo1.title);
expect(img).toHaveAttribute('src', photo1.url_m);
expect(img).toHaveAttribute('alt', 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);
expect(link).toHaveAttribute('href', photo1Link);
expect(link).toHaveText(photo1Link);
});
});
<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'],
})
this.flickrService.searchPublicPhotos(searchTerm).subscribe(
(photos) => {
this.searchTerm = searchTerm;
this.photos = photos;
this.currentPhoto = null;
);
this.currentPhoto = photo;
CHILD COMPONENTS
3. The search term and the photo list are passed down to the
PhotoListComponent via Inputs.
WITHOUT SPECTATOR
describe('FlickrSearchComponent', () => {
beforeEach(async () => {
fakeFlickrService = {
searchPublicPhotos: jasmine
.createSpy('searchPublicPhotos')
.and.returnValue(of(photos)),
};
await TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
declarations: [FlickrSearchComponent],
providers: [
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(FlickrSearchComponent);
component = fixture.debugElement.componentInstance;
fixture.detectChanges();
});
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();
});
searchForm.triggerEventHandler('search', searchTerm);
fixture.detectChanges();
expect(fakeFlickrService.searchPublicPhotos).toHaveBeenCalledWith(s
earchTerm);
expect(photoList.properties.title).toBe(searchTerm);
expect(photoList.properties.photos).toBe(photos);
});
expect(() => {
findComponent(fixture, 'app-full-photo');
}).toThrow();
photoList.triggerEventHandler('focusPhoto', photo1);
fixture.detectChanges();
expect(fullPhoto.properties.photo).toBe(photo1);
});
});
fakeFlickrService = {
searchPublicPhotos: jasmine
.createSpy('searchPublicPhotos')
.and.returnValue(of(photos)),
};
WITH SPECTATOR
import {
} from '@ngneat/spectator';
/* … */
component: FlickrSearchComponent,
shallow: true,
declarations: [
MockComponents(
),
],
providers: [mockProvider(FlickrService)],
});
/* … */
});
import {
} 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', () => {
expect(photoList.title).toBe('');
expect(photoList.photos).toEqual([]);
expect(fullPhoto).not.toExist();
});
expect(() => {
findComponent(fixture, 'app-full-photo');
}).toThrow();`.
TEST SEARCH
searchForm.search.emit(searchTerm);
spectator.detectChanges();
expect(flickrService.searchPublicPhotos).toHaveBeenCalledWith(searc
hTerm);
expect(photoList.title).toBe(searchTerm);
expect(photoList.photos).toBe(photos);
});
expect(fullPhoto).not.toExist();
if (!photoList) {
photoList.focusPhoto.emit(photo1);
spectator.detectChanges();
fullPhoto = spectator.query(FullPhotoComponent);
if (!fullPhoto) {
expect(fullPhoto.photo).toBe(photo1);
});
SYNTHETIC EVENTS
/* … */
photo = otherPhoto;
});
spectator.click(byTestId('photo-item-link'));
expect(photo).toBe(photo1);
});
/* … */
});
SPECTATOR.
T YPEINELEMENT
/* … */
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.
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 {
WHAT IT DOES
For reading the state, the Service has the getCount method.
It does not return a synchronous value, but an RxJS
Observable. We will use getCount to get the current count
and also to subscribe to changes.
For changing the state, the Service provides the methods
increment, decrement and reset. We will call them and
check whether the state has changed accordingly.
describe('CounterService', () => {
/* … */
});
describe('CounterService', () => {
});
describe('CounterService', () => {
beforeEach(() => {
});
});
counterService.getCount().subscribe((count) => {
actualCount = count;
});
expect(actualCount).toBe(0);
});
STATE CHANGE
The next spec tests the increment method. We call the method
and verify that the count state has changed.
counterService.increment();
counterService.getCount().subscribe((count) => {
actualCount = count;
});
expect(actualCount).toBe(1);
});
The two remaining specs work almost the same. We just call the
respective methods.
counterService.decrement();
counterService.getCount().subscribe((count) => {
actualCount = count;
});
expect(actualCount).toBe(-1);
});
counterService.reset(newCount);
counterService.getCount().subscribe((count) => {
actualCount = count;
});
expect(actualCount).toBe(newCount);
});
REPEATING PATTERNS
We quickly notice that the specs are highly repetitive and noisy.
In every spec’s Assert phase, we are using this pattern to inspect
the Service state:
counterService.getCount().subscribe((count) => {
actualCount = count;
});
expect(actualCount).toBe(/* … */);
counterService.getCount().subscribe((actualCount2) => {
actualCount = actualCount2;
});
expect(actualCount).toBe(count);
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”.
In expectCount, we need to get the current count only once.
We do not want to create a long-lasting subscription. We are not
interested in future changes.
UNSUBSCRIBE MANUALLY
counterService
.getCount()
.subscribe((actualCount2) => {
actualCount = actualCount2;
})
.unsubscribe();
expect(actualCount).toBe(count);
RXJS OPERATOR
A more idiomatic way is to use an RxJS operator that completes
the Observable after the first value: first.
counterService
.getCount()
.pipe(first())
.subscribe((actualCount2) => {
actualCount = actualCount2;
});
expect(actualCount).toBe(count);
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.
describe('CounterService', () => {
counterService
.getCount()
.pipe(first())
.subscribe((actualCount2) => {
actualCount = actualCount2;
});
expect(actualCount).toBe(count);
beforeEach(() => {
});
expectCount(0);
});
counterService.increment();
expectCount(1);
});
counterService.decrement();
expectCount(-1);
});
counterService.reset(newCount);
expectCount(newCount);
});
});
@Injectable()
return this.http
.get<FlickrAPIResponse>(
'https://www.flickr.com/services/rest/',
params: {
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: 'XYZ',
},
INTERCEPT REQUESTS
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [FlickrService],
});
describe('FlickrService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [FlickrService],
});
flickrService = TestBed.inject(FlickrService);
});
flickrService.searchPublicPhotos(searchTerm).subscribe(
(actualPhotos) => {
/* … */
);
/* … */
});
});
In our case, we search for a request with a given URL of the Flickr
API.
describe('FlickrService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [FlickrService],
});
flickrService = TestBed.inject(FlickrService);
controller = TestBed.inject(HttpTestingController);
});
flickrService.searchPublicPhotos(searchTerm).subscribe(
(actualPhotos) => {
/* … */
);
/* … */
});
});
The Flickr search repository contains fake photo objects that are
used throughout the tests. For the FlickrService test, we
import the photos array with two fake photo objects.
flickrService.searchPublicPhotos(searchTerm).subscribe(
(actualPhotos) => {
/* … */
);
flickrService.searchPublicPhotos(searchTerm).subscribe(
(actualPhotos) => {
expect(actualPhotos).toEqual(photos);
);
There are several ways to solve this problem. We have opted for
a variable that is undefined initially and is assigned a value.
flickrService.searchPublicPhotos(searchTerm).subscribe(
(otherPhotos) => {
actualPhotos = otherPhotos;
);
expect(actualPhotos).toEqual(photos);
Finally, we call:
controller.verify();
describe('FlickrService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [FlickrService],
});
flickrService = TestBed.inject(FlickrService);
controller = TestBed.inject(HttpTestingController);
});
flickrService.searchPublicPhotos(searchTerm).subscribe(
(otherPhotos) => {
actualPhotos = otherPhotos;
);
controller.verify();
expect(actualPhotos).toEqual(photos);
});
});
UNHAPPY PATH
searchPublicPhotos passes through the error from
HttpClient. If the Observable returned by this.http.get
fails with an error, the Observable returned by
searchPublicPhotos fails with the same error.
/* … */
request.error(
errorEvent,
{ status, statusText }
);
flickrService.searchPublicPhotos(searchTerm).subscribe(
() => {
},
(error) => {
/*
*/
},
() => {
},
);
FAIL
When the next or complete handlers are called, the spec must
fail immediately. There is a handy global Jasmine function for
this purpose: fail.
flickrService.searchPublicPhotos(searchTerm).subscribe(
() => {
},
(error) => {
actualError = error;
},
() => {
},
);
if (!actualError) {
expect(actualError.error).toBe(errorEvent);
expect(actualError.status).toBe(status);
expect(actualError.statusText).toBe(statusText);
TYPE GUARD
flickrService.searchPublicPhotos(searchTerm).subscribe(
() => {
},
(error) => {
actualError = error;
},
() => {
},
);
controller.expectOne(expectedUrl).error(
errorEvent,
{ status, statusText }
);
if (!actualError) {
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) =>
);
method: 'GET',
url: 'https://www.example.org'
});
expect(httpRequest.headers.get('Accept')).toBe('application/json');
MATCH
@Injectable()
class CommentService() {
return combineLatest([
]);
commentService
.postTwoComments(firstComment, secondComment)
.subscribe();
method: 'POST',
url: '/comments/new',
});
expect(requests.length).toBe(2);
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 }}
describe('GreetPipe', () => {
beforeEach(() => {
});
expect(greetPipe.transform('Julie')).toBe('Hello, Julie!');
});
});
TranslateService
The current language is stored in the TranslateService. This
Service also loads and holds the translations for the current
language.
@Injectable()
this.loadTranslations(this.currentLang);
this.currentLang = language;
this.loadTranslations(language);
if (this.translations) {
return of(this.translations[key]);
return this.onTranslationChange.pipe(
take(1),
);
this.translations = null;
this.http
.get<Translations>(`assets/${language}.json`)
.subscribe((translations) => {
this.translations = translations;
this.onTranslationChange.emit(translations);
});
TranslatePipe
To show a translated label, a Component could call the Service’s
get method manually for each translation key. Instead, we
introduce the TranslatePipe to do the heavy lifting. It lets us
write:
{{ 'greeting' | translate }}
import {
ChangeDetectorRef,
OnDestroy,
Pipe,
PipeTransform,
} from '@angular/core';
@Pipe({
name: 'translate',
pure: false,
})
constructor(
) {
this.onTranslationChangeSubscription =
this.translateService.onTranslationChange.subscribe(
() => {
if (this.lastKey) {
this.getTranslation(this.lastKey);
);
this.lastKey = key;
this.getTranslation(key);
return this.translation;
this.getSubscription?.unsubscribe();
this.getSubscription = this.translateService
.get(key)
.subscribe((translation) => {
this.translation = translation;
this.changeDetectorRef.markForCheck();
this.getSubscription = null;
});
this.onTranslationChangeSubscription.unsubscribe();
this.getSubscription?.unsubscribe();
ASYNC TRANSLATION
TRANSLATION CHANGES
The same process happens when the user changes the language
and new translations are loaded. The Pipe subscribes to
TranslateService’s onTranslationChange and calls the
TranslateService again to get the new translation.
TranslatePipe test
Now let us test the TranslatePipe! We can either write a test
that integrates the TranslateService dependency. Or we
write a unit test that replaces the dependency with a fake.
>;
/* … */
translateService = {
},
};
HOST COMPONENT
@Component({
})
class HostComponent {
>;
beforeEach(async () => {
translateService = {
},
};
await TestBed.configureTestingModule({
providers: [
],
}).compileComponents();
translateService = TestBed.inject(TranslateService);
fixture = TestBed.createComponent(HostComponent);
});
/* … */
});
In the testing Module, we declare the Pipe under test and the
HostComponent. For the TranslateService, we provide a fake
object instead. Just like in a Component test, we create the
Component and examine the rendered DOM.
fixture.detectChanges();
});
SIMULATE DELAY
/* … */
});
/* … */
});
DELAY OBSERVABLE
We still use of, but we delay the output by 100 milliseconds. The
exact number does not matter as long as there is some delay
greater or equal 1.
fixture.detectChanges();
/* … */
});
The Pipe’s transform method is called for the first time and
returns null since the Observable does not emit a value
immediately.
fixture.detectChanges();
expectContent(fixture, '');
/* … */
});
fixture.detectChanges();
expectContent(fixture, '');
tick(100);
/* … */
});
fixture.detectChanges();
expectContent(fixture, '');
tick(100);
fixture.detectChanges();
}));
Testing these details may seem pedantic at first. But the logic in
TranslatePipe exists for a reason.
DIFFERENT KEY
fixture.detectChanges();
fixture.componentInstance.key = key2;
fixture.detectChanges();
});
TRANSLATION CHANGE
Last but no least, the Pipe needs to fetch a new translation from
the TranslateService when the user changes the language
and new translations have been loaded. For this purpose, the
Pipe subscribes to the Service’s onTranslationChange emitter.
Our TranslateService fake supports onTranslationChange
as well, hence we call the emit method to simulate a translation
change. Before, we let the Service return a different translation
in order to see a change in the DOM.
fixture.detectChanges();
translateService.onTranslationChange.emit({});
fixture.detectChanges();
});
STYLING LOGIC
ThresholdWarningDirective
None of our example applications contain an Attribute Directive,
so we are introducing and testing the
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 {
} from '@angular/core';
@Directive({
selector: '[appThresholdWarning]',
})
@Input()
@HostBinding('class.overThreshold')
@HostListener('input')
this.overThreshold =
this.elementRef.nativeElement.valueAsNumber >
this.appThresholdWarning;
This means: If the user enters a number that is greater than 10,
mark the field with a visual warning.
input[type='number'].overThreshold {
background-color: #fe9;
Before we write the test for the Directive, let us walk through the
implementation parts.
@Input()
INPUT EVENT
@HostListener('input')
this.overThreshold =
this.elementRef.nativeElement.valueAsNumber >
this.appThresholdWarning;
READ VALUE
TOGGLE CLASS
@HostBinding('class.overThreshold')
ThresholdWarningDirective test
Now that we understand what is going on, we need to replicate
the workflow in our test.
HOST COMPONENT
@Component({
template: `
<input type="number"
[appThresholdWarning]="10" />
})
class HostComponent {}
describe('ThresholdWarningDirective', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
});
/* … */
});
@Component({
template: `
<input type="number"
[appThresholdWarning]="10"
data-testid="input" />
})
class HostComponent {}
describe('ThresholdWarningDirective', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
}).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.
expect(input.classList.contains('overThreshold')).toBe(false);
});
fixture.detectChanges();
expect(input.classList.contains('overThreshold')).toBe(true);
});
The last spec makes sure that the threshold is still considered as
a safe value. No warning should be shown.
fixture.detectChanges();
expect(input.classList.contains('overThreshold')).toBe(false);
});
@Component({
template: `
<input type="number"
[appThresholdWarning]="10"
data-testid="input" />
})
class HostComponent {}
describe('ThresholdWarningDirective', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
});
expect(input.classList.contains('overThreshold')).toBe(false);
});
fixture.detectChanges();
expect(input.classList.contains('overThreshold')).toBe(true);
});
fixture.detectChanges();
expect(input.classList.contains('overThreshold')).toBe(false);
});
});
The NgFor Directive walks over a list of items and renders the
template repeatedly for each item.
PaginateDirective
We are introducing and testing the PaginateDirective, a
complex Structural Directive.
Per default, only ten items are rendered. The user can turn the
pages by clicking on “next” or “previous” buttons.
<ul>
{{ item }}
</li>
</ul>
<li>
{{ item }}
</li>
</ng-template>
@Directive({
selector: '[appPaginate]',
})
@Input()
/* … */
DIRECTIVE INPUTS
Per default, there are ten items on a page. To change it, we set
perPage: … in the microsyntax:
<ul>
{{ item }}
</li>
</ul>
<ng-template
appPaginate
let-item
[appPaginateOf]="items"
[appPaginatePerPage]="5">
<li>
{{ item }}
</li>
</ng-template>
@Directive({
selector: '[appPaginate]',
})
@Input()
@Input()
/* … */
<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
The Directive renders the controls template with a context object
that implements the following TypeScript interface:
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>
{{ item }}
</li>
</ul>
<ng-template
appPaginate
let-item
[appPaginateOf]="items"
[appPaginatePerPage]="5"
[appPaginateControls]="controls">
<li>
{{ item }}
</li>
</ng-template>
@Directive({
selector: '[appPaginate]',
})
@Input()
@Input()
@Input()
/* … */
PaginateDirective test
We have explored all features of PaginateDirective and are
now ready to test them!
HOST COMPONENT
@Component({
template: `
<ul>
<li
data-testid="item"
>
{{ item }}
</li>
</ul>
`,
})
class HostComponent {
CONTROLS TEMPLATE
@Component({
template: `
<ul>
<li
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>
<button
(click)="nextPage()"
data-testid="nextPage">
Next page
</button>
</ng-template>
`,
})
class HostComponent {
describe('PaginateDirective', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
}).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.length).toBe(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');
});
function expectItems(
elements: DebugElement[],
expectedItems: number[],
): void {
expect(actualText).toBe(String(expectedItems[index]));
});
expect(els.length).toBe(3);
});
CHECK CONTROLS
});
Three more specs deal with the controls for turning pages. Let
us start with the “next” button.
click(fixture, 'nextPage');
fixture.detectChanges();
expect(els.length).toBe(3);
});
TURN PAGES
Finally, we verify that the Directive has rendered the next three
items, the numbers 4, 5 and 6.
The spec for the “previous” button looks similar. First, we jump to
the second page, then back to the first page.
click(fixture, 'nextPage');
click(fixture, 'previousPage');
fixture.detectChanges();
expect(els.length).toBe(3);
});
STRESS TEST
fixture.detectChanges();
expect(els.length).toBe(3);
});
import {
findEls,
expectText,
click,
} from './spec-helpers/element.spec-helper';
@Component({
template: `
<ul>
<li
data-testid="item"
>
{{ item }}
</li>
</ul>
<ng-template
#controls
let-previousPage="previousPage"
let-page="page"
let-pages="pages"
let-nextPage="nextPage"
>
Previous page
</button>
Next page
</button>
</ng-template>
`,
})
class HostComponent {
function expectItems(
elements: DebugElement[],
expectedItems: number[],
): void {
expect(actualText).toBe(String(expectedItems[index]));
});
describe('PaginateDirective', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
});
expect(els.length).toBe(3);
});
});
click(fixture, 'nextPage');
fixture.detectChanges();
expect(els.length).toBe(3);
});
click(fixture, 'nextPage');
click(fixture, 'previousPage');
fixture.detectChanges();
expect(els.length).toBe(3);
});
fixture.detectChanges();
expect(els.length).toBe(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({ … }).
@NgModule({
declarations: [ExampleComponent],
imports: [CommonModule],
})
describe('FeatureModule', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [FeatureModule],
});
});
it('initializes', () => {
expect(module).toBeTruthy();
});
});
Code coverage, also called test coverage, tells you which parts of
your code are executed by running the unit and integration
tests. Code coverage is typically expressed as percent values, for
example, 79% statements, 53% branches, 74% functions, 78%
lines.
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.
To activate Istanbul when running the tests, add the --code-
coverage parameter:
ng test --code-coverage
After the tests have completed, Istanbul saves the report in the
coverage directory located in the Angular project directory.
The report is a bunch of HTML files you can open with a browser.
Start by opening coverage/index.html in the browser of your
choice.
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.
You can tell from the coverage report above that the
handleClick method is never called. A key Component
behavior is untested.
How to use the coverage report
Now that we know how to generate the report, what should we
do with it?
IMPROVE COVERAGE
Writing new code and changing existing code should not lower
the coverage score, but gradually increase it. This means if your
existing tests cover 75% lines of code, new code needs to be at
least 75% covered. Otherwise the score slowly deteriorates.
coverageReporter: {
/* … */
check: {
global: {
statements: 75,
branches: 75,
functions: 75,
lines: 75,
},
},
},
For beginners and experts alike, the coverage report helps to set
up, debug and improve their tests. For experienced developers,
the score helps to keep up a steady testing practice.
🔗 karma-coverage Configuration
End-to-end testing
LEARNING OBJECTIVES
USER PERSPECTIVE
REAL CONDITIONS
The unit and integration tests we wrote worked with a fake back-
end. We send fake HTTP requests and respond with fake data.
We made an effort to keep the originals and fakes on par.
DETERMINISTIC ENVIRONMENT
Since this guide is not about DevOps, we will not go into details
here and focus on writing end-to-end tests.
Just like unit and integration tests, the end-to-end test then
makes expectations: Does the page include the right content?
Did the URL change? This way, whole features and user
interfaces are examined.
BROWSER AUTOMATION
🔗 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
First, Protractor hooks into the Angular app under tests. After
sending a WebDriver command, Protractor waits for Angular to
update the page. Then it continues with the next command. By
synchronizing the test and the application, the test gets faster
and more reliable.
Without the control flow, you practically need to disable the “wait
for Angular” feature as well. This means both key Protractor
features have lapsed.
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
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
? Would you like the default `ng e2e` command to use Cypress? [
Protractor to Cypress Migration Guide:
https://on.cypress.io/protractor-to-cypress?cli=true ] Yes
The installer asks if you would like the ng e2e command to start
Cypress. If you are setting up a new project without end-to-end
tests yet, it is safe to answer “Yes”.
The tests itself are structured with the test framework Mocha.
The assertions (also called expectations) are written using Chai.
TEST SUITES
If you have written unit tests with Jasmine before, the Mocha
structure will be familiar to you. A test file contains one or more
suites declared with describe('…', () => { /* … */}).
Typically, one file contains one describe block, possible with
nested describe blocks.
beforeEach(() => {
});
});
});
describe('Counter', () => {
beforeEach(() => {
cy.visit('/');
});
});
});
COMMANDS
Cypress commands are methods of the cy namespace object.
Here, we are using two commands, visit and title.
CHAINERS
ASSERTIONS
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.
This command is typically used in a continuous integration
environment.
You can see the test results the browser window. If you make
changes on the test files, Cypress automatically re-runs the
tests.
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.
On the next screen, you need to choose the browser for running
the tests.
Cypress automatically lists all browsers it finds on your system.
In addition, you can run your tests in Electron. Cypress’ user
interface is an Electron application. Electron is based on
Chromium, the open source foundation of the Chrome browser.
Select a browser and click on the “Start E2E Testing” button. This
launches the browser and opens the test runner, Cypress’
primary user interface. (The screenshot shows Chrome.)
In the main window pane, all tests are listed. To run a single test,
click on it.
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
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.
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')
Just like with unit and integration test, the immediate question
is: How should we find an element – by id, name, class or by
other means?
FIND BY TEST ID
cy.get('[data-testid="example"]')
FIND BY TYPE
Test ids are recommended, but other ways to find elements are
still useful in some cases. For example, you might want to check
the presence and the content of an h1 element. This element has
a special meaning and you should not find it with an arbitrary
test id.
cy.get('[data-testid="count"]')
cy.get('[data-testid="count"]').should('have.text', '5');
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('/');
});
});
cy.get('[data-testid="count"]').should('have.text', '5');
cy.get('[data-testid="increment-button"]').click();
cy.get('[data-testid="count"]').should('have.text', '6');
});
});
cy.get('[data-testid="decrement-button"]').click();
cy.get('[data-testid="count"]').should('have.text', '4');
});
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.
cy.get('[data-testid="reset-input"]').type('123');
cy.get('[data-testid="reset-button"]').click();
cy.get('[data-testid="count"]').should('have.text', '123');
});
describe('Counter', () => {
beforeEach(() => {
cy.visit('/');
});
});
cy.get('[data-testid="count"]').should('have.text', '5');
cy.get('[data-testid="increment-button"]').click();
cy.get('[data-testid="count"]').should('have.text', '6');
});
cy.get('[data-testid="decrement-button"]').click();
cy.get('[data-testid="count"]').should('have.text', '4');
});
cy.get('[data-testid="reset-input"]').type('123');
cy.get('[data-testid="reset-button"]').click();
cy.get('[data-testid="count"]').should('have.text', '123');
});
});
On the start page of the counter project, there are in fact nine
counter instances. The cy.get command therefore returns nine
elements instead of one.
FIRST MATCH
cy.get('[data-testid="count"]').first().should('have.text', '5');
cy.get('[data-testid="increment-button"]').first().click();
cy.get('[data-testid="count"]').first().should('have.text', '6');
});
This also applies to the other specs. If the element under test
only appears once, the first command is not necessary, of
course.
All counter features are now tested. In the next chapters, we will
refactor the code to improve its readability and maintainability.
FIND BY TEST ID
return cy.get(`[data-testid="${testId}"]`);
CUSTOM COMMANDS
This works fine, but we opt for a another way. Cypress supports
adding custom commands to the cy namespace. We are going
to add the command byTestId so we can write
cy.byTestId('count').
CY.BYTESTID
Cypress.Commands.add(
'byTestId',
cy.get(`[data-testid="${id}"]`)
);
Cypress.Commands.add(
'byTestId',
id: string,
options?: Partial<
>,
): Cypress.Chainable<JQuery<E>> =
cy.get(`[data-testid="${id}"]`, options),
);
interface Chainable {
/**
*/
id: string,
options?: Partial<
>,
): Cypress.Chainable<JQuery<E>>;
import './commands';
beforeEach(() => {
cy.visit('/');
});
});
cy.byTestId('count').first().should('have.text', '5');
cy.byTestId('increment-button').first().click();
cy.byTestId('count').first().should('have.text', '6');
});
cy.byTestId('decrement-button').first().click();
cy.byTestId('count').first().should('have.text', '4');
});
cy.byTestId('reset-input').first().type('123');
cy.byTestId('reset-button').first().click();
cy.byTestId('count').first().should('have.text', '123');
});
});
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
There are two ways to deal with this dependency during testing:
1. Test against the real Flickr 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.
With Cypress, both type of tests are possible. For a start, we will
test against the real Flickr API. Then, we will fake the API.
beforeEach(() => {
cy.visit('/');
});
/* … */
});
});
cy.byTestId('search-term-input')
.first()
.clear()
.type(searchTerm);
cy.byTestId('submit-search').first().click();
/* … */
});
The type command does not overwrite the form value with a
new value, but sends keyboard input, key by key.
Clicking on the submit button starts the search. When the Flickr
API has responded, we expect the search results to be appear.
cy.byTestId('photo-item-link')
.should('have.length', 15)
cy.byTestId('photo-item-link')
.should('have.length', 15)
.each((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('search-term-input')
.first()
.clear()
.type(searchTerm);
cy.byTestId('submit-search').first().click();
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
/* … */
});
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('search-term-
input').first().clear().type(searchTerm);
cy.byTestId('submit-search').first().click();
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
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
cy.visit('/');
The class has a visit method that opens the page that the page
object represents.
beforeEach(() => {
page.visit();
});
/* … */
});
SEARCH
cy.byTestId('search-term-input').first().clear().type(term);
cy.byTestId('submit-search').first().click();
ELEMENT QUERIES
Other high-level interactions, like reading the photo list and the
photo details, cannot be translated into page object methods.
But we can move the test ids and element queries to the page
object.
return cy.byTestId('photo-item-link');
return cy.byTestId('photo-item-image');
return cy.byTestId('full-photo');
return cy.byTestId('full-photo-title');
return cy.byTestId('full-photo-tags');
return cy.byTestId('full-photo-image');
beforeEach(() => {
page.visit();
});
page.searchFor(searchTerm);
page
.photoItemLinks()
.should('have.length', 15)
.each((link) => {
expect(link.attr('href')).to.contain(
'https://www.flickr.com/photos/'
);
});
page.photoItemImages().should('have.length', 15);
});
page.searchFor(searchTerm);
page.photoItemLinks().first().click();
page.fullPhoto().should('contain', searchTerm);
page.fullPhotoTitle().should('not.have.text', '');
page.fullPhotoTags().should('not.have.text', '');
page.fullPhotoImage().should('exist');
});
});
For the Flickr search above, a page object is probably too much
of a good thing. Still, the example demonstrates the key ideas of
page objects:
Move the finding of elements into the page object. The test
ids, tag names, etc. used for finding should live in a central
place.
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.
🔗 Flickr search E2E test with page object
🔗 Flickr search page object
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('/');
});
cy.byTestId('search-term-
input').first().clear().type(searchTerm);
cy.byTestId('submit-search').first().click();
cy.wait('@flickrSearchRequest');
/* … */
});
SPECIFIC ASSERTIONS
cy.byTestId('search-term-
input').first().clear().type(searchTerm);
cy.byTestId('submit-search').first().click();
cy.wait('@flickrSearchRequest');
cy.byTestId('photo-item-link')
.should('have.length', 2)
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)
expect(image.attr('src')).to.equal(photos[index].url_q);
});
});
Here, we walk through the links and images to ensure that the
URLs originate from the fake data. Previously, when testing
against the real API, we tested the links only superficially. We
could not test the image URLs at all.
Likewise, for the full photo spec, we make the assertions more
specific.
cy.byTestId('search-term-
input').first().clear().type(searchTerm);
cy.byTestId('submit-search').first().click();
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);
});
The specs now ensure that the application under test outputs
the data from the Flickr API. have.text checks an element’s text
content, whereas have.attr checks the src and href
attributes.
Even with Cypress, end-to-end tests are much more complex and
error-prone than unit and integration tests with Jasmine and
Karma. Then again, end-to-end tests are highly effective to test a
feature under realistic circumstances.
🔗 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
Thanks to Nils Binder for helping with the design, including the
dark color scheme. Thanks to Melina Jacob for designing the e-
book cover.
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.