In NgRx, how do you unit-test a payment effect with provideMockActions, and prove that a declined charge does not kill the effect?
answer
- fake the action stream
- @ngrx/effects/testing
- a factory, not the observable
- two actions, first one fails
- no error handler when you subscribe
basics
~20 sProvide provideMockActions(() => actions$) and a fake payment API, subscribe to the effect and assert the actions it emits; feed a failing charge then a good one and expect a failure action, then a success action.
solid answer
~40 s`provideMockActions` from `@ngrx/effects/testing` replaces the `Actions` stream the effect listens to. I pass a factory, `provideMockActions(() => actions$)`, because it is deferred: each test can assign `actions$` after `TestBed` is configured. The payment API is a fake provided with `useValue`. Then I inject the effects class, subscribe to `effects.chargeCard$` and collect what it emits (subscribe style), or compare it with marble diagrams for timing-sensitive effects. To prove resilience, `actions$` carries two `paymentSubmitted` actions, the fake's first `charge` call errors and the second succeeds, and I expect `[paymentFailed, paymentSucceeded]`. That only passes if `catchError` sits inside the flattened inner observable. Subscribing directly also bypasses NgRx's default effects error handler, so an escaping error fails the test instead of being quietly resubscribed.
code
ts · 32 linesimport { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Action } from '@ngrx/store';
import { Observable, of, throwError } from 'rxjs';
import { PaymentEffects } from './payment.effects';
import { PaymentApi } from './payment.api';
import { CheckoutPageActions, PaymentApiActions } from './checkout.actions';
it('turns a declined charge into paymentFailed and keeps listening', () => {
let actions$: Observable<Action> = of();
const charge = vi.fn()
.mockReturnValueOnce(throwError(() => 'declined'))
.mockReturnValueOnce(of({ id: 'r1' }));
TestBed.configureTestingModule({
providers: [
PaymentEffects,
provideMockActions(() => actions$),
{ provide: PaymentApi, useValue: { charge } },
],
});
const effects = TestBed.inject(PaymentEffects);
const submit = CheckoutPageActions.paymentSubmitted({ orderId: 'o1', amount: 45 });
actions$ = of(submit, submit);
const emitted: Action[] = [];
effects.chargeCard$.subscribe((a) => emitted.push(a));
expect(emitted).toEqual([
PaymentApiActions.paymentFailed({ reason: 'declined' }),
PaymentApiActions.paymentSucceeded({ receipt: { id: 'r1' } }),
]);
});go deeper
Recall provideMockActions from @ngrx/effects/testing and that an effect test subscribes to the effect and checks the actions it emits.
Explain why the factory form lets each test assign actions$, and how to fake the API so the test controls success and failure.
Prove resilience with two actions and a failing first call, and know the direct subscription bypasses the default effects error handler, exposing what production would mask.
Decide which effects deserve marble tests for timing and which subscribe tests suffice, so the suite catches ordering bugs without becoming unreadable.
## What is being tested An NgRx **effect** is a stream that listens to the store's **actions**, performs side effects such as HTTP calls, and usually maps the result to new actions. It is created with `createEffect`, either as a property of an injectable class or as a **functional effect** (`{ functional: true }`) whose dependencies are `inject()` calls in default parameters. An effect test answers one question: *given these incoming actions and this API behaviour, which actions come out?* For a checkout, `chargeCard` listens for `CheckoutPageActions.paymentSubmitted({ orderId, amount })`, calls `PaymentApi.charge`, and emits `PaymentApiActions.paymentSucceeded({ receipt })` or `paymentFailed({ reason })`. ## The two test seams | Seam | Tool | Why | |---|---|---| | Incoming actions | `provideMockActions` from `@ngrx/effects/testing` | the effect's `Actions` becomes a stream the test controls | | The side effect | a fake `PaymentApi` via `useValue` | no network, and the test decides success or failure | `provideMockActions` accepts either an observable or a factory returning one. The factory form is wrapped in `defer`, so the factory runs when the effect subscribes. That lets the recommended pattern work: declare `let actions$`, configure `provideMockActions(() => actions$)` once in `beforeEach`, and assign `actions$` inside each test. With the plain observable form, `Actions` wraps whatever observable the variable held when `provideMockActions` was called, and reassigning the variable afterwards changes nothing. If the effect also reads state (for example through `concatLatestFrom` and a selector), combine it with `provideMockStore` and pin that selector. ## Proving the effect survives a failure A payment effect has one production-critical property: **a declined card must not stop the effect**. If the error escapes into the outer stream, the effect's subscription ends and later payments are silently ignored. The test that proves otherwise: 1. Fake `charge` to return an erroring observable on the first call and a receipt on the second. 2. Set `actions$` to emit two `paymentSubmitted` actions. 3. Subscribe to the effect, collect emitted actions. 4. Expect `[paymentFailed(...), paymentSucceeded(...)]`. This passes only when `catchError` is inside the inner observable returned to the flattening operator, so the error becomes an action and the outer stream lives on. ## Why the direct subscription is stricter than the app `createEffect` defaults to `useEffectsErrorHandler: true`. When NgRx registers an effect at runtime, its resolver wraps the stream in the default error handler, which resubscribes after an error, up to 10 attempts. That safety net is applied at **registration**, not by `createEffect` itself. A test that subscribes directly to `effects.chargeCard$` gets the raw stream: an escaping error errors the subscription and the second action is never processed. The test therefore catches exactly the bug the runtime handler would mask. ## Variants you will meet - **Subscribe style**: `of(...)` or a `ReplaySubject` as the source, collect into an array. Simple and enough for synchronous fakes. - **Marble style**: hot and cold observables with the RxJS test scheduler, useful for debounce, cancellation and ordering. - **Functional effects**: call the effect with fakes as arguments, `chargeCard(actions$, fakeApi)`, with no `TestBed` at all. - **Non-dispatching effects** (`dispatch: false`): there are no result actions to check, so subscribe to run the effect and assert on the side effect, for example a spy on the router or a notification service. ## What the effect test does not prove It does not prove that a component dispatches `paymentSubmitted` (a component test with a mock store does) or that the reducer records the receipt (a reducer test does). It proves the translation from actions to side effects to actions, including the failure path. ## Common mistakes - **Testing only one action.** A single failing charge that yields `paymentFailed` passes whether `catchError` is inside or outside the flattening operator; only a second action tells the two apart. - **Dispatching through a mock store.** `MockStore.dispatch` sends actions to its own actions subject, not to the `Actions` stream `provideMockActions` created, so the effect never sees them. - **Forgetting to subscribe.** Effects are cold streams; a test that sets `actions$` and asserts without subscribing runs nothing. - **Real HTTP in the test.** Fake the API service instead; the effect test is about the action translation, not the network.
- Would this NgRx effect test still pass if catchError were moved to the outer pipe, after exhaustMap?No. An outer `catchError` replaces the whole effect stream with its fallback, which emits `paymentFailed` and completes, so the second `paymentSubmitted` is never handled and the expectation of two actions fails. That is the test's purpose: a single-action test would pass both versions.
- How do you test an NgRx effect declared with dispatch: false, such as navigating to the receipt page?It emits no actions to the store, so there is nothing to compare. Set `actions$` to the trigger action, subscribe to the effect to run it, and assert on the side effect, for example that a spied `Router.navigateByUrl` was called with the receipt URL. Without the subscription the effect never runs.
- How does testing a functional NgRx effect differ?A functional effect created with `createEffect((actions$ = inject(Actions), api = inject(PaymentApi)) => …, { functional: true })` is a function you can call directly with fakes: `chargeCard(of(submit), fakeApi)`. No `TestBed` and no `provideMockActions` are needed, which is why the NgRx docs recommend injecting dependencies as arguments.
saying these in an interview costs you the question
- provideMockActions(actions$) picks up a reassigned actions$ in every later test.
- The default effects error handler also resubscribes when a test subscribes directly.
- Dispatching on MockStore feeds actions into the effect under test.
- A dispatch: false effect needs no subscription, because it never emits actions.
- One failing action is enough to prove the effect survives errors.