Skip to content

Commit

Permalink
feat(ng-dev): AngularContext automatically sets up http testing whe…
Browse files Browse the repository at this point in the history
…n using `provideHttpClient()`. Previously it only worked with `HttpClientModule`.

BREAKING CHANGE: `AngularContext` no longer automatically provides `HttpClient`. This is a good thing, because it will now catch when you forget to import/provide it in your production code. But it's technically a breaking change because any tests that relied on it will start to fail.
  • Loading branch information
ersimont committed May 9, 2023
1 parent 8bbaf3b commit b734bc1
Show file tree
Hide file tree
Showing 7 changed files with 101 additions and 87 deletions.
70 changes: 34 additions & 36 deletions projects/ng-dev/src/lib/angular-context/angular-context.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { OverlayContainer } from '@angular/cdk/overlay';
import { HttpClient } from '@angular/common/http';
import {
HttpClient,
HttpClientModule,
provideHttpClient,
} from '@angular/common/http';
import { HttpTestingController } from '@angular/common/http/testing';
import {
APP_INITIALIZER,
Expand All @@ -16,16 +20,13 @@ import {
import { flush, TestBed, tick } from '@angular/core/testing';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatSnackBarHarness } from '@angular/material/snack-bar/testing';
import {
ANIMATION_MODULE_TYPE,
BrowserAnimationsModule,
NoopAnimationsModule,
} from '@angular/platform-browser/animations';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { sleep } from '@s-libs/js-core';
import { noop, Observable } from 'rxjs';
import { ComponentContext } from '../component-context';
import { MockErrorHandler } from '../mock-error-handler/mock-error-handler';
import { expectSingleCallAndReset } from '../spies';
import { expectRequest } from '../test-requests';
import { AngularContext } from './angular-context';
import { FakeAsyncHarnessEnvironment } from './fake-async-harness-environment';
import createSpy = jasmine.createSpy;
Expand Down Expand Up @@ -69,7 +70,7 @@ describe('AngularContext', () => {
const ctx = new AngularContext();
const now = Date.now();
ctx.run(() => {
expect(Date.now()).toBeCloseTo(now, -1);
expect(Date.now()).toBeCloseTo(now, -2);
});
});
});
Expand All @@ -86,10 +87,20 @@ describe('AngularContext', () => {
});
});

it('sets up HttpClientTestingModule', () => {
const ctx = new AngularContext();
it('sets up testing for `HttpClientModule`', () => {
const ctx = new AngularContext({ imports: [HttpClientModule] });
ctx.run(() => {
inject(HttpClient).get('some URL').subscribe();
expectRequest('GET', 'some URL');
});
});

// this is more sensitive than the test above, since `provideHttpClientTesting()` has to end up _after_ `provideHttpClient()` to work properly
it('sets up testing for `provideHttpClient()`', () => {
const ctx = new AngularContext({ providers: [provideHttpClient()] });
ctx.run(() => {
expect(ctx.inject(HttpTestingController)).toBeDefined();
inject(HttpClient).get('some URL').subscribe();
expectRequest('GET', 'some URL');
});
});

Expand All @@ -100,6 +111,16 @@ describe('AngularContext', () => {
});
});

it('allows the user to override MockErrorHandler', () => {
const errorHandler = { handleError: noop };
const ctx = new AngularContext({
providers: [{ provide: ErrorHandler, useValue: errorHandler }],
});
ctx.run(() => {
expect(ctx.inject(ErrorHandler)).toBe(errorHandler);
});
});

