Skip to content

Commit

Permalink
feat(dependencies): Added explicit dependencies option to createEpicM…
Browse files Browse the repository at this point in the history
  • Loading branch information
bali182 authored and jayphelps committed Mar 2, 2017
1 parent 3e43376 commit 7e2a479
Show file tree
Hide file tree
Showing 7 changed files with 179 additions and 8 deletions.
3 changes: 2 additions & 1 deletion SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
## [Recipes](docs/recipes/SUMMARY.md)
* [Cancellation](docs/recipes/Cancellation.md)
* [Error Handling](docs/recipes/ErrorHandling.md)
* [Injecting Dependencies Into Epics](docs/recipes/InjectingDependenciesIntoEpics.md)
* [Writing Tests](docs/recipes/WritingTests.md)
* [Usage with UI Frameworks](docs/recipes/UsageWithUIFrameworks.md)
* [Hot Module Replacement](docs/recipes/HotModuleReplacement.md)
* [Adding New Epics Asynchronously](docs/recipes/AddingNewEpicsAsynchronously.md)
* [Hot Module Replacement](docs/recipes/HotModuleReplacement.md)

## Help
* [FAQ](docs/FAQ.md)
Expand Down
3 changes: 2 additions & 1 deletion docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
* [Recipes](recipes/SUMMARY.md)
* [Cancellation](recipes/Cancellation.md)
* [Error Handling](recipes/ErrorHandling.md)
* [Injecting Dependencies Into Epics](recipes/InjectingDependenciesIntoEpics.md)
* [Writing Tests](recipes/WritingTests.md)
* [Usage with UI Frameworks](recipes/UsageWithUIFrameworks.md)
* [Hot Module Replacement](recipes/HotModuleReplacement.md)
* [Adding New Epics Asynchronously](recipes/AddingNewEpicsAsynchronously.md)
* [Hot Module Replacement](recipes/HotModuleReplacement.md)
* [FAQ](FAQ.md)
* [Troubleshooting](Troubleshooting.md)
* [API Reference](api/SUMMARY.md)
Expand Down
5 changes: 5 additions & 0 deletions docs/api/createEpicMiddleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
#### Arguments

