|
1 | 1 | ## Component with async service |
2 | 2 |
|
3 | 3 | ```bash |
4 | | -npx ng g s twain |
| 4 | +npx ng g s twain/twain |
5 | 5 | ``` |
6 | 6 |
|
7 | | -Create `quote.ts` |
| 7 | +Create `twain/quote.ts` |
8 | 8 |
|
9 | 9 | ```ts |
10 | 10 | export interface Quote { |
@@ -40,7 +40,7 @@ export class TwainService { |
40 | 40 | ``` |
41 | 41 |
|
42 | 42 | ```bash |
43 | | -npx ng g c twain |
| 43 | +npx ng g c twain/twain --inline-styles --inline-template --flat |
44 | 44 | ``` |
45 | 45 |
|
46 | 46 | Update `twain.component.ts` |
@@ -154,4 +154,181 @@ The spy is designed such that any call to `getQuote` receives an observable with |
154 | 154 |
|
155 | 155 | > **NOTE**: It is best to limit the usage of spies to only what is necessary for the test. Creating mocks or spies for more than what's necessary can be brittle. As the component and injectable evolves, the unrelated tests can fail because they no longer mock enough behaviors that would otherwise not affect the test. |
156 | 156 |
|
157 | | -Notice that we are not able to test the transition between '...' to the 'test quote' text. Why is that? Basically our real service behaves as async and the Spy is behaving as sync, no time for transition here |
| 157 | +Notice that we are not able to test the transition between '...' to the 'test quote' text. Why is that? Basically our real service behaves as async and the Spy is behaving as sync, no time for transition here. |
| 158 | + |
| 159 | +### Async Behavior |
| 160 | + |
| 161 | +Create `twain.component.async-utils.spec.ts` |
| 162 | + |
| 163 | +```ts |
| 164 | +import { |
| 165 | + ComponentFixture, |
| 166 | + fakeAsync, |
| 167 | + TestBed, |
| 168 | + tick, |
| 169 | +} from '@angular/core/testing'; |
| 170 | + |
| 171 | +import { TwainComponent } from './twain.component'; |
| 172 | +import { TwainService } from './twain.service'; |
| 173 | +import { defer, of } from 'rxjs'; |
| 174 | +import { provideHttpClient } from '@angular/common/http'; |
| 175 | +import { asyncData, asyncError } from '../async-observable-helpers'; |
| 176 | + |
| 177 | +describe('TwainComponent', () => { |
| 178 | + let component: TwainComponent; |
| 179 | + let fixture: ComponentFixture<TwainComponent>; |
| 180 | + let testQuote: string; |
| 181 | + let getQuoteSpy: jasmine.Spy; |
| 182 | + |
| 183 | + beforeEach(() => { |
| 184 | + TestBed.configureTestingModule({ |
| 185 | + imports: [TwainComponent], |
| 186 | + providers: [TwainService, provideHttpClient()], |
| 187 | + }); |
| 188 | + testQuote = 'Test Quote'; |
| 189 | + |
| 190 | + // Create a fake TwainService object with `getQuote()` spy |
| 191 | + const twainService = TestBed.inject(TwainService); |
| 192 | + getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue( |
| 193 | + of(testQuote) |
| 194 | + ); |
| 195 | + |
| 196 | + fixture = TestBed.createComponent(TwainComponent); |
| 197 | + component = fixture.componentInstance; |
| 198 | + }); |
| 199 | + |
| 200 | + describe('when test with synchronous observable', () => { |
| 201 | + it('should not show quote before OnInit', () => { |
| 202 | + const quoteEl = fixture.nativeElement.querySelector('.twain'); |
| 203 | + const errorEl = fixture.nativeElement.querySelector('.error'); |
| 204 | + expect(quoteEl.textContent).withContext('nothing displayed').toBe(''); |
| 205 | + expect(errorEl).withContext('should not show error element').toBeNull(); |
| 206 | + expect(getQuoteSpy.calls.any()) |
| 207 | + .withContext('getQuote not yet called') |
| 208 | + .toBe(false); |
| 209 | + }); |
| 210 | + |
| 211 | + // The quote would not be immediately available if the service were truly async. |
| 212 | + it('should show quote after component initialized', async () => { |
| 213 | + fixture.detectChanges(); |
| 214 | + const quoteEl = fixture.nativeElement.querySelector('.twain'); |
| 215 | + await fixture.whenStable(); |
| 216 | + |
| 217 | + // sync spy result shows testQuote immediately after init |
| 218 | + expect(quoteEl.textContent).toBe(testQuote); |
| 219 | + expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true); |
| 220 | + }); |
| 221 | + |
| 222 | + // The error would not be immediately available if the service were truly async. |
| 223 | + // Use `fakeAsync` because the component error calls `setTimeout` |
| 224 | + it('should display error when TwainService fails', fakeAsync(() => { |
| 225 | + getQuoteSpy.and.returnValue( |
| 226 | + defer(() => { |
| 227 | + return new Promise((_, reject) => { |
| 228 | + setTimeout(() => { |
| 229 | + reject('TwainService test failure'); |
| 230 | + }); |
| 231 | + }); |
| 232 | + }) |
| 233 | + ); |
| 234 | + |
| 235 | + fixture.detectChanges(); // onInit(); |
| 236 | + |
| 237 | + tick(); // flush the setTimeout() |
| 238 | + |
| 239 | + fixture.detectChanges(); // update errorMessage |
| 240 | + |
| 241 | + const errorEl = fixture.nativeElement.querySelector('.error'); |
| 242 | + const quoteEl = fixture.nativeElement.querySelector('.twain'); |
| 243 | + |
| 244 | + expect(errorEl.textContent) |
| 245 | + .withContext('should display error') |
| 246 | + .toMatch(/test failure/); |
| 247 | + expect(quoteEl.textContent) |
| 248 | + .withContext('should show placeholder') |
| 249 | + .toBe('...'); |
| 250 | + })); |
| 251 | + }); |
| 252 | + |
| 253 | + describe('when test asynchronous observable', () => { |
| 254 | + beforeEach(() => { |
| 255 | + getQuoteSpy.and.returnValue(asyncData(testQuote)); |
| 256 | + }); |
| 257 | + |
| 258 | + it('should not show quote before OnInit', () => { |
| 259 | + const quoteEl = fixture.nativeElement.querySelector('.twain'); |
| 260 | + const errorEl = fixture.nativeElement.querySelector('.error'); |
| 261 | + expect(quoteEl.textContent).withContext('nothing displayed').toBe(''); |
| 262 | + expect(errorEl).withContext('should not show error element').toBeNull(); |
| 263 | + expect(getQuoteSpy.calls.any()) |
| 264 | + .withContext('getQuote not yet called') |
| 265 | + .toBe(false); |
| 266 | + }); |
| 267 | + |
| 268 | + it('should still not show quote after component initialized', () => { |
| 269 | + const quoteEl = fixture.nativeElement.querySelector('.twain'); |
| 270 | + const errorEl = fixture.nativeElement.querySelector('.error'); |
| 271 | + fixture.detectChanges(); |
| 272 | + expect(quoteEl.textContent).withContext('nothing displayed').toBe('...'); |
| 273 | + expect(errorEl).withContext('should not show error element').toBeNull(); |
| 274 | + expect(getQuoteSpy.calls.any()) |
| 275 | + .withContext('getQuote not yet called') |
| 276 | + .toBe(true); |
| 277 | + }); |
| 278 | + |
| 279 | + it('should show quote after getQuote (fakeAsync)', fakeAsync(() => { |
| 280 | + const quoteEl = fixture.nativeElement.querySelector('.twain'); |
| 281 | + const errorEl = fixture.nativeElement.querySelector('.error'); |
| 282 | + fixture.detectChanges(); // nOnInit |
| 283 | + expect(quoteEl.textContent).withContext('nothing displayed').toBe('...'); |
| 284 | + |
| 285 | + tick(); // flush the observable to get the quote |
| 286 | + fixture.detectChanges(); // update view |
| 287 | + |
| 288 | + expect(quoteEl.textContent) |
| 289 | + .withContext('should show quote') |
| 290 | + .toBe(testQuote); |
| 291 | + expect(errorEl).withContext('should not show error element').toBeNull(); |
| 292 | + })); |
| 293 | + |
| 294 | + it('should show quote after getQuote (async)', async () => { |
| 295 | + const quoteEl = fixture.nativeElement.querySelector('.twain'); |
| 296 | + const errorEl = fixture.nativeElement.querySelector('.error'); |
| 297 | + fixture.detectChanges(); // ngOnInit |
| 298 | + expect(quoteEl.textContent).withContext('nothing displayed').toBe('...'); |
| 299 | + |
| 300 | + await fixture.whenStable(); |
| 301 | + fixture.detectChanges(); // update view with quote |
| 302 | + expect(quoteEl.textContent) |
| 303 | + .withContext('should show quote') |
| 304 | + .toBe(testQuote); |
| 305 | + expect(errorEl).withContext('should not show error element').toBeNull(); |
| 306 | + }); |
| 307 | + |
| 308 | + // TODO: Have a deeper look |
| 309 | + // TODO: Implement using 'async' |
| 310 | + it('should display error when TwainService fails', fakeAsync(() => { |
| 311 | + const quoteEl = fixture.nativeElement.querySelector('.twain'); |
| 312 | + const errorEl = fixture.nativeElement.querySelector('.error'); |
| 313 | + getQuoteSpy.and.returnValue( |
| 314 | + asyncError<string>('TwainService test failure') |
| 315 | + ); |
| 316 | + |
| 317 | + fixture.detectChanges(); // ngOnInit |
| 318 | + tick(); // flush the observable |
| 319 | + fixture.detectChanges(); |
| 320 | + |
| 321 | + expect(quoteEl.textContent).withContext('nothing displayed').toBe('...'); |
| 322 | + console.log(errorEl) |
| 323 | + // expect(errorEl.textContent) |
| 324 | + // .withContext('should display error') |
| 325 | + // .toMatch(/test failure/); |
| 326 | + })); |
| 327 | + }); |
| 328 | +}); |
| 329 | + |
| 330 | +``` |
| 331 | + |
| 332 | +```bash |
| 333 | +npx ng test --include app/twain/twain.component.async-utils.spec.ts |
| 334 | +``` |
0 commit comments