it('gives a nice error message if trying to use 2 at the same time', () => {
new AngularContext().run(async () => {
expect(() => {
Expand Down Expand Up @@ -399,10 +420,10 @@ describe('AngularContext', () => {

describe('.verifyPostTestConditions()', () => {
it('errs if there are unexpected http requests', () => {
const ctx = new AngularContext();
const ctx = new AngularContext({ providers: [provideHttpClient()] });
expect(() => {
ctx.run(() => {
ctx.inject(HttpClient).get('an unexpected URL').subscribe();
inject(HttpClient).get('an unexpected URL').subscribe();
});
}).toThrowError(
'Expected no open requests, found 1: GET an unexpected URL',
Expand Down Expand Up @@ -471,7 +492,7 @@ describe('AngularContext class-level doc example', () => {
// Tests should have exactly 1 variable outside an "it": `ctx`.
let ctx: AngularContext;
beforeEach(() => {
ctx = new AngularContext();
ctx = new AngularContext({ providers: [provideHttpClient()] });
});

it('requests a post from 1 year ago', () => {
Expand All @@ -491,26 +512,3 @@ describe('AngularContext class-level doc example', () => {
});
});
});

describe('extendMetadata', () => {
it('allows animations to be unconditionally disabled', () => {
@Component({ template: '' })
class BlankComponent {}
const ctx = new ComponentContext(BlankComponent, {
imports: [BrowserAnimationsModule],
});
ctx.run(() => {
expect(ctx.inject(ANIMATION_MODULE_TYPE)).toBe('NoopAnimations');
});
});

it('allows the user to override providers', () => {
const errorHandler = { handleError: noop };
const ctx = new AngularContext({
providers: [{ provide: ErrorHandler, useValue: errorHandler }],
});
ctx.run(() => {
expect(ctx.inject(ErrorHandler)).toBe(errorHandler);
});
});
});
43 changes: 21 additions & 22 deletions projects/ng-dev/src/lib/angular-context/angular-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ComponentHarness, HarnessQuery } from '@angular/cdk/testing';
import {
HttpClientTestingModule,
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import {
AbstractType,
Expand All @@ -21,27 +22,24 @@ import {
tick,
} from '@angular/core/testing';
import { assert, convertTime } from '@s-libs/js-core';
import { clone, forOwn, isUndefined } from '@s-libs/micro-dash';
import { forOwn, isUndefined } from '@s-libs/micro-dash';
import { MockErrorHandler } from '../mock-error-handler/mock-error-handler';
import { FakeAsyncHarnessEnvironment } from './fake-async-harness-environment';

export function extendMetadata(
metadata: TestModuleMetadata,
toAdd: TestModuleMetadata,
...allMetadata: TestModuleMetadata[]
): TestModuleMetadata {
const result: any = clone(metadata);
forOwn(toAdd, (val, key) => {
const existing = result[key];
if (isUndefined(existing)) {
result[key] = val;
} else if (key === 'imports') {
// to allow ComponentContext to unconditionally disable animations, added imports override previous imports
result[key] = [result[key], val];
} else {
// but for most things we want to let what comes in from subclasses and users win
result[key] = [val, result[key]];
}
});
const result: any = {};
for (const metadata of allMetadata) {
forOwn(metadata, (val, key) => {
const existing = result[key];
if (isUndefined(existing)) {
result[key] = val;
} else {
result[key] = [result[key], val];
}
});
}
return result as TestModuleMetadata;
}

Expand All @@ -53,7 +51,7 @@ export function extendMetadata(
* thrown away, so they cannot leak between tests.
* - Clearly separates initialization code from the test itself.
* - Gives control over the simulated date & time with a single line of code.
* - Automatically includes [HttpClientTestingModule]{@link https://angular.io/api/common/http/testing/HttpClientTestingModule} to stub network requests without additional setup.
* - Automatically includes [provideHttpClientTesting()]{@link https://angular.io/api/common/http/testing/provideHttpClientTesting} to stub network requests without additional setup.
* - Always verifies that no unexpected http requests were made.
* - Automatically discards periodic tasks and flushes pending timers at the end of each test to avoid the error "X timer(s) still in the queue".
*
Expand All @@ -77,7 +75,7 @@ export function extendMetadata(
* // Tests should have exactly 1 variable outside an "it": `ctx`.
* let ctx: AngularContext;
* beforeEach(() => {
* ctx = new AngularContext();
* ctx = new AngularContext({ providers: [provideHttpClient()] });
* });
*
* it('requests a post from 1 year ago', () => {
Expand Down Expand Up @@ -117,10 +115,11 @@ export class AngularContext {
);
AngularContext.#current = this;
TestBed.configureTestingModule(
extendMetadata(moduleMetadata, {
imports: [HttpClientTestingModule],
providers: [MockErrorHandler.overrideProvider()],
}),
extendMetadata(
{ providers: [MockErrorHandler.overrideProvider()] },
moduleMetadata,
{ providers: [provideHttpClientTesting()] },
),
);
}

Expand Down
14 changes: 9 additions & 5 deletions projects/ng-dev/src/lib/test-requests/expect-request.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { HttpClient, HttpRequest } from '@angular/common/http';
import {
HttpClient,
HttpRequest,
provideHttpClient,
} from '@angular/common/http';
import { HttpTestingController } from '@angular/common/http/testing';
import { inject } from '@angular/core';
import { expectTypeOf } from 'expect-type';
import { AngularContext } from '../angular-context';
import { expectSingleCallAndReset } from '../spies';
Expand All @@ -10,7 +15,7 @@ describe('expectRequest()', () => {
let ctx: AngularContext;
let http: HttpClient;
beforeEach(() => {
ctx = new AngularContext();
ctx = new AngularContext({ providers: [provideHttpClient()] });
http = ctx.inject(HttpClient);
});

Expand Down Expand Up @@ -161,10 +166,9 @@ describe('expectRequest() outside an AngularContext', () => {

describe('expectRequest() example in the docs', () => {
it('works', () => {
const ctx = new AngularContext();
const ctx = new AngularContext({ providers: [provideHttpClient()] });
ctx.run(() => {
ctx
.inject(HttpClient)
inject(HttpClient)
.get('http://example.com', { params: { key: 'value' } })
.subscribe();
const request = expectRequest<string>('GET', 'http://example.com', {
Expand Down
5 changes: 2 additions & 3 deletions projects/ng-dev/src/lib/test-requests/expect-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,9 @@ let pendingRequests: Array<HttpRequest<any>>;
* This function only works when you are using an {@linkcode AngularContext}.
*
* ```ts
* const ctx = new AngularContext();
* const ctx = new AngularContext({ providers: [provideHttpClient()] });
* ctx.run(() => {
* ctx
* .inject(HttpClient)
* inject(HttpClient)
* .get('http://example.com', { params: { key: 'value' } })
* .subscribe();
* const request = expectRequest<string>('GET', 'http://example.com', {
Expand Down
44 changes: 29 additions & 15 deletions projects/ng-dev/src/lib/test-requests/sl-test-request.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import {
HttpErrorResponse,
HttpRequest,
HttpResponse,
provideHttpClient,
} from '@angular/common/http';
import { TestRequest } from '@angular/common/http/testing';
import { inject } from '@angular/core';
import { noop } from '@s-libs/micro-dash';
import { Subject } from 'rxjs';
import { AngularContext } from '../angular-context';
Expand All @@ -25,10 +27,10 @@ describe('SlTestRequest', () => {

describe('.flush()', () => {
it('resolves the request with the given body and options', () => {
const ctx = new AngularContext();
const ctx = new AngularContext({ providers: [provideHttpClient()] });
ctx.run(() => {
const spy = jasmine.createSpy();
ctx.inject(HttpClient).get('a url').subscribe(spy);
inject(HttpClient).get('a url').subscribe(spy);
const req = expectRequest('GET', 'a url');

const body = 'the body';
Expand All @@ -39,11 +41,10 @@ describe('SlTestRequest', () => {
});

it('passes along other arguments', () => {
const ctx = new AngularContext();
const ctx = new AngularContext({ providers: [provideHttpClient()] });
ctx.run(() => {
const spy = jasmine.createSpy();
ctx
.inject(HttpClient)
inject(HttpClient)
.request('GET', 'a url', { observe: 'response' })
.subscribe(spy);
const req = expectRequest('GET', 'a url');
Expand All @@ -57,10 +58,10 @@ describe('SlTestRequest', () => {
});

it('runs tick if an AngularContext is in use', () => {
const ctx = new AngularContext();
const ctx = new AngularContext({ providers: [provideHttpClient()] });
const spy = spyOn(ctx, 'tick');
ctx.run(() => {
ctx.inject(HttpClient).get('a url').subscribe();
inject(HttpClient).get('a url').subscribe();
const req = expectRequest('GET', 'a url');

req.flush('the body');
Expand All @@ -72,10 +73,10 @@ describe('SlTestRequest', () => {

describe('.flushError()', () => {
it('rejects the request with the given args', () => {
const ctx = new AngularContext();
const ctx = new AngularContext({ providers: [provideHttpClient()] });
ctx.run(() => {
const spy = jasmine.createSpy();
ctx.inject(HttpClient).get('a url').subscribe({ error: spy });
inject(HttpClient).get('a url').subscribe({ error: spy });
const req = expectRequest('GET', 'a url');

req.flushError(123, { statusText: 'bad', body: 'stop it' });
Expand All @@ -88,10 +89,10 @@ describe('SlTestRequest', () => {
});

it('has good default args', () => {
const ctx = new AngularContext();
const ctx = new AngularContext({ providers: [provideHttpClient()] });
ctx.run(() => {
const spy = jasmine.createSpy();
ctx.inject(HttpClient).get('a url').subscribe({ error: spy });
inject(HttpClient).get('a url').subscribe({ error: spy });
const req = expectRequest('GET', 'a url');

req.flushError();
Expand All @@ -104,10 +105,10 @@ describe('SlTestRequest', () => {
});

it('runs tick if an AngularContext is in use', () => {
const ctx = new AngularContext();
const ctx = new AngularContext({ providers: [provideHttpClient()] });
const spy = spyOn(ctx, 'tick');
ctx.run(() => {
ctx.inject(HttpClient).get('a url').subscribe({ error: noop });
inject(HttpClient).get('a url').subscribe({ error: noop });
const req = expectRequest('GET', 'a url');

req.flushError();
Expand All @@ -119,9 +120,9 @@ describe('SlTestRequest', () => {

describe('.isCancelled()', () => {
it('returns whether the request has been cancelled', () => {
const ctx = new AngularContext();
const ctx = new AngularContext({ providers: [provideHttpClient()] });
ctx.run(() => {
const subscription = ctx.inject(HttpClient).get('a url').subscribe();
const subscription = inject(HttpClient).get('a url').subscribe();
const req = expectRequest('GET', 'a url');

expect(req.isCancelled()).toBe(false);
Expand All @@ -142,4 +143,17 @@ describe('SlTestRequest', () => {
}).not.toThrowError();
});
});

it('works for the example in the docs', () => {
const ctx = new AngularContext({ providers: [provideHttpClient()] });
ctx.run(() => {
inject(HttpClient)
.get('http://example.com', { params: { key: 'value' } })
.subscribe();
const request = expectRequest<string>('GET', 'http://example.com', {
params: { key: 'value' },
});
request.flush('my response body');
});
});
});
5 changes: 2 additions & 3 deletions projects/ng-dev/src/lib/test-requests/sl-test-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@ import { HttpBody } from './expect-request';
* Though it is possible to construct yourself, normally an instance of this class is obtained from {@linkcode expectRequest()}.
*
* ```ts
* const ctx = new AngularContext();
* const ctx = new AngularContext({ providers: [provideHttpClient()] });
* ctx.run(() => {
* ctx
* .inject(HttpClient)
* inject(HttpClient)
* .get('http://example.com', { params: { key: 'value' } })
* .subscribe();
* const request = expectRequest<string>('GET', 'http://example.com', {
Expand Down
Loading

0 comments on commit b734bc1

Please sign in to comment.