1. *`rootEpic: Epic`*: The root [Epic](../basics/Epics.md)
2. *`[options: Object]`*: The optional configuration. Options:
* *`dependencies`*: If given, it will be injected as the 3rd argument to all epics.
* *`adapter`*: An adapter object which can transform the input / output streams provided to your epics. Usually used to adapt a stream library other than RxJS v5, like [adapter-rxjs-v4](https://github.com/redux-observable/redux-observable-adapter-rxjs-v4) or [adapter-most](https://github.com/redux-observable/redux-observable-adapter-most) Options:
* *`input: ActionsObservable => any`*: Transforms the input stream of actions, `ActionsObservable` that is passed to your root Epic (transformation takes place *before* it is passed to the root epic).
* *`output: any => Observable`*: Transforms the return value of root Epic (transformation takes place *after* the root epic returned it).

#### Returns

Expand Down
79 changes: 79 additions & 0 deletions docs/recipes/InjectingDependenciesIntoEpics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Injecting Dependencies Into Epics

Injecting your dependencies into your Epics can help with testing.

Let's say you want to interact with the network. You could use the `ajax` helpers directly from `rxjs`:

```js
import { ajax } from 'rxjs/observable/dom/ajax';

const fetchUserEpic = (action$, store) =>
action$.ofType('FETCH_USER')
.mergeMap(({ payload }) =>
ajax.getJSON(`/api/users/${payload}`)
.map(response => ({
type: 'FETCH_USER_FULFILLED',
payload: response
}))
);
```

But there is a problem with this approach: Your file containing the epic imports its dependency directly, so mocking it is much more difficult.

One approach might be to mock `window.XMLHttpRequest`, but this is a lot more work and now you're not just testing your Epic, you're testing that RxJS correctly uses XMLHttpRequest a certain way when in fact that shouldn't be the goal of your test.

### Injecting dependencies

To inject dependencies you can use `createEpicMiddleware`'s `dependencies` configuration option:

```js
import { createEpicMiddleware, combineEpics } from 'redux-observable';
import { ajax } from 'rxjs/observable/dom/ajax';
import rootEpic from './somewhere';

const epicMiddleware = createEpicMiddleware(rootEpic, {
dependencies: { getJSON: ajax.getJSON }
});
```

Anything you provide will then be passed as the third argument to all your Epics, after the store.

Now your Epic can use the injected `getJSON`, instead of importing it itself:

```js
// Notice the third argument is our injected dependencies!
const fetchUserEpic = (action$, store, { getJSON }) =>
action$.ofType('FETCH_USER')
.mergeMap(() =>
getJSON(`/api/users/${payload}`)
.map(response => ({
type: 'FETCH_USER_FULFILLED',
payload: response
}))
);

```

To test, you can just call your Epic directly, passing in a mock for `getJSON`:

```js
import { ActionsObservable } from 'redux-observable';
import { fetchUserEpic } from './somewhere/fetchUserEpic';

const mockResponse = { name: 'Bilbo Baggins' };
const action$ = ActionsObservable.of({ type: 'FETCH_USERS_REQUESTED' });
const store = null; // not needed for this epic
const dependencies = {
getJSON: url => Observable.of(mockResponse)
};

// Adapt this example to your test framework and specific use cases
fetchUserEpic(action$, store, dependencies)
.toArray() // buffers all emitted actions until your Epic naturally completes()
.subscribe(actions => {
assertDeepEqual(actions, [{
type: 'FETCH_USER_FULFILLED',
payload: mockResponse
}]);
});
```
3 changes: 2 additions & 1 deletion docs/recipes/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

* [Cancellation](Cancellation.md)
* [Error Handling](ErrorHandling.md)
* [Injecting Dependencies Into Epics](InjectingDependenciesIntoEpics.md)
* [Writing Tests](WritingTests.md)
* [Usage with UI Frameworks](UsageWithUIFrameworks.md)
* [Hot Module Replacement](HotModuleReplacement.md)
* [Adding New Epics Asynchronously](AddingNewEpicsAsynchronously.md)
* [Hot Module Replacement](HotModuleReplacement.md)

Have a common pattern you can share? We would love it if you shared it with us! [Add your own Recipe](https://github.com/redux-observable/redux-observable/edit/master/docs/recipes/SUMMARY.md) or [create an issue](https://github.com/redux-observable/redux-observable/issues/new) with the examples.
15 changes: 11 additions & 4 deletions src/createEpicMiddleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ const defaultOptions = {
adapter: defaultAdapter
};

export function createEpicMiddleware(epic, { adapter = defaultAdapter } = defaultOptions) {
export function createEpicMiddleware(epic, options = defaultOptions) {
if (typeof epic !== 'function') {
throw new TypeError('You must provide a root Epic to createEpicMiddleware');
}

// even though we used default param, we need to merge the defaults
// inside the options object as well in case they declare only some
options = { ...defaultOptions, ...options };
const input$ = new Subject();
const action$ = adapter.input(
const action$ = options.adapter.input(
new ActionsObservable(input$)
);
const epic$ = new Subject();
Expand All @@ -31,13 +34,17 @@ export function createEpicMiddleware(epic, { adapter = defaultAdapter } = defaul
return next => {
epic$
::map(epic => {
const output$ = epic(action$, store);
const output$ = ('dependencies' in options)
? epic(action$, store, options.dependencies)
: epic(action$, store);

if (!output$) {
throw new TypeError(`Your root Epic "${epic.name || '<anonymous>'}" does not return a stream. Double check you\'re not missing a return statement!`);
}

return output$;
})
::switchMap(output$ => adapter.output(output$))
::switchMap(output$ => options.adapter.output(output$))
.subscribe(store.dispatch);

// Setup initial root epic
Expand Down
79 changes: 78 additions & 1 deletion test/createEpicMiddleware-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import 'babel-polyfill';
import { expect } from 'chai';
import sinon from 'sinon';
import { createStore, applyMiddleware } from 'redux';
import { createEpicMiddleware, ActionsObservable, EPIC_END } from '../';
import { createEpicMiddleware, combineEpics, ActionsObservable, EPIC_END } from '../';
// We need to import the operators separately and not add them to the Observable
// prototype, otherwise we might accidentally cover-up that the source we're
// testing uses an operator that it does not import!
Expand Down Expand Up @@ -141,4 +141,81 @@ describe('createEpicMiddleware', () => {
{ type: 3 }
]);
});

it('should not pass third argument to epic if no dependencies provided', () => {
const reducer = (state = [], action) => state;
const epic = sinon.spy(action$ => action$);

const middleware = createEpicMiddleware(epic);

createStore(reducer, applyMiddleware(middleware));
expect(epic.firstCall.args.length).to.deep.equal(2);
});

it('should inject dependencies into a single epic', () => {
const reducer = (state = [], action) => state;
const epic = sinon.spy(action$ => action$);

const middleware = createEpicMiddleware(epic, { dependencies: 'deps' });

createStore(reducer, applyMiddleware(middleware));
expect(epic.firstCall.args.length).to.deep.equal(3);
expect(epic.firstCall.args[2]).to.deep.equal('deps');
});

it('should pass literally anything provided as dependencies, even `undefined`', () => {
const reducer = (state = [], action) => state;
const epic = sinon.spy(action$ => action$);

const middleware = createEpicMiddleware(epic, { dependencies: undefined });

createStore(reducer, applyMiddleware(middleware));
expect(epic.firstCall.args.length).to.deep.equal(3);
expect(epic.firstCall.args[2]).to.deep.equal(undefined);
});

it('should inject dependencies into combined epics', () => {
const reducer = (state = [], action) => state;
const epic = sinon.spy((action$, store, { foo, bar }) => {
expect(foo).to.equal('bar');
expect(bar).to.equal('foo');
return action$;
});

const rootEpic = combineEpics(
epic,
epic,
combineEpics(
epic,
combineEpics(
epic,
epic
)
)
);

const middleware = createEpicMiddleware(rootEpic, { dependencies: { foo: 'bar', bar: 'foo' } });

createStore(reducer, applyMiddleware(middleware));

expect(epic.called).to.equal(true);
expect(epic.callCount).to.equal(5);
});

it('should call epics with all additional arguments, not just dependencies', () => {
const reducer = (state = [], action) => state;
const epic = sinon.spy((action$, store, deps, arg1, arg2) => {
expect(deps).to.equal('deps');
expect(arg1).to.equal('first');
expect(arg2).to.equal('second');
return action$;
});

const rootEpic = (...args) => combineEpics(epic)(...args, 'first', 'second');

const middleware = createEpicMiddleware(rootEpic, { dependencies: 'deps' });

createStore(reducer, applyMiddleware(middleware));
expect(epic.called).to.equal(true);
});
});

0 comments on commit 7e2a479

Please sign in to comment.