Skip to content

Commit

Permalink
feat(ng-dev): ComponentContextNext runs async tests and uses normal…
Browse files Browse the repository at this point in the history
… async component harnesses. Deprecate `AngularContext` in favor of the new `AngularContextNext` (which powers some of this new behavior).
  • Loading branch information
ersimont committed Jan 3, 2021
1 parent e0902c1 commit 07cecef
Show file tree
Hide file tree
Showing 20 changed files with 781 additions and 212 deletions.
6 changes: 4 additions & 2 deletions projects/integration/src/app/api-tests/ng-core.spec.ts
Expand Up @@ -291,7 +291,8 @@ describe('ng-core', () => {
flushMicrotasks();
}

ctx.run({ inputs: { string: 'initial value' } }, () => {
ctx.assignInputs({ string: 'initial value' });
ctx.run(() => {
expect(stringInput().value).toBe('initial value');

setValue(stringInput(), 'edited value');
Expand Down Expand Up @@ -405,7 +406,8 @@ describe('ng-core', () => {
flushMicrotasks();
}

ctx.run({ inputs: { shouldDisable: true } }, () => {
ctx.assignInputs({ shouldDisable: true });
ctx.run(() => {
expect(incrementButton().disabled).toBe(true);

click(toggleDisabledButton());
Expand Down
5 changes: 5 additions & 0 deletions projects/integration/src/app/api-tests/ng-dev.spec.ts
@@ -1,5 +1,6 @@
import {
AngularContext,
AngularContextNext,
AsyncMethodController,
ComponentContext,
ComponentContextNext,
Expand All @@ -18,6 +19,10 @@ describe('ng-dev', () => {
expect(AngularContext).toBeDefined();
});

it('has AngularContextNext', () => {
expect(AngularContextNext).toBeDefined();
});

it('has AsyncMethodController', () => {
expect(AsyncMethodController).toBeDefined();
});
Expand Down
3 changes: 2 additions & 1 deletion projects/ng-core/src/lib/directive-superclass.spec.ts
Expand Up @@ -188,7 +188,8 @@ describe('DirectiveSuperclass', () => {
}

const ctx2 = new ComponentContextNext(TestDirective);
ctx2.run({ inputs: { specified: 'a value' } }, () => {
ctx2.assignInputs({ specified: 'a value' });
ctx2.run(() => {
const testDirective = ctx2.getComponentInstance();
expect(testDirective.emittedValue).toBe(undefined);
});
Expand Down
6 changes: 4 additions & 2 deletions projects/ng-core/src/lib/form-control-superclass.spec.ts
Expand Up @@ -79,7 +79,8 @@ describe('FormControlSuperclass', () => {
///////

it('provides help for 2-way binding', () => {
ctx.run({ inputs: { value: 15 } }, () => {
ctx.assignInputs({ value: 15 });
ctx.run(() => {
expect(ctx.getComponentInstance().value).toBe(15);
expect(ctx.fixture.nativeElement.innerText).toContain('15');

Expand All @@ -98,7 +99,8 @@ describe('FormControlSuperclass', () => {
});

it('provides help for `[disabled]`', () => {
ctx.run({ inputs: { shouldDisable: true } }, () => {
ctx.assignInputs({ shouldDisable: true });
ctx.run(() => {
expect(incrementButton().disabled).toBe(true);

click(toggleDisabledButton());
Expand Down
4 changes: 2 additions & 2 deletions projects/ng-dev/src/lib/spies/async-method-controller.spec.ts
@@ -1,4 +1,4 @@
import { AngularContext } from '../test-context';
import { AngularContextNext } from '../test-context/angular-context/angular-context-next';
import { AsyncMethodController } from './async-method-controller';

describe('AsyncMethodController', () => {
Expand Down Expand Up @@ -343,7 +343,7 @@ describe('AsyncMethodController', () => {
describe('examples from the docs', () => {
it('can paste', () => {
const clipboard = navigator.clipboard;
const ctx = new AngularContext();
const ctx = new AngularContextNext();

// mock the browser API for pasting
const controller = new AsyncMethodController(clipboard, 'readText', {
Expand Down
9 changes: 4 additions & 5 deletions projects/ng-dev/src/lib/spies/async-method-controller.ts
@@ -1,6 +1,5 @@
import { Deferred } from '@s-libs/js-core';
import { isEqual, nth, remove } from '@s-libs/micro-dash';
import { AngularContext } from '../test-context';
import { TestCall } from './test-call';

/** @hidden */
Expand All @@ -25,11 +24,11 @@ type AsyncMethodKeys<T> = {
* ```ts
* it('can paste', () => {
* const clipboard = navigator.clipboard;
* const ctx = new AngularContext();
* const ctx = new AngularContextNext();
*
* // mock the browser API for pasting
* const controller = new AsyncMethodController(clipboard, 'readText', {
* context: ctx,
* ctx,
* });
* ctx.run(() => {
* // BEGIN production code that copies to the clipboard
Expand Down Expand Up @@ -62,9 +61,9 @@ export class AsyncMethodController<
constructor(
obj: WrappingObject,
methodName: FunctionName,
{ ctx = undefined as AngularContext | undefined } = {},
{ ctx = undefined as { tick(): void } | undefined } = {},
) {
// Note: it wasn't immediately clear how avoid `any` in this constructor, and this will be invisible to users. So I gave up. (For now.)
// Note: it wasn't immediately clear how avoid `any` in this constructor, and this will be invisible to users. So I gave up. (For now?)
this.#spy = spyOn(obj, methodName as any) as any;
this.#spy.and.callFake((() => {
const deferred = new Deferred<any>();
Expand Down
3 changes: 1 addition & 2 deletions projects/ng-dev/src/lib/spies/test-call.ts
@@ -1,6 +1,5 @@
import { Deferred } from '@s-libs/js-core';
import { PromiseType } from 'utility-types';
import { AngularContext } from '../test-context';

/**
* A mock method call that was made and is ready to be answered. This interface allows access to the underlying <code>jasmine.CallInfo</code>, and allows resolving or rejecting the asynchronous call's result.
Expand All @@ -11,7 +10,7 @@ export class TestCall<F extends jasmine.Func> {

constructor(
private deferred: Deferred<PromiseType<ReturnType<F>>>,
private ctx?: AngularContext,
private ctx?: { tick(): void },
) {}

/**
Expand Down
@@ -0,0 +1,267 @@
import { HttpClient } from '@angular/common/http';
import { HttpTestingController } from '@angular/common/http/testing';
import {
ApplicationRef,
Component,
ComponentFactoryResolver,
DoCheck,
Injectable,
InjectionToken,
Injector,
} from '@angular/core';
import { TestBed, tick } from '@angular/core/testing';
import { sleep } from '@s-libs/js-core';
import { noop, Observable } from 'rxjs';
import { AngularContextNext } from './angular-context-next';

describe('AngularContextNext', () => {
describe('.startTime', () => {
it('controls the time at which the test starts', () => {
const ctx = new AngularContextNext();
ctx.startTime = new Date('2012-07-14T21:42:17.523Z');
ctx.run(() => {
expect(new Date()).toEqual(new Date('2012-07-14T21:42:17.523Z'));
});
});

it('defaults to the current time', () => {
const ctx = new AngularContextNext();
const now = Date.now();
ctx.run(() => {
expect(Date.now()).toBeCloseTo(now, -1);
});
});
});

describe('constructor', () => {
it('accepts module metadata to be bootstrapped', () => {
const value = Symbol();
const token = new InjectionToken<symbol>('tok');
const ctx = new AngularContextNext({
providers: [{ provide: token, useValue: value }],
});
ctx.run(() => {
expect(ctx.inject(token)).toBe(value);
});
});

it('sets up HttpClientTestingModule', () => {
const ctx = new AngularContextNext();
ctx.run(() => {
expect(ctx.inject(HttpTestingController)).toBeDefined();
});
});
});

describe('.run()', () => {
it('uses the fakeAsync zone', () => {
const ctx = new AngularContextNext();
ctx.run(() => {
expect(tick).not.toThrow();
});
});

it('can handle async tests that call tick', () => {
let completed = false;
const ctx = new AngularContextNext();
ctx.run(async () => {
await sleep(0);
setTimeout(() => {
completed = true;
}, 500);
ctx.tick(500);
});
expect(completed).toBeTrue();
});
});

describe('.inject()', () => {
it('fetches from the root injector', () => {
const ctx = new AngularContextNext();
ctx.run(() => {
expect(ctx.inject(Injector)).toBe(TestBed.inject(Injector));
});
});
});

describe('.tick()', () => {
it('defaults to not advance time', () => {
const ctx = new AngularContextNext();
const start = ctx.startTime.getTime();
ctx.run(() => {
ctx.tick();
expect(Date.now()).toBe(start);
});
});

it('defaults to advancing in milliseconds', () => {
const ctx = new AngularContextNext();
const start = ctx.startTime.getTime();
ctx.run(() => {
ctx.tick(10);
expect(Date.now()).toBe(start + 10);
});
});

it('allows specifying the units to advance', () => {
const ctx = new AngularContextNext();
const start = ctx.startTime.getTime();
ctx.run(() => {
ctx.tick(10, 'sec');
expect(Date.now()).toBe(start + 10000);
});
});

it('runs change detection even if no tasks are queued', () => {
let ranChangeDetection = false;

@Component({ template: '' })
class LocalComponent implements DoCheck {
ngDoCheck(): void {
ranChangeDetection = true;
}
}
TestBed.overrideComponent(LocalComponent, {});

const ctx = new AngularContextNext();
ctx.run(() => {
const resolver = ctx.inject(ComponentFactoryResolver);
const factory = resolver.resolveComponentFactory(LocalComponent);
const componentRef = factory.create(ctx.inject(Injector));
ctx.inject(ApplicationRef).attachView(componentRef.hostView);

expect(ranChangeDetection).toBe(false);
ctx.tick();
expect(ranChangeDetection).toBe(true);
});
});

it('flushes micro tasks before running change detection', () => {
let ranChangeDetection = false;
let flushedMicroTasksBeforeChangeDetection = false;

@Component({ template: '' })
class LocalComponent implements DoCheck {
ngDoCheck(): void {
ranChangeDetection = true;
}
}
TestBed.overrideComponent(LocalComponent, {});

const ctx = new AngularContextNext();
ctx.run(() => {
const resolver = ctx.inject(ComponentFactoryResolver);
const factory = resolver.resolveComponentFactory(LocalComponent);
const componentRef = factory.create(ctx.inject(Injector));
ctx.inject(ApplicationRef).attachView(componentRef.hostView);

Promise.resolve().then(() => {
flushedMicroTasksBeforeChangeDetection = !ranChangeDetection;
});
ctx.tick();
expect(flushedMicroTasksBeforeChangeDetection).toBe(true);
});
});

it('runs change detection after timeouts', () => {
let ranTimeout = false;
let ranChangeDetectionAfterTimeout = false;

@Component({ template: '' })
class LocalComponent implements DoCheck {
ngDoCheck(): void {
ranChangeDetectionAfterTimeout = ranTimeout;
}
}
TestBed.overrideComponent(LocalComponent, {});

const ctx = new AngularContextNext();
ctx.run(() => {
const resolver = ctx.inject(ComponentFactoryResolver);
const factory = resolver.resolveComponentFactory(LocalComponent);
const componentRef = factory.create(ctx.inject(Injector));
ctx.inject(ApplicationRef).attachView(componentRef.hostView);

setTimeout(() => {
ranTimeout = true;
});
ctx.tick();
expect(ranChangeDetectionAfterTimeout).toBe(true);
});
});

it('advances `performance.now()`', () => {
const ctx = new AngularContextNext();
ctx.run(() => {
const start = performance.now();
ctx.tick(10);
expect(performance.now()).toBe(start + 10);
});
});
});

describe('.verifyPostTestConditions()', () => {
it('errs if there are unexpected http requests', () => {
const ctx = new AngularContextNext();
expect(() => {
ctx.run(() => {
ctx.inject(HttpClient).get('an unexpected URL').subscribe();
});
}).toThrowError(
'Expected no open requests, found 1: GET an unexpected URL',
);
});
});

describe('.cleanUp()', () => {
it('discards periodic tasks', () => {
const ctx = new AngularContextNext();
expect(() => {
ctx.run(() => {
setInterval(noop, 10);
});
})
// No error: "1 periodic timer(s) still in the queue."
.not.toThrowError();
});
});
});

describe('AngularContextNext class-level doc example', () => {
// This is the class we will test.
@Injectable({ providedIn: 'root' })
class MemoriesService {
constructor(private httpClient: HttpClient) {}

getLastYearToday(): Observable<any> {
const datetime = new Date();
datetime.setFullYear(datetime.getFullYear() - 1);
const date = datetime.toISOString().split('T')[0];
return this.httpClient.get(`http://example.com/post-from/${date}`);
}
}

describe('MemoriesService', () => {
// Tests should have exactly 1 variable outside an "it": `ctx`.
let ctx: AngularContextNext;
beforeEach(() => {
ctx = new AngularContextNext();
});

it('requests a post from 1 year ago', () => {
// Before calling `run`, set up any context variables this test needs.
ctx.startTime = new Date('2004-02-16T10:15:00.000Z');

// Pass the test itself as a callback to `run()`.
ctx.run(() => {
const httpBackend = ctx.inject(HttpTestingController);
const myService = ctx.inject(MemoriesService);

myService.getLastYearToday().subscribe();

httpBackend.expectOne('http://example.com/post-from/2003-02-16');
});
expect().nothing();
});
});
});

0 comments on commit 07cecef

Please sign in to comment.