ngxs / store

🚀 NGXS - State Management for Angular
http://ngxs.io
MIT License
3.53k stars 399 forks source link

🚀[FEATURE]: How to mock dispatch #1007

Open uiii opened 5 years ago

uiii commented 5 years ago

Hi, I'm creating unit tests and have a problem when testing actions. I would like to mock dispatch function in a way it really dispatches only a tested action and any additional dispatch call are only spied.

Something like this:

    it('should login with valid credentials', async (done) => {
        const action = new Login(validCredentials)

        const dispatchSpy = spyOn(store, 'dispatch').withArgs(action).and.callThrough()

        store.dispatch(action)

        await expect(store.selectSnapshot(AuthState.getError)).toBeFalsy()
        await expect(store.selectSnapshot(AuthState.getPending)).toBeTruthy()

        authTrigger.next()

        const dispatchedLoginSuccessAction = [].concat(...dispatchSpy.calls.allArgs()).find(a => a instanceof LoginSuccess)

        await expect(dispatchedLoginSuccessAction).toBeTruthy()
        await expect(dispatchedLoginSuccessAction.payload).toEqual(token)
    })

BUT! This doesn't work, because as I investigate, the store.dispatch is different from the dispatch function in the action context. I know I can use the construction like this without mocking (and it works):

        actions$.pipe(ofActionDispatched(LoginSuccess)).subscribe(async (action) => {
            await expect(action).toBeTruthy()
            done()
        })

BUT! I don't want to actually dispatch additional actions because of side effects. Consider the tested action dispatches an action from another module, so I would have to mock all services which causes side effects in that module.

I've found out the actual dispatch to be mocked is the one in the InternalStateOperations object, but I don't know how to mock it.

QUESTION So what is the proper way to make tests like this?

splincode commented 5 years ago

@uiii hi, could you please help me with your idea.

uiii commented 5 years ago

@splincode Hi, perhaps, what do you need to know?

uiii commented 5 years ago

My intention to report this issue was I started creating unit tests. But I'm not skilled at it and work with ngxs only few weeks. As I studied, I've found out unit test should really test only one unit of code and all dependencies shloud be mocked as much as possible. This make sense to me. What I didn't like at action testing as described here: https://ngxs.gitbook.io/ngxs/recipes/unit-testing is that the store is set up and the action is actually dispatched which may cause side-effects so it is more like integration testing. Few days ago I've found new way how to accomplish clean unit testing and it is mocking the action context and run the action handler directly without dispatching the action:

let indexState: IndexState
let indexStateContextMock: StateContextMock<IndexStateModel>

beforeEach(() => {
    indexState = new IndexState(indexServiceMock)
    indexStateContextMock = mockStateContext<IndexStateModel>(IndexState) 
})

it('should fetch modules', async (done) => {
    indexState.fetchModules(indexStateContextMock, new FetchModules).subscribe(() => {
        await expect(IndexState.getPending(indexStateContextMock.getState())).toBeTruthy()
        await expect(indexStateContextMock.dispatch).toHaveBeenCalledWith(new ModulesFetched(fetchedModulesFixture))
        done()
    })
})

the mock helpers are this:

export type StateContextMock<T> = jasmine.SpyObj<StateContext<T>>

export function mockStateContext<T>(stateClass: any): StateContextMock<T> {
    let values = stateClass['NGXS_OPTIONS_META'].defaults

    return {
        getState: jasmine.createSpy('getState').and.callFake(() => values),
        setState: jasmine.createSpy('setState').and.callFake((val: T) => values = val),
        patchState: jasmine.createSpy('setState').and.callFake((val: Partial<T>) => values = { ...values, ...val }),
        dispatch: jasmine.createSpy('dispatch').and.callFake(() => of())
    }
}

I'm quite happy with it. There is one problem that I have to use the 'NGXS_OPTIONS_META' constant directly because I can't import it as it is in internal package.

uiii commented 5 years ago

My colleague mentioned that with this I didn't test if the action decorator is correctly set with the appropriate action handler so it will be called by dispatching the action. I solved it with checking the action handler metadata. This is not possibly ideal because it relies on internal stuff but it works.

export function getActionFunctions(stateClass: any, actionClass: any): Function[] {
    return stateClass['NGXS_META'].actions[actionClass.type].map(meta => stateClass.prototype[meta.fn])
}

...

it('should have fetch modules action function', async () => {
    await expect(getActionFunctions(IndexState, FetchModules)).toContain(indexState.fetchModules)
})
arturovt commented 5 years ago

@uiii

I'm not aware of what's going on in this topic. Could you describe more clearly what would you like to achieve?

uiii commented 5 years ago

@arturovt Sorry man, but I'm not sure if I can describe it more.

arturovt commented 5 years ago

@uiii

As I understand from the topic - your problem is the dispatch function inside action handler - StateContext.dispatch right?

The Store.prototype.dispatch and StateContext.dispatch use the same implementation - InternalDispatcher.prototype.dispatch. Yeah their code is a little bit different, the Store class just gets operations object and calls dispatch method, whether StateContext.dispatch is got from createStateContext which also gets state operations object.

