Angular Testing
Jasmine
Simple Example
Angular by default using Jasmine for testing. Test follow the same approach as other frameworks. You can use pending and fail to simulate pending and guess what, failing.
describe('CalculatorService', ()=> {
it('Should add two numbers', () => {
const logger = new LoggerService()
spyOn(logger, 'log')
const calculator = new CalculatorService(logger)
const result = calculator.add(2,2)
expect(result).toBe(4)
expect(logger.log).toHaveBeenCalledTimes(1)
});
})
Spies
These allow you to track the things about the objects. In this simple example we are wanting to track how many time the function log was called on the LoggerService.
describe('CalculatorService', ()=> {
it('Should add two numbers', () => {
const logger = new LoggerService()
spyOn(logger, 'log')
const calculator = new CalculatorService(new LoggerService())
const result = calculator.add(2,2)
expect(result).toBe(4)
expect(logger.log).toHaveBeenCalledTimes(1)
});
})
In reality we should not use dependant services because the error could be in the logger service and not the one being tested. So we need to create a mock of the logger service using a SpyObject. Spy Objects are automatically spied on. We also use the beforeEach to simplify the test
let calculator: CalculatorService
let loggerSpy: any
beforeEach(()=> {
calculator = new CalculatorService(new LoggerService())
loggerSpy = jasmine.createSpyObj('LoggerService', ["log"])
})
it('Should add two numbers', () => {
const result = calculator.add(2,2)
expect(result).toBe(4)
expect(loggerSpy.log).toHaveBeenCalledTimes(1)
});
...
it('Should add two numbers', () => {
const logger = jasmine.createSpyObj('LoggerService', ["log"])
const calculator = new CalculatorService(new LoggerService())
const result = calculator.add(2,2)
expect(result).toBe(4)
expect(logger.log).toHaveBeenCalledTimes(1)
});
})
Dependency Injection
In the above example we call new on the CalculatorService. The better way to do this is to use the TestBed.configureTestingModule where we use a similar approach to the running code. We specify the class being tested as a provider from TestBed and we use our mocked versions of the logger to ensure we are ONLY testing the service.
beforeEach(()=> {
loggerSpy = jasmine.createSpyObj('LoggerService', ["log"])
TestBed.configureTestingModule({
providers: [
CalculatorService,
{provide: LoggerService, useValue: loggerSpy}
]
})
calculator = TestBed.inject(CalculatorService)
})
Example Testing HTTP Requests
Request to be Tested
Here is an example of testing the HTTP module from angular. Angular comes with its own testing client so this is fairly simple. The example shows a typical use of subscribe. Below is the method to be tested.
findAllCourses(): Observable<Course[]> {
return this.http.get('/api/courses')
.pipe(
map(res => res['payload'])
);
}
Setting up the Test
We setup the http testing client
beforeEach(()=> {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
CoursesService,
]
})
coursesService = TestBed.inject(CoursesService)
httpTestingController = TestBed.inject(HttpTestingController)
})
Test case for GET Request
Now we can write our tests. It is only when flush is called that the subscribe part of the test is run.
it('should retrieve all course',()=> {
coursesService.findAllCourses().subscribe(
courses => {
// Not undefined, NULL
expect(courses).toBeTruthy()
// length of array returned
expect(courses.length).toBe(12)
// Get a course to examine
const course = courses.find(course => course.id == 12)
// Test a property
expect(course.titles.description).toBe("Angular Testing Course")
})
const req = httpTestingController.expectOne('/api/courses')
expect(req.request.method).toEqual("GET")
req.flush({payload: Object.values(COURSES)})
})
Verifying no Other Requests
At the end of each request we call verify on the controller. This ensures no unmatched requests are outstanding.
afterEach(()=>{
httpTestingController.verify()
})
How to test Errors in Requests
Here is an example of testing an error. We deliberately flush with a status:500. Note that we fail the test if the next() function is called.
it('should give an error if save course fails', () => {
const changes :Partial<Course> = {titles:{description: 'Testing Course'}};
coursesService.saveCourse(12, changes)
.subscribe({
next:() => fail("the save course operation should have failed"),
error:(error: HttpErrorResponse) => {
expect(error.status).toBe(500);
}
});
const req = httpTestingController.expectOne('/api/courses/12');
expect(req.request.method).toEqual("PUT");
req.flush('Save course failed', {status:500,
statusText:'Internal Server Error'});
});
How to test a request with Parameters
This is very similar to the first GET request but maybe worth showing how to approach this. Here is the code, not the use of default arguments to make the test easier.
findLessons(
courseId:number, filter = '', sortOrder = 'asc',
pageNumber = 0, pageSize = 3): Observable<Lesson[]> {
return this.http.get('/api/lessons', {
params: new HttpParams()
.set('courseId', courseId.toString())
.set('filter', filter)
.set('sortOrder', sortOrder)
.set('pageNumber', pageNumber.toString())
.set('pageSize', pageSize.toString())
}).pipe(
map(res => res["payload"])
);
}
And the test case. I think the point was not to have complex request urls and to separate the testing of parameters.
it('should find a list of lessons', () => {
coursesService.findLessons(12).subscribe(lessons => {
expect(lessons).toBeTruthy();
expect(lessons.length).toBe(3);
});
const req = httpTestingController.expectOne(
req => req.url == '/api/lessons');
expect(req.request.method).toEqual("GET");
expect(req.request.params.get("courseId")).toEqual("12");
expect(req.request.params.get("filter")).toEqual("");
expect(req.request.params.get("sortOrder")).toEqual("asc");
expect(req.request.params.get("pageNumber")).toEqual("0");
expect(req.request.params.get("pageSize")).toEqual("3");
req.flush({
payload: findLessonsForCourse(12).slice(0,3)
});
});
Jasmine Tip
Putting "f" before the "it" or "describe" means on that test case/test suite will run, putting "x" e.g. xdescribe, xit will exclude this test case/test suite.
Testing Presentation components
Setup
When you want to test components you need to make sure the testbed has all of the necessary dependencies. Generally you can do this by importing the module with the component and the modules it depends on. When we import the module we need to compile the components. We do this with compileComponents which returns a promise as it is asynchronous there for we need a waitForAsync (used to be called async) to ensure before is executed BEFORE a test. This is not the await/async in the language but a utility provided by Angular. Lastly Angular provides ComponentFixture which provides an API for testing components. We have also created an debugElement which we can use to query the DOM in the test specifications.
describe('CoursesCardListComponent', () => {
let component: CoursesCardListComponent
let fixture: ComponentFixture<CoursesCardListComponent>
let el: DebugElement
beforeEach( waitForAsync( ()=>{
TestBed.configureTestingModule({
imports: [CoursesModule]
}).compileComponents().then(()=> {
fixture = TestBed.createComponent(CoursesCardListComponent)
component = fixture.componentInstance
el = fixture.debugElement
})
}) )
...
Test Presentation Template we are Testing
This is the example template we are testing in the tests below
<mat-card *ngFor="let course of courses" class="course-card mat-elevation-z10">
<mat-card-header>
<mat-card-title>{{course.titles.description}}</mat-card-title>
</mat-card-header>
<img mat-card-image [src]="course.iconUrl">
<mat-card-content>
<p>{{course.titles.longDescription}}</p>
</mat-card-content>
<mat-card-actions class="course-actions">
<button mat-button class="mat-raised-button mat-primary" [routerLink]="['/courses', course.id]">
VIEW COURSE
</button>
<button mat-button class="mat-raised-button mat-accent"
(click)="editCourse(course)">
EDIT
</button>
</mat-card-actions>
</mat-card>
Tests Cases
When testing component we need to ensure that the data has been applied to the component. We do this by calling detectChanges() on the fixture. Also shown is how to output the html which is useful for debugging
it("should display the course list", () => {
// Assign the data to the component
component.courses = Object.values(COURSES).sort(sortCoursesBySeqNo) as Course[];
// When we assign data we need to notify component
fixture.detectChanges()
// Debug an element with
console.log(el.nativeElement.outerHTML)
// lets get the cards and test expectations
const cards = el.queryAll(By.css(".course-card"))
expect(cards).toBeTruthy()
expect(cards.length).toEqual(12)
});
Let see how to test parts of the component using the query to get the first card. This is probably a good example of how to extract parts to the elements in a template.
it("should display the first course", () => {
component.courses = Object.values(COURSES).sort(sortCoursesBySeqNo) as Course[];
fixture.detectChanges()
const course = component.courses[0]
const card = el.query(By.css(".course-card:first-child"))
const title = card.query(By.css("mat-card-title"))
const image = card.query(By.css("img"))
expect(card).toBeTruthy()
expect(title.nativeElement.textContent).toBe(course.titles.description)
expect(image.nativeElement.src).toBe(course.iconUrl)
});
Testing Container components
Setup
To setup is much like the presentation setup. This component uses a service so we must create an instance to the service, create a spy and specify the method we will be mocking and specify it in the providers object. We need to specify the provide statement for it. Also, in this case, there are angular animations so we need to also use the NoopAnimationsModule which stubs the animations.
describe('HomeComponent', () => {
let fixture: ComponentFixture<HomeComponent>;
let component:HomeComponent;
let el: DebugElement;
let coursesService: any
beforeEach(waitForAsync(() => {
let coursesServiceSpy = jasmine.createSpyObj('CoursesService', ['findAllCourses', 'anotherMethod'])
TestBed.configureTestingModule({
imports: [CoursesModule,NoopAnimationsModule],
providers: [
{provide: CoursesService, useValue: coursesServiceSpy}
]
}).compileComponents().then(() =>{
fixture = TestBed.createComponent(HomeComponent)
component = fixture.componentInstance
el = fixture.debugElement
coursesService = TestBed.inject(CoursesService)
})
}));
Test Container Template we are Testing
Here is the template we will be testing
<div class="container">
<h3>All Courses</h3>
<mat-tab-group>
<ng-container *ngIf="(beginnerCourses$ | async) as beginnerCourses">
<mat-tab label="Beginners" *ngIf="beginnerCourses?.length > 0">
<courses-card-list (courseEdited)="reloadCourses()"
[courses]="beginnerCourses">
</courses-card-list>
</mat-tab>
</ng-container>
<ng-container *ngIf="(advancedCourses$ | async) as advancedCourses">
<mat-tab label="Advanced" *ngIf="advancedCourses?.length > 0">
<courses-card-list (courseEdited)="reloadCourses()"
[courses]="advancedCourses">
</courses-card-list>
</mat-tab>
</ng-container>
</mat-tab-group>
</div>
Setting up Karma
This took a lifetime to set up and make me wonder if I am using the right tool. It comes with Angular to stayed with it. What took the time was setting up the console.log to work. Here is my karma.conf.js.
module.exports = function (config) {
config.set({
basePath: "",
frameworks: ["jasmine", "@angular-devkit/build-angular"],
plugins: [
require("karma-jasmine"),
require("karma-chrome-launcher"),
require("karma-jasmine-html-reporter"),
require("karma-coverage-istanbul-reporter"),
require("karma-coverage"),
require("@angular-devkit/build-angular/plugins/karma"),
],
client: {
clearContext: false, // leave Jasmine Spec Runner output visible in browser
},
browserConsoleLogOptions: {
terminal: true,
level: ""
},
jasmineHtmlReporter: {
suppressAll: true // removes the duplicated traces
},
coverageIstanbulReporter: {
dir: require("path").join(__dirname, "./coverage/debug-tests"),
reports: ["html", "lcovonly", "text-summary"],
fixWebpackSourcePaths: true,
},
coverageReporter: {
dir: require('path').join(__dirname, './coverage/bibble-park-app'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' }
]
},
reporters: ["progress", "kjhtml"],
port: 9876,
colors: true,
logLevel: config.LOG_DEBUG,
autoWatch: true,
browsers: ["Chrome"],
singleRun: false,
restartOnFileChange: true,
});
};
RxJs Testing
Decided to look a bit more into how to test observables. My starting point is Kevin Kreuzer's Medium page here.
The RxJs team provides and approach where you define a scheduler and a RunHelper
interface RunHelpers {
cold: typeof TestScheduler.prototype.createColdObservable
hot: typeof TestScheduler.prototype.createHotObservable
flush: typeof TestScheduler.prototype.flush
time: typeof TestScheduler.prototype.createTime
expectObservable: typeof TestScheduler.prototype.expectObservable
expectSubscriptions: typeof TestScheduler.prototype.expectSubscriptions
animate: (marbles: string) => void
}
Let's define the code we are testing which is function which filters out odd numbers and multiplies the even ones by 10.
const evenTimesTen = (source$: Observable<number>): Observable<number> => {
return source$.pipe(
filter((n) => n % 2 === 0),
map((n) => n * 10),
)
}
Simple Test
Then a define the test case like any Jasmine case.
describe('ExampleRxjsTest', () => {
let scheduler: TestScheduler
beforeEach(() => {
scheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected)
})
})
it('should filter out odd numbers', () => {
scheduler.run((runhelpers: RunHelpers) => {...})
})
})
The author suggested that destructuring the interface implementation makes it a log easier to read. In this example we will define code and expectObservable.
/*
it('should filter out odd numbers', () => {
scheduler.run((runhelpers: RunHelpers) => {...})
})
*/
it('should filter out odd numbers', () => {
scheduler.run(({ cold, expectObservable }) => {
const values = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }
const source$ = cold('a-b-c-d-e-f|', values)
const expectedMarble = '--a---b---c|'
const expectedValues = { a: 20, b: 40, c: 60 }
const result$ = evenTimesTen(source$)
expectObservable(result$).toBe(expectedMarble, expectedValues)
})
})
Testing with an Error
I like this because I found my trusted divide by zero does not work with TS/Javascript. Much the same as above but this time we are testing for an error. Here is the function
const tenDivideByValue = (
source$: Observable<number>,
): Observable<number> | Observable<unknown> => {
return source$.pipe(
map((n) => {
const result = 10 / n
console.log(`Value we got was ${result}`)
if (!isFinite(result)) {
throw new Error('Divide by zero')
} else {
return result
}
}),
)
}
And here is the test case. The tricky part was understanding errorvalue meant an instance of Error.
it('should fail on divide by zero', () => {
const errorValue = new Error('Divide by zero')
scheduler.run(({ cold, animate, expectObservable }) => {
const values = { a: 5, b: 2, c: 10, d: 0 }
const source$ = cold('--a---b---c---d---|', values)
const expectedMarble = '--a---b---c---#'
animate(' ----r----r---r--')
const expectedValues = { a: 2, b: 5, c: 1 }
const result$ = tenDivideByValue(source$)
expectObservable(result$).toBe(expectedMarble, expectedValues, errorValue)
})
})
Testing with an catchError
Again a long struggle to get this working but here we go.
const tenDivideByValueCatchError = (
source$: Observable<number>,
): Observable<number> | Observable<unknown> => {
return source$.pipe(
map((n) => {
const result = 10 / n
console.log(`Value we got was ${result}`)
if (!isFinite(result)) {
throw new Error('Divide by zero')
} else {
return result
}
}),
catchError((error) => {
return of({})
}),
)
}
And here is the test case. The tricky part this time was understanding how to define on frame 4 emit a and complete.
it('should fail on divide by zero with catch', () => {
scheduler.run(({ cold, expectObservable }) => {
const values = { a: 5, b: 2, c: 10, d: 0 }
const source$ = cold('--a---b---c---d|', values)
const expectedMarble = '--a---b---c---(d|)'
const expectedValues = { a: 2, b: 5, c: 1, d: {} }
const result$ = tenDivideByValueCatchError(source$)
expectObservable(result$).toBe(expectedMarble, expectedValues)
})
})
Testing with an concat with cold
Struggled again to work off the examples on the net but getting through it has allowed me to understand the concepts.
const concatHot = (
source1$: Observable<number>,
source2$: Observable<number>,
): Observable<number> => {
return source1$.pipe(concat(source2$))
}
And the test case
it('should work with cold observables', () => {
scheduler.run(({ cold, expectObservable }) => {
const values1 = { a: 5, b: 2 }
const source1$ = cold('-a---b-|', values1)
const values2 = { c: 10, d: 0 }
const source2$ = cold('-c---d-|', values2)
const expectedMarbles = '-a---b--c---d-|'
const expectedValues = { a: 5, b: 2, c: 10, d: 0 }
const result$ = concatCold(source1$, source2$)
expectObservable(result$).toBe(expectedMarbles, expectedValues)
})
})
Testing with an concat with hot
I don't quite understand how we set the test up in real life. My guess it that the hot observable is mocking the timings of the release of the results.
const concatHot = (
source1$: Observable<number>,
source2$: Observable<number>,
): Observable<number> => {
return source1$.pipe(concat(source2$))
}
Here is the test case which does make sense.
/*
When testing hot observables you can specify the subscription
point using a caret ^, similar to how you specify subscriptions
when utilizing the expectSubscriptions assertion.
*/
it('should work with hot observables', () => {
scheduler.run(({ hot, expectObservable }) => {
const values1 = { a: 5, b: 2 }
const source1$ = hot('---a--^-b-|', values1)
const values2 = { c: 10, d: 0 }
const source2$ = hot('-----c^----d-|', values2)
const expectedMarbles = '--b--d-|'
const expectedValues = { b: 2, d: 0 }
const result$ = concatHot(source1$, source2$)
expectObservable(result$).toBe(expectedMarbles, expectedValues)
})
})
Marble Testing Anotation
You will see the source$ and expectedMarble have characters in them. This is a language that the library supports. Below is a list of some of these which can be found in detail at [1]
- '-' or '------': Equivalent to NEVER, or an observable that never emits or errors or completes.
- |: Equivalent to EMPTY, or an observable that never emits and completes immediately.
- #: Equivalent to throwError, or an observable that never emits and errors immediately.
- '--a--': An observable that waits 2 "frames", emits value a on frame 2 and then never completes.
- '--a--b--|': On frame 2 emit a, on frame 5 emit b, and on frame 8, complete.
- '--a--b--#': On frame 2 emit a, on frame 5 emit b, and on frame 8, error.
- '-a-^-b--|': In a hot observable, on frame -2 emit a, then on frame 2 emit b, and on frame 5, complete.
- '--(abc)-|': on frame 2 emit a, b, and c, then on frame 8, complete.
- '-----(a|)': on frame 5 emit a and complete.
- 'a 9ms b 9s c|': on frame 0 emit a, on frame 10 emit b, on frame 9,011 emit c, then on frame 9,012 complete.
- '--a 2.5m b': on frame 2 emit a, on frame 150,003 emit b and never complete.