angular-redux / store

Angular 2+ bindings for Redux
MIT License
1.34k stars 203 forks source link

Test example for epics.md #448

Open drewbailey opened 7 years ago

drewbailey commented 7 years ago

Hi I just used https://github.com/angular-redux/store/blob/master/articles/epics.md as a basis to implement an epic and ran into a bit of trouble figuring out how to test it. I now have a working spec and wanted to know if this would be beneficial for the epics.md page.

I am happy to submit a PR, just wanted to check first.

This is a...

e-schultz commented 7 years ago

Hi,

I'll try and provide with a working example soon - however when I was first trying to figure out how to write tests for epics, looking at how the creators of redux-observable wrote their tests was very useful.

The general pattern though was.

// pseduo code .... 
let results = [];
let myAction = new ActionObservable({...});
let myEpicClass = new ClassWithEpics(myMocks);
myEpicClass.epic(myAction).subscribe(n=>results.push(n));
expect(results[0]).toEqual(expectedOutAction)
drewbailey commented 7 years ago

Thanks yes I would be happy to help as well, The example was great but there was some slight differences when working with angular

drewbailey commented 7 years ago
describe("someEpic", () => {
  let store;
  let mockBackend;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpModule],
      providers: [
        rootEpic,
        { provide: XHRBackend, useClass: MockBackend },
      ],
    });

    const epics = TestBed.get(rootEpic);
    mockBackend = TestBed.get(XHRBackend);

    const epicMiddleware = createEpicMiddleware(rootEpic.myEpic);
    const mockStore = configureMockStore([epicMiddleware]);
    store = mockStore();
  });

  it("does something", () => {
    const payload = { thing: {} };
    const expectedState = [
      { type: SOME_ACTION },
      { type: SOME_ACTION_SUCCESS, payload },
    ];

    const response = new Response(new ResponseOptions({
      body: JSON.stringify(payload),
    }));

    mockBackend.connections.subscribe((connection) => {
      connection.mockRespond(response);
    });

    store.dispatch({ type: SOME_ACTION });

    expect(store.getActions()).toEqual(expectedState);
  });
});
gregkopp commented 7 years ago

Your test here might be going a bit too far. And you shouldn't need to use the TestBed for this. If all you want to do is unit test your epic, then it could be written this way:

import { Observable } from 'rxjs/Observable';
import { ActionsObservable } from 'redux-observable';

import { WidgetsEpics } from './widgets.epics';
import * as WidgetsTypes from './widgets.types';

describe('Epics: Widgets: ', () => {
  let epics: WidgetsEpics;

  beforeEach(() => {
    epics = new WidgetsEpics();
  });

  it('should dispatch a data recieved action when receiving a data requested action', () => {
    const initialAction$: any = ActionsObservable.of({
      type: WidgetsTypes.WIDGETS_DATA_REQUESTED
    });
    const expectedAction: any = {
      type: WidgetsTypes.WIDGETS_DATA_RECEIVED,
      payload: []
    };
    epics.watchWidgetsDataRequested(initialAction$).subscribe({
      next: (result) => {
        expect(result).toEqual(expectedAction);
      }
    });
  });
});

Where my epic looks like this:

import { Injectable } from '@angular/core';
import { ActionsObservable } from 'redux-observable';
import { Epic } from 'redux-observable-decorator';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/mergeMap';
import 'rxjs/add/observable/of';

import * as WidgetsTypes from './widgets.types';
import * as WidgetsActions from './widgets.actions';

@Injectable()
export class WidgetsEpics {

  @Epic()
  watchWidgetsDataRequested = (action$: ActionsObservable<Action>) =>
    action$.ofType(WidgetsTypes.WIDGETS_DATA_REQUESTED)
      .mergeMap(() => {
        // Do whatever you need to do here that gets your data from a web service
        return [];
      })
      .map(res => {
        return WidgetsActions.onWidgetsDataReceipt(res);
      })
      .catch(this.onError)

private onError = e => {
    this.router.navigate(['error_page']);
    return Observable.of(WidgetsActions.onServerError(e));
  }

Of course in the real world, there would be a service injected into the epic that would have to mocked out in the spec, plus any other ancillary code. Then you could simulate an error and test the onServerError functionality as well.

My point is, if you want to unit test your epic, only test the epic. As soon as you start injecting the store, even a mock, you're now likely testing more than you need to, or want to, and it's no longer a unit test. Since your epic would never be updating the store (that's the reducer's job, and would be a different spec) there is no need to check expected state.

I would add, that the only place you really need to mock redux would be anywhere you have a selector (@select) or a dispatch function (@dispatch).

Just my $0.02

drewbailey commented 7 years ago

Thanks for the feedback, I'm all in favor of simplifying my example.

I was going off of two articles. The first one defines an Epic class has a private http

The second article uses nock to mock out the http call.

When I was first writing something similar to the example I shared I ended up using TestBed as to not get an undefined error for this.http. I guess injecting dependencies into the epic might be a more favorable approach then what I have.

I did try the dependency injection way and ran into a few typescript errors, maybe a TS example for properly configuring dependency injection would help

const epicMiddleware = createEpicMiddleware(rootEpic, {
  dependencies: { getJSON: ajax.getJSON }
});
gregkopp commented 7 years ago

Even if you are injected http, you can still mock that class in your epic (although I am not a fan of that approach). I think these articles are very simplified, and that's probably a good approach from a teaching perspective, but shouldn't be taken as "this is the way to do all epic programming." :) You also should not need to create middleware for the spec. The epic class is no different than any other class.

As long as @Injectable is decorating your class, it will get injected into any class that has that type as a constructor parameter.

I take a fairly strict view of true separation of concerns. My epic should not directly interact with a web API. I will create a separate service class that will handle that. It makes mocking the web API calls much easier as well.

I create separate classes for each piece of functionality. Here's an idea of what I mean:

    - models
        - widget.ts - A model representing a widget
        - widgets-state.interface.ts - the shape of how a collection of widgets are stored in redux
    - services
        - widgets.service.ts - an injectable class that communicates with the web API (getData())
        - widgets.service.spec.ts - any unit tests for said API wrapper
    - store
        - widgets.types.ts - abstract readonly class with constant declarations of my action types
        - widgets.actions.ts - abstract readonly class with action creators
        - widgets.actions.spec.ts - any unit tests (to ensure the payloads are created properly, etc.)
        - widgets.epics.ts - Epics
        - widgets.epics.spec.ts - Epic unit tests
        - widgets.initial-state.ts - shape of the redux store on app startup
        - widgets.reducer.ts - the reducer
        - widgets.reducer.spec.ts - unit tests for the reducer
        - widgets.selectors.ts - abstract readonly class that defines selectors as named functions
        - widgets-state.service.ts - injectable class for @select and @dispatch operations (only place where you find a direct reference to redux)
        - widgets-state.service.spec.ts - tests for the state service (only place where redux is mocked)

All of this isolates functionality into defined areas - separation of concerns. Each is a unit and can be tested on its own without having to worry about interaction with other classes. As soon as you start relying on redux working correctly, it's no longer a unit test and it's more of a integration test (even if it's on a small level).

If you take a step back from your code and break it apart to something like this, you might find that it's a lot easier to debug.