Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

馃殌[FEATURE]: How to mock dispatch #1007

Open
uiii opened this issue Apr 17, 2019 · 14 comments
Open

馃殌[FEATURE]: How to mock dispatch #1007

uiii opened this issue Apr 17, 2019 · 14 comments
Projects

Comments

@uiii
Copy link

uiii commented Apr 17, 2019

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
Copy link
Member

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

@uiii
Copy link
Author

uiii commented May 5, 2019

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

@uiii
Copy link
Author

uiii commented May 5, 2019

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
Copy link
Author

uiii commented May 5, 2019

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
Copy link
Member

@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
Copy link
Author

uiii commented May 27, 2019

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

@arturovt
Copy link
Member

arturovt commented May 27, 2019

@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
Copy link
Author

uiii commented May 27, 2019

@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 #1007 (comment).

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

@splincode
Copy link
Member

This problem is now considered here: ngxs-labs/testing#1

@splincode splincode changed the title How to mock dispatch 馃殌[FEATURE]: How to mock dispatch Nov 4, 2019
@dmrickey
Copy link

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

@DanBoSlice
Copy link

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
Copy link
Member

markwhitfeld commented Jun 4, 2021

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
Copy link

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
Copy link

koraxos commented Jul 11, 2023

Is there any update on this subject since 2 years ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
No open projects
Backlog
  
To do
Development

No branches or pull requests

8 participants