|
| 1 | +# Testing Services |
| 2 | + |
| 3 | +```bash |
| 4 | +npx ng g s value --skip-tests |
| 5 | +``` |
| 6 | + |
| 7 | +Update `value.service.spec.ts` |
| 8 | + |
| 9 | +```ts |
| 10 | +import { Injectable } from "@angular/core"; |
| 11 | +import { Observable, of } from "rxjs"; |
| 12 | + |
| 13 | +@Injectable({ |
| 14 | + providedIn: "root", |
| 15 | +}) |
| 16 | +export class ValueService { |
| 17 | + constructor() {} |
| 18 | + |
| 19 | + getValue(): string { |
| 20 | + return "real value"; |
| 21 | + } |
| 22 | + |
| 23 | + getObservableValue(): Observable<string> { |
| 24 | + return of("observable value"); |
| 25 | + } |
| 26 | + |
| 27 | + getPromiseValue(): Promise<string> { |
| 28 | + return Promise.resolve("promise value"); |
| 29 | + } |
| 30 | +} |
| 31 | +``` |
| 32 | + |
| 33 | +## Services with Dependencies |
| 34 | + |
| 35 | +```bash |
| 36 | +npx ng g s master |
| 37 | +``` |
| 38 | + |
| 39 | +Update `master.service.ts` |
| 40 | + |
| 41 | +```ts |
| 42 | +import { Injectable } from "@angular/core"; |
| 43 | +import { ValueService } from "./value.service"; |
| 44 | + |
| 45 | +@Injectable({ |
| 46 | + providedIn: "root", |
| 47 | +}) |
| 48 | +export class MasterService { |
| 49 | + constructor(private valueService: ValueService) {} |
| 50 | + |
| 51 | + getValue() { |
| 52 | + return this.valueService.getValue(); |
| 53 | + } |
| 54 | +} |
| 55 | +``` |
| 56 | + |
| 57 | +We can test this kind of services on different ways. Angular gives us support to deal with dependencies, but we can relay on different techniques out of Angular goodies. |
| 58 | + |
| 59 | +Update `master.service.spec.ts` |
| 60 | + |
| 61 | +```ts |
| 62 | +import { MasterService } from "./master.service"; |
| 63 | +import { ValueService } from "./value.service"; |
| 64 | + |
| 65 | +class FakeValueService { |
| 66 | + getValue() { |
| 67 | + return "faked service value"; |
| 68 | + } |
| 69 | +} |
| 70 | + |
| 71 | +describe("MasterService", () => { |
| 72 | + let service: MasterService; |
| 73 | + |
| 74 | + it("#getValue should return real value from the real service", () => { |
| 75 | + service = new MasterService(new ValueService()); |
| 76 | + expect(service.getValue()).toBe("real value"); |
| 77 | + }); |
| 78 | + |
| 79 | + it("#getValue should return faked value from a fakeService", () => { |
| 80 | + service = new MasterService(new FakeValueService() as ValueService); |
| 81 | + expect(service.getValue()).toBe("faked service value"); |
| 82 | + }); |
| 83 | + |
| 84 | + it("#getValue should return faked value from a fake object", () => { |
| 85 | + const fake = { getValue: () => "fake value" }; |
| 86 | + service = new MasterService(fake as ValueService); |
| 87 | + expect(service.getValue()).toBe("fake value"); |
| 88 | + }); |
| 89 | + |
| 90 | + it("#getValue should return value from a spy", () => { |
| 91 | + const valueServiceSpy = jasmine.createSpyObj("ValueService", ["getValue"]); |
| 92 | + valueServiceSpy.getValue.and.returnValue("stub value"); |
| 93 | + |
| 94 | + service = new MasterService(valueServiceSpy); |
| 95 | + |
| 96 | + expect(service.getValue()) |
| 97 | + .withContext("service returned stub value") |
| 98 | + .toBe("stub value"); |
| 99 | + expect(valueServiceSpy.getValue.calls.count()) |
| 100 | + .withContext("spy method was called once") |
| 101 | + .toBe(1); |
| 102 | + expect(valueServiceSpy.getValue.calls.mostRecent().returnValue).toBe( |
| 103 | + "stub value" |
| 104 | + ); |
| 105 | + }); |
| 106 | +}); |
| 107 | +``` |
| 108 | + |
| 109 | +```bash |
| 110 | +npx ng test --include app/master.service.spec.ts |
| 111 | +``` |
| 112 | + |
| 113 | +These standard testing techniques are great for unit testing services in isolation. |
| 114 | + |
| 115 | +However, you almost always inject services into application classes using Angular dependency injection and you should have tests that reflect that usage pattern. Angular testing utilities make it straightforward to investigate how injected services behave. |
| 116 | + |
| 117 | +## Testing HTTP services |
| 118 | + |
| 119 | +Data services that make HTTP calls to remote servers typically inject and delegate to the Angular `HttpClient` service for XHR calls. |
| 120 | + |
| 121 | +You can test a data service with an injected `HttpClient` spy as you would test any service with a dependency. |
| 122 | + |
| 123 | +```bash |
| 124 | +npx ng g s met |
| 125 | +``` |
| 126 | + |
| 127 | +Update `met.service.ts` |
| 128 | + |
| 129 | +```ts |
| 130 | +import { HttpClient } from "@angular/common/http"; |
| 131 | +import { Injectable } from "@angular/core"; |
| 132 | +import { map, Observable, tap } from "rxjs"; |
| 133 | + |
| 134 | +// API REFERENCE: https://metmuseum.github.io/ |
| 135 | +export interface Object { |
| 136 | + id: number; |
| 137 | + title: string; |
| 138 | +} |
| 139 | + |
| 140 | +export interface Department { |
| 141 | + id: number; |
| 142 | + name: string; |
| 143 | +} |
| 144 | + |
| 145 | +@Injectable({ |
| 146 | + providedIn: "root", |
| 147 | +}) |
| 148 | +export class MetService { |
| 149 | + private url = "https://collectionapi.metmuseum.org/public/collection/v1"; |
| 150 | + |
| 151 | + // https://collectionapi.metmuseum.org/public/collection/v1/objects/[objectID] |
| 152 | + // https://collectionapi.metmuseum.org/public/collection/v1/departments |
| 153 | + |
| 154 | + constructor(private http: HttpClient) {} |
| 155 | + |
| 156 | + getObjectById(id: number): Observable<Object> { |
| 157 | + return this.http.get<Object>(`${this.url}/objects/${id}`).pipe( |
| 158 | + tap((d) => console.log(d)), |
| 159 | + map((q: any) => { |
| 160 | + return q as Object; |
| 161 | + }) |
| 162 | + ); |
| 163 | + } |
| 164 | + |
| 165 | + getDepartments(): Observable<Department[]> { |
| 166 | + return this.http.get<Department[]>(`${this.url}/departments`).pipe( |
| 167 | + tap((d) => console.log(d)), |
| 168 | + map((result: any) => { |
| 169 | + const { departments } = result; |
| 170 | + return (departments as any[]).map((d) => ({ |
| 171 | + id: d.departmentId, |
| 172 | + name: d.displayName, |
| 173 | + })); |
| 174 | + }) |
| 175 | + ); |
| 176 | + } |
| 177 | +} |
| 178 | +``` |
| 179 | + |
| 180 | +Create `app/async-observable-helpers.ts` |
| 181 | + |
| 182 | +```ts |
| 183 | +import { defer } from "rxjs"; |
| 184 | + |
| 185 | +export function asyncData<T>(data: T) { |
| 186 | + return defer(() => Promise.resolve(data)); |
| 187 | +} |
| 188 | + |
| 189 | +export function asyncError<T>(errorObject: any) { |
| 190 | + return defer(() => Promise.reject(errorObject)); |
| 191 | +} |
| 192 | +``` |
| 193 | + |
| 194 | +Update `app/met.service.spec.ts` |
| 195 | + |
| 196 | +```ts |
| 197 | +import { Department, MetService } from "./met.service"; |
| 198 | +import { HttpClient } from "@angular/common/http"; |
| 199 | +import { asyncData } from "./async-observable-helpers"; |
| 200 | + |
| 201 | +describe("MetService", () => { |
| 202 | + let service: MetService; |
| 203 | + let httpClientSpy: jasmine.SpyObj<HttpClient>; |
| 204 | + |
| 205 | + beforeEach(() => { |
| 206 | + httpClientSpy = jasmine.createSpyObj("HttpClient", ["get"]); |
| 207 | + service = new MetService(httpClientSpy); |
| 208 | + }); |
| 209 | + |
| 210 | + it("should return expected departments (HttpClient called once)", (done: DoneFn) => { |
| 211 | + const departmentsModel: { |
| 212 | + departments: { departmentId: number; displayName: string }[]; |
| 213 | + } = { |
| 214 | + departments: [ |
| 215 | + { departmentId: 1, displayName: "Foo" }, |
| 216 | + { departmentId: 2, displayName: "Boo" }, |
| 217 | + ], |
| 218 | + }; |
| 219 | + |
| 220 | + const expectedDepartments: Department[] = [ |
| 221 | + { id: 1, name: "Foo" }, |
| 222 | + { id: 2, name: "Boo" }, |
| 223 | + ]; |
| 224 | + |
| 225 | + httpClientSpy.get.and.returnValue(asyncData(departmentsModel)); |
| 226 | + |
| 227 | + service.getDepartments().subscribe({ |
| 228 | + next: (departments) => { |
| 229 | + expect(departments) |
| 230 | + .withContext("expected departments") |
| 231 | + .toEqual(expectedDepartments); |
| 232 | + done(); |
| 233 | + }, |
| 234 | + error: done.fail, |
| 235 | + }); |
| 236 | + expect(httpClientSpy.get.calls.count()).withContext("one call").toBe(1); |
| 237 | + }); |
| 238 | +}); |
| 239 | +``` |
| 240 | + |
| 241 | +```bash |
| 242 | +npx ng test --include app/met.service.spec.ts |
| 243 | +``` |
| 244 | + |
| 245 | +We can even test expected errors. |
| 246 | + |
| 247 | +```diff |
| 248 | +.... |
| 249 | +-import { HttpClient } from "@angular/common/http"; |
| 250 | ++import { HttpClient, HttpErrorResponse } from "@angular/common/http"; |
| 251 | +-import { asyncData } from "./async-observable-helpers"; |
| 252 | ++import { asyncData, asyncError } from "./async-observable-helpers"; |
| 253 | +.... |
| 254 | ++ it("should return an error when the server returns a 404", (done: DoneFn) => { |
| 255 | ++ const errorResponse = new HttpErrorResponse({ |
| 256 | ++ error: "test 404 error", |
| 257 | ++ status: 404, |
| 258 | ++ statusText: "Not Found", |
| 259 | ++ }); |
| 260 | ++ |
| 261 | ++ httpClientSpy.get.and.returnValue(asyncError(errorResponse)); |
| 262 | ++ |
| 263 | ++ service.getDepartments().subscribe({ |
| 264 | ++ next: (departments) => done.fail("expected and error, not departments"), |
| 265 | ++ error: ({ error }) => { |
| 266 | ++ expect(error).toContain("test 404 error"); |
| 267 | ++ done(); |
| 268 | ++ }, |
| 269 | ++ }); |
| 270 | +}); |
| 271 | +``` |
| 272 | + |
| 273 | +## HttpClientTestingModule |
| 274 | + |
| 275 | +Extended interactions between a data service and the `HttpClient` can be complex and difficult to mock with spies. |
| 276 | + |
| 277 | +The `HttpClientTestingModule` can make these testing scenarios more manageable. |
| 278 | + |
| 279 | +Follow [HTTP Client Testing](https://angular.dev/guide/http/testing) for detail guide for using `HttpClientTestingModule`. |
0 commit comments