Unit testing in Angular
Andrei Antal
@andrei_antal
- frontend engineer, since i can remember
- currently doing consulting and training @JSLeague
- web & JS technologies enthusiast
- UX and accessibility passionate
- perpetual learner
Frontend Developer
organizer for ngBucharest
@ngBucharest
groups/angularjs.bucharest
Hello!
Contents
- Introduction
- The Jasmine testing framework
- Writing unit tests for Angular apps
- Setting up tests
- Isolated tests
- Interaction tests
- Integration tests
- Upgrade your test tooling with Jest
Why test
What is Automated testing?
-
Unit test
-
Integration/functional tests
-
End to end tests
Benefits of Testing
-
Documented Intentions
-
Improved Design
-
Fewer Bugs into Production
-
No Regressions
-
Safer Refactoring
Unit testing tools
-
Test frameworks - where you write your test
-
Jasmine, Mocha, Tape
-
-
Test environment - where your tests are executed
-
browsers - Chrome, Firefox, etc
-
headless browsers - JSDom, PhantomJS, Puppeteer
-
-
Test runners - where you run your tests
-
Karma, Jest
-
Jasmine
-
A behavior-driven development framework for testing JavaScript code.
-
Create hierarchical suites of test - describe(‘', function)
-
The tests are written as specifications - it('', function)
-
Expectations and Matchers (built-in and custom) - expect(x).toBe(expected)
-
Spies - a test double pattern
-
Asynchronous operations support
Jasmine
describe("A resource",() => {
const resource;
beforeEach(() => {
resource = new Resource();
resource.allocateSpace();
});
afterEach(() => {
resource.free();
});
it("should have allocated 100 units of space",() => {
expect(resource.space).toEqual(100);
});
});
Runs before each test (it clause) - good place to initialize test data
Runs after each test - good place to free any used resources
Expectation
Matcher
Jasmine
Jasmine Matchers
- not
- toBe
- toEqual
- toMatch
- toBeDefined
- toBeUndefined
- toBeNull
- toBeTruthy
- toBeFalsy
- toContain
- toBeLessThan
- toBeGreaterThan
- toBeCloseTo
- toThrow
Jasmine
class Person {
helloSomeone(toGreet) {
return `${this.sayHello()} ${toGreet}`;
};
sayHello() {
return 'Hello';
};
}
Spies - Test double functions that record calls, arguments and return values
Jasmine
describe('A Person', () => {
let fakePerson;
beforeEach( () => {fakePerson = new Person();})
it('should call the sayHello() function', () => {
spyOn(fakePerson, 'sayHello');
fakePerson.helloSomeone('world');
expect(fakePerson.sayHello).toHaveBeenCalled();
});
it('should greet the world', () => {
spyOn(fakePerson, 'helloSomeone');
fakePerson.helloSomeone('world');
expect(fakePerson.helloSomeone).toHaveBeenCalledWith('world')
});
});
Jasmine
// Actually calling the method
spyOn(fakePerson, 'sayHello').and.callThrough();
// Set a return value for a call
spyOn(fakePerson, 'sayHello').and.returnValue('Hello World');
// Set a return value for a call
spyOn(fakePerson, 'sayHello').and.returnValue('Hello World');
// Call a different function
spyOn(fakePerson, 'sayHello').and.callFake(
(arguments, can, be, received) => ...);
// Get number of calls
spyOn(fakePerson, 'sayHello')
expect(fakePerson.sayHello.calls.count()).toBe(3)
fakePerson.sayHello.calls.reset() // reset the counts
// Create a "bare" spy
spy = jasmine.createSpy('whatAmI');
expect(spy).toHaveBeenCalled();
// Create a spy object
tape = jasmine.createSpyObj('tape', ['play', 'pause', 'stop', 'rewind']);
Async tests
describe('Asynchronous specs', () => {
let value;
beforeEach(() => {
value = 0;
});
it('should support async execution', done => {
setTimeout(() => {
value++;
expect(value).toBeGreaterThan(0);
done();
}, 1000);
});
});
Call the done method when the async test finished
Karma
- JavaScript test runner that integrates with a browser environment
- Created by the AngularJS team
- Configuration file to set:
- browser launchers
- test framework
- reporters
- preprocessors
Writing good tests
Structuring tests
-
Arrange all necessary preconditions and inputs.
-
Act on the object or method under test.
-
Assert that the expected results have occurred.
Structuring tests
-
DRY vs DAMP
TECHNIQUES
- Remove less interesting setup to beforeEach()
- Keep critical setup within the it()
- Include all of the "Act" and "Assert" test parts are in the it() clause
RULES
- Repeat yourself if necessary to make it easier to read
- A test should be a complete story, all within the it()
- You shouldn’t need to look around much to understand the test
- Minimize logic out of tests (what will test the tests?)
Overly DRY Test
describe("Hero Detail Component", function() {
var heroDetCmp;
beforeEach(function() {
heroDetCmp = createComponent();
heroDetCmp.ngOnInit();
});
describe('ngOninit' function() {
it("should set the hero", function() {
expect(heroDetCmp.hero).toBeDefined()
});
it("should set the heroId", function() {
expect(heroDetCmp.heroId).toBe(3));
});
});
});
DAMP Test
describe("Hero Detail Component", function() {
var heroDetCmp;
beforeEach(function() {
heroDetCmp = createComponent();
});
describe('ngOninit' function() {
it("should set the hero", function() {
heroDetCmp.ngOnInit();
expect(heroDetCmp.hero).toBeDefined()
});
it("should set the heroId", function() {
heroDetCmp.ngOnInit();
expect(heroDetCmp.heroId).toBe(3));
});
});
});
DRY vs DAMP
Unit testing in Angular
How deep to test?
-
Isolated tests: only the class, mocking everything
-
Integration tests: compiling components and using the injector
-
Shallow: mock out related components
-
Deep: include all components
-
Isolated Tests
Isolated test
@Component({
template: `
<h1>{{message}}</h1>
<button (click)="clearMessage">Clear</button>
`
})
export class GreetComponent {
public message = '';
constructor() {}
setMessage(newMessage: string) {
this.message = newMessage;
}
clearMessage() {
this.message = '';
}
}
Isolated test
import {GreetComponent} from './greet.component';
describe('Testing message state in greet.component', () => {
let greetComponent: GreetComponent;
beforeEach(() => {
greetComponent = new GreetComponent();
});
it('should set new message', () => {
greetComponent.setMessage('Testing');
expect(greetComponent.message).toBe('Testing');
});
it('should clear message', () => {
greetComponent.clearMessage();
expect(greetComponent.message).toBe('');
});
});
Integrated tests
Angular testing utilities
Angular Testing Utilities
-
TestBed - a harness for compiling components
-
inject() - provides access to injectables
-
waitForAsync() & fakeAsync() - async Zone control
describe('Testing GreetComponent', () => {
let component: GreetComponent;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ HeroComponent ],
imports: [ ... ],
providers: [ ... ],
schemas: [ ... ]
});
});
});
TestBed configures a temporary NgModule for testing
TestBed
TestBed
describe('Testing GreetComponent', () => {
let component: GreetComponent;
beforeEach(() => {
TestBed.configureTestingModule({...});
TestBed.overrideComponent(GreetComponent, {
set: {
template: '<div>Overridden template here</div>'
// ...
}
});
});
});
TestBed configurations can be overriden
Component Fixture
describe('Testing GreetComponent', () => {
let component: GreetComponent;
let fixture: ComponentFixture<GreetComponent>;
beforeEach(() => {
TestBed.configureTestingModule({...});
fixture = TestBed.createComponent(GreetComponent);
});
});
- creates an instance of the component to test
-
returns a component fixture
- access to the component instance
- access to Native DOM Element
- control Change Detection
- closes current TestBed configurations
Component Fixture
-
Access to the component, its DOM and change detection
-
componentInstance - the instance of the component created by TestBed
-
debugElement - provides insight into the component and its DOM element
-
nativeElement - the native DOM element at the root of the component
-
detectChanges() - trigger a change detection cycle for the component
-
whenStable() - returns a promise that resolves when the fixture is stable
-
Change detection
describe('Testing message state in greet.component', () => {
beforeEach(...)
it('should display original greet', () => {
fixture.detectChanges();
expect(element.textContent).toContain(component.message);
});
})
- tells Angular to perform change detection
- TestBed.createComponent() does not trigger change detection
import { ComponentFixtureAutoDetect } from '@angular/core/testing';
TestBed.configureTestingModule({
providers: [
{ provide: ComponentFixtureAutoDetect, useValue: true }
]
})
Configure automatic change detection
Change detection
describe('Testing message state in greet.component', () => {
beforeEach(...)
it('should display original greet', () => {
fixture.detectChanges();
expect(element.textContent).toContain(component.message);
});
it('should display a different test greet', () => {
component.setMessage('Test Greet');
fixture.detectChanges();
expect(element.textContent).toContain('Test Greet');
});
it('should clear the message', () => {
fixture.detectChanges();
component.clearMessage();
fixture.detectChanges();
expect(element.textContent).toBe('');
});
})
Debug Element
-
Insights into the component's DOM representation
-
parent / children - the immediate parent or children of this DebugElement
-
query(predicate) - search for one descendant that matches
-
queryAll(predicate) - search for many descendants that match
-
injector - this component's injector
-
listeners - this callback handlers for this component's events and @Outputs
-
triggerEventHandler(listener) - trigger an event or @Output
-
Querying the DOM
-
nativeElement provides:
-
querySelector(cssSelector)
-
-
debugElement provides:
-
query(predicate)
-
queryAll(predicate)
-
-
predicates can be created by helpers:
-
By.css(selector)
-
By.directive(DirectiveType)
-
Interacting with the DOM
-
nativeElement - can't use outside the browser
- dispatchEvent
- textContent
-
debugElement - doesn't have access to textContent
- triggerEventHandler
- properties
- attributes
- classes
- styles
Dependency Injection
- Gets services from the root injector
- Can be placed in beforeEach or it blocks:
let heroService;
beforeEach(() => {
heroService = TestBed.inject(HeroService);
}));
TestBed.inject(Type)
Deep Integration Tests
Deep Component Testing
- Nested Components need to be tested too
- Shallow testing (mocking all children) is not enough
- Deep tests check that
- the parent is rendering the children correctly
- the child is receiving the correct values in its inputs
- the parent handles output events correctly
Testing components with inputs and outputs
Accessing child components
-
Search for instances of the child component:
movieElements =
fixture.debugElement.queryAll(By.directive(MovieItemComponent));
-
Check the value of @Input properties on the child component :
expect(movieElements[0].componentInstance.movie).toBe(MOVIES[0]);
-
Trigger @Output bindings:
movieElements[0].triggerEventHandler('delete', null);
Testing inputs and outputs
-
Goal - Test if inputs and outputs work correctly
-
Approaches
-
test as a standalone component
-
test inside a container component
-
@Component({
selector: 'greet-message',
template: `<div class="greet">
{{message}}
<button (click)="handleClick()">LIKE</button>
</div>`
})
export class GreetComponent {
@Input() message: string;
@Output() onLiked = new EventEmitter<string>();
handleClick() {
this.onLiked.emit(this.message);
}
}
Testing inputs and outputs
• Inputs - set a value to the input property on the component object
it('should display greeting', () => {
expect(greetElementText.nativeElement.textContent).toBe(expectedMessage);
});
• Outputs - subscribe to EventEmitter, trigger click event
it('should raise selected event when clicked', () => {
let likedMessage: string;
component.onLiked.subscribe((message: string) => {
likedMessage = message;
});
greetElementButton.triggerEventHandler('click', null);
expect(likedMessage).toBe(expectedMessage);
});
Testing inputs and outputs
Test in host component - create an on the fly component to test the target component
@Component({
template: `
<greet-message [message]="greet" (onLiked)="handleLike($event)">
</greet-message>
`,
})
class TestHostComponent {
greet = 'Wassuuuup?!?';
handleLike(message: string) {
this.greet = 'New greet';
}
}
Testing inputs and outputs
describe('Test input/output for components', () => {
let fixture: ComponentFixture<TestHostComponent>;
let testHost: TestHostComponent;
let greetElementText: DebugElement;
let greetElementButton: DebugElement;
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [GreetComponent, TestHostComponent],
}).compileComponents();
}),
);
beforeEach(() => {
fixture = TestBed.createComponent(TestHostComponent);
testHost = fixture.componentInstance;
greetElementText = fixture.debugElement.query(By.css('.greet span'));
greetElementButton = fixture.debugElement.query(By.css('.greet button'));
fixture.detectChanges();
});
...
});
Declare both components in the TestBed
Only create the test host component
Testing inputs and outputs
• Inputs - set a value to the input property on the component object
it('should display greeting', () => {
const expectedGreet = testHost.greet;
expect(greetElementText.nativeElement.textContent).toBe(expectedGreet);
});
• Outputs - subscribe to EventEmitter, trigger click event
it('should raise selected event when clicked', () => {
let likedMessage: string;
component.onLiked.subscribe((message: string) => {
likedMessage = message;
});
greetElementButton.triggerEventHandler('click', null);
expect(likedMessage).toBe(expectedMessage);
});
Testing with depencencies
Testing inputs and outputs
import {Component} from '@angular/core';
import {UserService} from './user.service';
@Component({
selector: 'greet-message',
template: '<h1>{{message}}</h1>'
})
export class GreetComponent {
public message = 'Hello';
constructor(private userService: UserService) { }
ngOnInit(): void {
this.message = this.userService.isLoggedIn ?
'Welcome, ' + this.userService.user.name :
'Please log in.';
}
}
The component injects a service
Stubbing the service
userServiceStub = {
isLoggedIn: true,
user: { name: 'Test User'}
};
TestBed.configureTestingModule({
declarations: [ WelcomeComponent ],
// providers: [ UserService ]
providers: [{
provide: UserService,
useValue: userServiceStub
}]
});
userService = TestBed.inject(UserService);
A stub is just a replacement for the actual service. We can only declare the properties/methods that will be in use for the current test suite
Always access service from injector, never directly (userService Stub) - the injected service is a clone
Mocking HTTP
Mocking HTTP
describe('HttpClient testing', () => {
let httpTestingController: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ HttpClientTestingModule ]
});
httpTestingController = TestBed.inject(HttpTestingController);
});
});
Mock the Http service
-
HttpClientTestingModule - don't use the regular HttpClientModule
-
HttpTestingController - used to control the HTTP calls
Mocking HTTP
it('should get the proper todo\'s', () => {
const testData: Data = [{todo: 'Test Data'}];
// when call is made, observable emits
component.todos$.subscribe(data =>
expect(data).toEqual(testData)
);
// Match request URL's
const req = httpTestingController.expectOne('/data');
// Assert request method
expect(req.request.method).toEqual('GET');
// respond with mock data
req.flush(testData);
// make sure no outstanding calls
httpTestingController.verify();
});
Asynchronicity in Unit Tests
Zone.js
Intercepts and tracks asynchronous callbacks
- Intercept asynchronous task scheduling
- Wrap callbacks for error-handling and zone tracking across async operations.
- Provide a way to attach data to zones
- Provide a context specific last frame error handling
Configured by rules (or specs)
- AsyncTestZoneSpec - rules for async test zones
- FakeAsyncTestZoneSpec - rules for fake async test zones
Components with async dependencies
@Component({
selector: 'greet-message',
template: '<h1>{{message}}</h1>'
})
export class GreetComponent {
public message = 'Hello';
constructor(private greetingsService: GreetingsService) { }
ngOnInit() {
this.greetingsService.getGreets()
.then(greets => this.message = greets[0]);
}
}
Components with async dependencies
it('should show message (async)',
waitForAsync(() => {
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(element.textContent)
.toBe(testGreetings[0]);
});
}),
);
it('should show message (fakeAsync)',
fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
expect(element.textContent)
.toBe(testGreetings[0]);
}),
);
running the test in a special async test zone
- no access to promises called in component
- whenStable() - called when all async operations in the test complete (still need to detect changes)
allows for a more linear coding style, no need for then()-s
can only be called inside a fakeAsync, “waits” for all async operations to finish
Jest
Jest
- A testing platform, created by Facebook, mostly used in the React community
- The API is very close to the Jasmine API
- It's VERY fast
- Uses JSdom out of the box
- Snapshot testing
- Code coverage out of the box
- Watch mode (only run tests for files affected by git chenges)
npm install --save-dev jest
Jest
Behaviour Driven Development (BDD) Testing Framework
-
Create hierarchical suites of test - describe('', function)
-
The tests are written as specifications - it('', function)
-
Expectations and Matchers (built-in and custom) - expect(x).toBe(expected)
-
Spies - a test double pattern
-
Asynchronous operations support
Jest
describe("A resource",() => {
const resource;
beforeEach(() => {
resource = new Resource();
resource.allocateSpace();
});
afterEach(() => {
resource.free();
});
it("should have allocated 100 units of space",() => {
expect(resource.space).toEqual(100);
});
});
Runs before each test (it clause) - good place to initialize test data
Runs after each test - good place to free any used resources
Expectation
Matcher
Mocks and Spys
const mockObj = {
mockFn: jest.fn()
};
mockObj.mockFn('test');
expect(mockObj.mockFn).toHaveBeenCalled();
expect(mockObj.mockFn).toHaveBeenCalledTimes(1);
expect(mockObj.mockFn).toHaveBeenCalledWith('test');
const mockObj = {
mockFn: (value) => { this.prop = value; },
prop: false
};
mockObj.mockFn(true);
const spy = jest.spyOn(mockObj, 'mockFn');
expect(spy).toHaveBeenCalled();
expect(mockObj.prop).toBe(true);
Mocking implementatons
Spying on methods
Migration from Jasmine
-
jasmine.createSpyObj('name', ['key']) --> jest.fn({key: jest.fn()})
-
jasmine.createSpy('name') --> jest.fn()
-
and.returnValue() --> mockReturnValue()
-
spyOn(...).and.callFake(() => {}) --> jest.spyOn(...).mockImplementation(() => {})
-
jasmine.any, jasmine.objectContaining, etc. --> expect.any, expect.objectContaining
Angular Unit testing
By Andrei Antal
Angular Unit testing
- 1,044