IMHO you don't have to "test actions" but actually you have to test the result of action handlers. What I mean is - you dispatch an action, you do something inside action handler (e.g. set some state), then you select the snapshot if this state and test its value.

uiii commented 5 years ago

@arturovt Yes, I'm testing the result of the action and as a result I consider the values in the state (or returned from selectors) and what other actions are dispatched. What I want is to not actually dispatch the other actions, because it could result in different state values.

Consider you have one action for fetching data which sets a pending status to true and another action for storing the fetched data which set the pending status to false. You can't test if the pending status is correctly set to true if the second action is dispatched as well.

I don't think is is neccessary to resolve my original request to be able to mock the store.dispatch function, because I solved it as described here https://github.com/ngxs/store/issues/1007#issuecomment-489430384.

What could be done is make it more convenient to provide tool to easily mock state context etc.

splincode commented 5 years ago

This problem is now considered here: https://github.com/ngxs-labs/ngxs-testing/issues/1

dmrickey commented 3 years ago

With ngxs-labs/testing being archived, is there a plan for making this doable?

DanBoSlice commented 3 years ago

As much as I love using NGXS, this seems to be a major downside. The catch of using redux state management should be the simplicity of testing. However, not being able to test dispatched actions from another action in a straight-forward way goes directly against this advantage.

markwhitfeld commented 3 years ago

Here is a code snippet of a utility that I like to use for capturing actions that have been dispatched in my tests. Hope this helps!

You can either add it to your imports (NgxsActionCollector.collectActions()) to start collecting actions from NGXS initialisation. Or you can just inject it from the TestBed and call start(), stop() and reset() as needed.

Example usage in the doc comments in the code.

import {
  Injectable,
  ModuleWithProviders,
  NgModule,
  OnDestroy,
} from '@angular/core';
import { Actions } from '@ngxs/store';
import { ActionStatus } from '@ngxs/store/src/actions-stream';
import { ReplaySubject, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class NgxsActionCollector implements OnDestroy {
  /**
   * Including this in your imported modules will
   * set up the the action collector to start collecting actions
   * from before Ngxs initializes
   * @example
   * // In your module declaration for your tests:
   * {
   *   imports: [
   *     NgxsActionCollector.collectActions(),
   *     NgxsModule.forRoot([MyState]),
   *   ],
   *   // ...
   * }
   * // and then in your test:
   * const actionCollector = TestBed.inject(NgxsActionCollector);
   * const actionsDispatched = actionCollector.dispatched;
   * const action = actionsDispatched.find(
   *   (item) => item instanceof MyAction
   * );
   * expect(action).toBeDefined();
   * @returns A module that starts the collector immediately
   */
  public static collectActions(): ModuleWithProviders<any> {
    @NgModule()
    class NgxsActionCollectorModule {
      constructor(collectorService: NgxsActionCollector) {
        collectorService.start();
      }
    }
    return {
      ngModule: NgxsActionCollectorModule,
      providers: [Actions, NgxsActionCollector],
    };
  }

  private destroyed$ = new ReplaySubject<void>(1);
  private stopped$ = new Subject<void>();
  private started = false;

  public readonly dispatched: any[] = [];
  public readonly completed: any[] = [];
  public readonly successful: any[] = [];
  public readonly errored: any[] = [];
  public readonly cancelled: any[] = [];

  constructor(private actions$: Actions) {}

  start() {
    if (this.started) {
      return;
    }
    this.started = true;
    this.actions$
      .pipe(takeUntil(this.destroyed$), takeUntil(this.stopped$))
      .subscribe({
        next: (actionCtx: { status: ActionStatus; action: any }) => {
          switch (actionCtx?.status) {
            case ActionStatus.Dispatched:
              this.dispatched.push(actionCtx.action);
              break;
            case ActionStatus.Successful:
              this.successful.push(actionCtx.action);
              this.completed.push(actionCtx.action);
              break;
            case ActionStatus.Errored:
              this.errored.push(actionCtx.action);
              this.completed.push(actionCtx.action);
              break;
            case ActionStatus.Canceled:
              this.cancelled.push(actionCtx.action);
              this.completed.push(actionCtx.action);
              break;
            default:
              break;
          }
        },
        complete: () => {
          this.started = false;
        },
        error: () => {
          this.started = false;
        },
      });
  }

  reset() {
    function clearArray(arr) {
      arr.splice(0, arr.length);
    }
    clearArray(this.dispatched);
    clearArray(this.completed);
    clearArray(this.successful);
    clearArray(this.errored);
    clearArray(this.cancelled);
  }

  stop() {
    this.stopped$.next();
  }

  ngOnDestroy(): void {
    this.destroyed$.next();
  }
}
ivan-codes-foss commented 3 years ago

Hi Mark,

Many thanks for sharing NgxsActionCollector here - it's proved to be useful in our tests. Out of interest - can this be used in production code as well as test code or may there be reasons you wouldn't recommend it? Also, would it make sense to package this into a a standalone npm package or add it to the ngxs-store package?

koraxos commented 1 year ago

Is there any update on this subject since 2 years ?