Skip to content

Commit

Permalink
feat: support schedulers within run (#5619)
Browse files Browse the repository at this point in the history
* chore: add more providers

* refactor: use shorter provider names

* chore: implement delegates

* test: add failing test with schedulers

* refactor: remove delegate from schedule method

* fix: one immediate/interval handler per run

* refactor: remove AsyncScheduler.delegate

* test: use run() for some scheduler tests

* docs: remove AsyncScheduler-only bits

* chore: add comments
  • Loading branch information
cartant committed Aug 4, 2020
1 parent d9881c1 commit c63de0d
Show file tree
Hide file tree
Showing 17 changed files with 485 additions and 141 deletions.
6 changes: 3 additions & 3 deletions docs_app/content/guide/testing/marble-testing.md
Expand Up @@ -6,7 +6,7 @@

We can test our _asynchronous_ RxJS code _synchronously_ and deterministically by virtualizing time using the TestScheduler. ASCII **marble diagrams** provide a visual way for us to represent the behavior of an Observable. We can use them to assert that a particular Observable behaves as expected, as well as to create [hot and cold Observables](https://medium.com/@benlesh/hot-vs-cold-observables-f8094ed53339) we can use as mocks.

> At this time the TestScheduler can only be used to test code that uses timers, like `delay`, `debounceTime`, etc., (i.e. it uses `AsyncScheduler` with delays > 1). If the code consumes a Promise or does scheduling with `AsapScheduler`, `AnimationFrameScheduler`, etc., it cannot be reliably tested with `TestScheduler`, but instead should be tested more traditionally. See the [Known Issues](#known-issues) section for more details.
> At this time, the TestScheduler can only be used to test code that uses RxJS schedulers - `AsyncScheduler`, etc. If the code consumes a Promise, for example, it cannot be reliably tested with `TestScheduler`, but instead should be tested more traditionally. See the [Known Issues](#known-issues) section for more details.
```ts
import { TestScheduler } from 'rxjs/testing';
Expand Down Expand Up @@ -242,9 +242,9 @@ In the above situation we need the observable stream to complete so that we can

## Known issues

### RxJS code that consumes Promises or uses any of the other schedulers (e.g. AsapScheduler) cannot be directly tested
### RxJS code that consumes Promises cannot be directly tested

If you have RxJS code that uses any other form of asynchronous scheduling other than `AsyncScheduler`, e.g. Promises, `AsapScheduler`, etc. you can't reliably use marble diagrams _for that particular code_. This is because those other scheduling methods won't be virtualized or known to TestScheduler.
If you have RxJS code that uses asynchronous scheduling - e.g. Promises, etc. - you can't reliably use marble diagrams _for that particular code_. This is because those other scheduling methods won't be virtualized or known to TestScheduler.

The solution is to test that code in isolation, with the traditional asynchronous testing methods of your testing framework. The specifics depend on your testing framework of choice, but here's a pseudo-code example:

Expand Down
10 changes: 5 additions & 5 deletions spec/observables/dom/animationFrames-spec.ts
Expand Up @@ -5,7 +5,7 @@ import { animationFrames } from 'rxjs';
import { mergeMapTo, take, takeUntil } from 'rxjs/operators';
import { TestScheduler } from 'rxjs/testing';
import { observableMatcher } from '../../helpers/observableMatcher';
import { requestAnimationFrameProvider } from 'rxjs/internal/scheduler/requestAnimationFrameProvider';
import { animationFrameProvider } from 'rxjs/internal/scheduler/animationFrameProvider';

describe('animationFrames', () => {
let testScheduler: TestScheduler;
Expand Down Expand Up @@ -59,8 +59,8 @@ describe('animationFrames', () => {

it('should compose with take', () => {
testScheduler.run(({ animate, cold, expectObservable, time }) => {
const requestSpy = sinon.spy(requestAnimationFrameProvider.delegate!, 'requestAnimationFrame');
const cancelSpy = sinon.spy(requestAnimationFrameProvider.delegate!, 'cancelAnimationFrame');
const requestSpy = sinon.spy(animationFrameProvider.delegate!, 'requestAnimationFrame');
const cancelSpy = sinon.spy(animationFrameProvider.delegate!, 'cancelAnimationFrame');

animate(' ---x---x---x');
const mapped = cold('-m ');
Expand All @@ -85,8 +85,8 @@ describe('animationFrames', () => {

it('should compose with takeUntil', () => {
testScheduler.run(({ animate, cold, expectObservable, hot, time }) => {
const requestSpy = sinon.spy(requestAnimationFrameProvider.delegate!, 'requestAnimationFrame');
const cancelSpy = sinon.spy(requestAnimationFrameProvider.delegate!, 'cancelAnimationFrame');
const requestSpy = sinon.spy(animationFrameProvider.delegate!, 'requestAnimationFrame');
const cancelSpy = sinon.spy(animationFrameProvider.delegate!, 'cancelAnimationFrame');

animate(' ---x---x---x');
const mapped = cold('-m ');
Expand Down
8 changes: 3 additions & 5 deletions spec/operators/concat-spec.ts
Expand Up @@ -4,8 +4,6 @@ import { concat, mergeMap } from 'rxjs/operators';
import { TestScheduler } from 'rxjs/testing';
import { observableMatcher } from '../helpers/observableMatcher';

declare const rxTestScheduler: TestScheduler;

/** @test {concat} */
describe('concat operator', () => {
let testScheduler: TestScheduler;
Expand All @@ -20,7 +18,7 @@ describe('concat operator', () => {
const e2 = cold(' --x---y--|');
const expected = ' --a--b---x---y--|';

expectObservable(e1.pipe(concat(e2, rxTestScheduler))).toBe(expected);
expectObservable(e1.pipe(concat(e2, testScheduler))).toBe(expected);
});
});

Expand Down Expand Up @@ -347,7 +345,7 @@ describe('concat operator', () => {
const e3subs = ' ----------^-----!';
const expected = ' ---a---b-----c--|';

expectObservable(e1.pipe(concat(e2, e3, rxTestScheduler))).toBe(expected);
expectObservable(e1.pipe(concat(e2, e3, testScheduler))).toBe(expected);
expectSubscriptions(e1.subscriptions).toBe(e1subs);
expectSubscriptions(e2.subscriptions).toBe(e2subs);
expectSubscriptions(e3.subscriptions).toBe(e3subs);
Expand All @@ -360,7 +358,7 @@ describe('concat operator', () => {
const e1subs = ' ^----!';
const expected = ' ---a-|';

expectObservable(e1.pipe(concat(rxTestScheduler))).toBe(expected);
expectObservable(e1.pipe(concat(testScheduler))).toBe(expected);
expectSubscriptions(e1.subscriptions).toBe(e1subs);
});
});
Expand Down
6 changes: 2 additions & 4 deletions spec/operators/concatAll-spec.ts
Expand Up @@ -4,8 +4,6 @@ import { concatAll, take, mergeMap } from 'rxjs/operators';
import { TestScheduler } from 'rxjs/testing';
import { observableMatcher } from '../helpers/observableMatcher';

declare const rxTestScheduler: TestScheduler;

/** @test {concatAll} */
describe('concatAll operator', () => {
let testScheduler: TestScheduler;
Expand Down Expand Up @@ -488,7 +486,7 @@ describe('concatAll operator', () => {
const e3subs = ' ----------^-----!';
const expected = ' ---a---b-----c--|';

const result = of(e1, e2, e3, rxTestScheduler).pipe(concatAll());
const result = of(e1, e2, e3, testScheduler).pipe(concatAll());

expectObservable(result).toBe(expected);
expectSubscriptions(e1.subscriptions).toBe(e1subs);
Expand Down Expand Up @@ -516,7 +514,7 @@ describe('concatAll operator', () => {
const e1subs = ' ^----!';
const expected = ' ---a-|';

const result = of(e1, rxTestScheduler).pipe(concatAll());
const result = of(e1, testScheduler).pipe(concatAll());

expectObservable(result).toBe(expected);
expectSubscriptions(e1.subscriptions).toBe(e1subs);
Expand Down
77 changes: 51 additions & 26 deletions spec/schedulers/AnimationFrameScheduler-spec.ts
@@ -1,43 +1,68 @@
import { expect } from 'chai';
import * as sinon from 'sinon';
import { animationFrameScheduler, Subscription } from 'rxjs';
import { animationFrameScheduler, Subscription, merge } from 'rxjs';
import { delay } from 'rxjs/operators';
import { TestScheduler } from 'rxjs/testing';
import { observableMatcher } from '../helpers/observableMatcher';
import { animationFrameProvider } from 'rxjs/internal/scheduler/animationFrameProvider';
import { intervalProvider } from 'rxjs/internal/scheduler/intervalProvider';

const animationFrame = animationFrameScheduler;

/** @test {Scheduler} */
describe('Scheduler.animationFrame', () => {
let testScheduler: TestScheduler;

beforeEach(() => {
testScheduler = new TestScheduler(observableMatcher);
});

it('should exist', () => {
expect(animationFrame).exist;
});

it('should act like the async scheduler if delay > 0', () => {
let actionHappened = false;
const sandbox = sinon.createSandbox();
const fakeTimer = sandbox.useFakeTimers();
animationFrame.schedule(() => {
actionHappened = true;
}, 50);
expect(actionHappened).to.be.false;
fakeTimer.tick(25);
expect(actionHappened).to.be.false;
fakeTimer.tick(25);
expect(actionHappened).to.be.true;
sandbox.restore();
testScheduler.run(({ animate, cold, expectObservable, time }) => {
animate(' ----------x--');
const a = cold(' a ');
const ta = time(' ----| ');
const b = cold(' b ');
const tb = time(' --------| ');
const expected = '----a---b----';

const result = merge(
a.pipe(delay(ta, animationFrame)),
b.pipe(delay(tb, animationFrame))
);
expectObservable(result).toBe(expected);
});
});

it('should cancel animationFrame actions when unsubscribed', () => {
let actionHappened = false;
const sandbox = sinon.createSandbox();
const fakeTimer = sandbox.useFakeTimers();
animationFrame.schedule(() => {
actionHappened = true;
}, 50).unsubscribe();
expect(actionHappened).to.be.false;
fakeTimer.tick(25);
expect(actionHappened).to.be.false;
fakeTimer.tick(25);
expect(actionHappened).to.be.false;
sandbox.restore();
it('should cancel animationFrame actions when delay > 0', () => {
testScheduler.run(({ animate, cold, expectObservable, flush, time }) => {
const requestSpy = sinon.spy(animationFrameProvider, 'requestAnimationFrame');
const setSpy = sinon.spy(intervalProvider, 'setInterval');
const clearSpy = sinon.spy(intervalProvider, 'clearInterval');

animate(' ----------x--');
const a = cold(' a ');
const ta = time(' ----| ');
const subs = ' ^-! ';
const expected = '-------------';

const result = merge(
a.pipe(delay(ta, animationFrame))
);
expectObservable(result, subs).toBe(expected);

flush();
expect(requestSpy).to.have.not.been.called;
expect(setSpy).to.have.been.calledOnce;
expect(clearSpy).to.have.been.calledOnce;
requestSpy.restore();
setSpy.restore();
clearSpy.restore();
});
});

it('should schedule an action to happen later', (done: MochaDone) => {
Expand Down
73 changes: 48 additions & 25 deletions spec/schedulers/AsapScheduler-spec.ts
@@ -1,43 +1,66 @@
import { expect } from 'chai';
import * as sinon from 'sinon';
import { asapScheduler, Subscription, SchedulerAction } from 'rxjs';
import { asapScheduler, Subscription, SchedulerAction, merge } from 'rxjs';
import { delay } from 'rxjs/operators';
import { TestScheduler } from 'rxjs/testing';
import { observableMatcher } from '../helpers/observableMatcher';
import { immediateProvider } from 'rxjs/internal/scheduler/immediateProvider';
import { intervalProvider } from 'rxjs/internal/scheduler/intervalProvider';

const asap = asapScheduler;

/** @test {Scheduler} */
describe('Scheduler.asap', () => {
let testScheduler: TestScheduler;

beforeEach(() => {
testScheduler = new TestScheduler(observableMatcher);
});

it('should exist', () => {
expect(asap).exist;
});

it('should act like the async scheduler if delay > 0', () => {
let actionHappened = false;
const sandbox = sinon.createSandbox();
const fakeTimer = sandbox.useFakeTimers();
asap.schedule(() => {
actionHappened = true;
}, 50);
expect(actionHappened).to.be.false;
fakeTimer.tick(25);
expect(actionHappened).to.be.false;
fakeTimer.tick(25);
expect(actionHappened).to.be.true;
sandbox.restore();
testScheduler.run(({ cold, expectObservable, time }) => {
const a = cold(' a ');
const ta = time(' ----| ');
const b = cold(' b ');
const tb = time(' --------| ');
const expected = '----a---b----';

const result = merge(
a.pipe(delay(ta, asap)),
b.pipe(delay(tb, asap))
);
expectObservable(result).toBe(expected);
});
});

it('should cancel asap actions when delay > 0', () => {
let actionHappened = false;
const sandbox = sinon.createSandbox();
const fakeTimer = sandbox.useFakeTimers();
asap.schedule(() => {
actionHappened = true;
}, 50).unsubscribe();
expect(actionHappened).to.be.false;
fakeTimer.tick(25);
expect(actionHappened).to.be.false;
fakeTimer.tick(25);
expect(actionHappened).to.be.false;
sandbox.restore();
testScheduler.run(({ cold, expectObservable, flush, time }) => {
const setImmediateSpy = sinon.spy(immediateProvider, 'setImmediate');
const setSpy = sinon.spy(intervalProvider, 'setInterval');
const clearSpy = sinon.spy(intervalProvider, 'clearInterval');

const a = cold(' a ');
const ta = time(' ----| ');
const subs = ' ^-! ';
const expected = '-------------';

const result = merge(
a.pipe(delay(ta, asap))
);
expectObservable(result, subs).toBe(expected);

flush();
expect(setImmediateSpy).to.have.not.been.called;
expect(setSpy).to.have.been.calledOnce;
expect(clearSpy).to.have.been.calledOnce;
setImmediateSpy.restore();
setSpy.restore();
clearSpy.restore();
});
});

it('should reuse the interval for recursively scheduled actions with the same delay', () => {
Expand Down
36 changes: 23 additions & 13 deletions spec/schedulers/QueueScheduler-spec.ts
@@ -1,24 +1,34 @@
import { expect } from 'chai';
import * as sinon from 'sinon';
import { queueScheduler, Subscription } from 'rxjs';
import { queueScheduler, Subscription, merge } from 'rxjs';
import { delay } from 'rxjs/operators';
import { TestScheduler } from 'rxjs/testing';
import { observableMatcher } from '../helpers/observableMatcher';

const queue = queueScheduler;

/** @test {Scheduler} */
describe('Scheduler.queue', () => {
let testScheduler: TestScheduler;

beforeEach(() => {
testScheduler = new TestScheduler(observableMatcher);
});

it('should act like the async scheduler if delay > 0', () => {
let actionHappened = false;
const sandbox = sinon.createSandbox();
const fakeTimer = sandbox.useFakeTimers();
queue.schedule(() => {
actionHappened = true;
}, 50);
expect(actionHappened).to.be.false;
fakeTimer.tick(25);
expect(actionHappened).to.be.false;
fakeTimer.tick(25);
expect(actionHappened).to.be.true;
sandbox.restore();
testScheduler.run(({ cold, expectObservable, time }) => {
const a = cold(' a ');
const ta = time(' ----| ');
const b = cold(' b ');
const tb = time(' --------| ');
const expected = '----a---b----';

const result = merge(
a.pipe(delay(ta, queue)),
b.pipe(delay(tb, queue))
);
expectObservable(result).toBe(expected);
});
});

it('should switch from synchronous to asynchronous at will', () => {
Expand Down

0 comments on commit c63de0d

Please sign in to comment.