Open uiii opened 5 years ago
@uiii hi, could you please help me with your idea.
@splincode Hi, perhaps, what do you need to know?
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.
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)
})
@uiii
I'm not aware of what's going on in this topic. Could you describe more clearly what would you like to achieve?
@arturovt Sorry man, but I'm not sure if I can describe it more.
@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.
@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.
This problem is now considered here: https://github.com/ngxs-labs/ngxs-testing/issues/1
With ngxs-labs/testing being archived, is there a plan for making this doable?
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.
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();
}
}
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?
Is there any update on this subject since 2 years ?
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:
BUT! This doesn't work, because as I investigate, the
store.dispatch
is different from thedispatch
function in the action context. I know I can use the construction like this without mocking (and it works):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?