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’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

provideMockActions doesn't work as intended under vitest environment and cannot produce Actions if inject is declared after effect method #4708

Open
2 tasks
blackholegalaxy opened this issue Feb 19, 2025 · 2 comments

Comments

@blackholegalaxy
Copy link

blackholegalaxy commented Feb 19, 2025

Which @ngrx/* package(s) are the source of the bug?

effects

Minimal reproduction of the bug/regression with instructions

We have a very simple effect:

import { inject, Injectable } from '@angular/core';

import { Actions, createEffect, ofType } from '@ngrx/effects';
import { tap } from 'rxjs';

import { doSomething } from '../actions/something.action';

@Injectable()
export class SomethingEffect {
  private _actions$: Actions = inject(Actions);

  public doSomething$ = createEffect(
    () =>
      this._actions$.pipe(
        ofType(doSomething),
        tap((something) => {
          console.log('something is happening here', something);
        }),
      ),
    { dispatch: false },
  );
}

Tested with:

import {
  createServiceFactory,
  SpectatorService,
  SpectatorServiceFactory,
} from '@ngneat/spectator/vitest';
import { EffectsMetadata, getEffectsMetadata } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { ReplaySubject, Subject, takeUntil } from 'rxjs';
import { Mock } from 'vitest';

import { doSomething } from '../actions/something.action';
import { SomethingEffect } from './something.effect';

describe('Something Effects', () => {
  let action$: Subject<unknown>;
  let effects!: SomethingEffect;
  let spy: Mock;
  let metadata: EffectsMetadata<SomethingEffect>;
  let unsubscribe$: ReplaySubject<boolean>;

  let spectator: SpectatorService<SomethingEffect>;

  const createService: SpectatorServiceFactory<SomethingEffect> =
    createServiceFactory({
      service: SomethingEffect,
      providers: [provideMockActions(() => action$)],
    });

  beforeAll(() => {
    unsubscribe$ = new ReplaySubject(1);
  });

  afterAll(() => {
    unsubscribe$.next(true);
    unsubscribe$.complete();
  });

  beforeEach(() => {
    spectator = createService();
    effects = spectator.service;

    metadata = getEffectsMetadata(effects);

    spy = vi.fn();
    action$ = new ReplaySubject(1);
  });

  describe('doSomething$', () => {
    beforeEach(() => {
      vi.clearAllMocks();
      effects.doSomething$.pipe(takeUntil(unsubscribe$)).subscribe(spy);
    });

    it('should NOT dispatch an action', () => {
      expect(metadata.doSomething$).toEqual({
        dispatch: false,
        useEffectsErrorHandler: true,
      });
    });

    describe('on do something', () => {
      const consoleSpy = vi.spyOn(console, 'log');
      const action = doSomething({ something: 'you sure?' });

      beforeEach(() => {
        action$.next(action);
      });

      it('should log message when doSomething action is dispatched, () => {
        expect(consoleSpy).toHaveBeenCalled();
      });
    });
  });
});

This works actually. We obtain the proper result.

 ✓ src/app/effects/something.effect.spec.ts (2 tests) 26ms

BUT, if we just do this:

@Injectable()
export class SomethingEffect {
  public doSomething$ = createEffect(
    () =>
      this._actions$.pipe(
        ofType(doSomething),
        tap((something) => {
          console.log('something is happening here', something);
        }),
      ),
    { dispatch: false },
  );

  private _actions$: Actions = inject(Actions);
}

So basically, put the private at the bottom. It then fails:

 FAIL  src/app/effects/something.effect.spec.ts > Something Effects > doSomething$ > should NOT dispatch an action
 FAIL  src/app/effects/something.effect.spec.ts > Something Effects > doSomething$ > on do something > should log message when doSomething action is dispatched
TypeError: Cannot read properties of undefined (reading 'pipe')
 ❯ SomethingEffect2.doSomething$.dispatch src/app/effects/something.effect.ts:12:22
     10|   public doSomething$ = createEffect(
     11|     () =>
     12|       this._actions$.pipe(
       |                      ^
     13|         ofType(doSomething),
     14|         tap((something) => {
 ❯ createEffect ../../modules/effects/src/effect_creator.ts:117:47
 ❯ new SomethingEffect2 src/app/effects/something.effect.ts:10:25
 ❯ Object.SomethingEffect2_Factory [as factory] ../../ng:/SomethingEffect2/ɵfac.js:5:10
 ❯ ../../../../packages/core/src/di/r3_injector.ts:479:35
 ❯ runInInjectorProfilerContext ../../../../packages/core/src/render3/debug/injector_profiler.ts:308:5
 ❯ R3Injector.hydrate ../../../../packages/core/src/di/r3_injector.ts:478:11
 ❯ R3Injector.get ../../../../packages/core/src/di/r3_injector.ts:341:23
 ❯ _TestBedImpl.inject ../../../../packages/core/testing/src/test_bed_compiler.ts:1174:47
 ❯ Function.inject ../../../../packages/core/testing/src/test_bed_compiler.ts:1008:19

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/4]⎯

 FAIL  src/app/effects/something.effect.spec.ts > Something Effects > doSomething$ > should NOT dispatch an action
 FAIL  src/app/effects/something.effect.spec.ts > Something Effects > doSomething$ > on do something > should log message when doSomething action is dispatched
Error: NG0200: Circular dependency in DI detected for SomethingEffect2. Find more at https://angular.dev/errors/NG0200
 ❯ throwCyclicDependencyError ../../../../packages/core/src/render3/errors_di.ts:20:9
 ❯ R3Injector.hydrate ../../../../packages/core/src/di/r3_injector.ts:473:9
 ❯ R3Injector.get ../../../../packages/core/src/di/r3_injector.ts:341:23
 ❯ _TestBedImpl.inject ../../../../packages/core/testing/src/test_bed_compiler.ts:1174:47
 ❯ Function.inject ../../../../packages/core/testing/src/test_bed_compiler.ts:1008:19
 ❯ ../../node_modules/projects/spectator/src/lib/spectator/create-factory.ts:98:1
 ❯ _ZoneDelegate.invoke ../../node_modules/zone.js/bundles/zone.umd.js:416:32
 ❯ ProxyZoneSpec.onInvoke ../../node_modules/zone.js/bundles/zone-testing.umd.js:2177:43
 ❯ _ZoneDelegate.invoke ../../node_modules/zone.js/bundles/zone.umd.js:415:38
 ❯ ZoneImpl.run ../../node_modules/zone.js/bundles/zone.umd.js:147:47

Please note we did try to use TestBed directly or even instantiate the Effect manually (and removing it from providers of course) with the exact same results. As example:

beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [SomethingEffect, provideMockActions(() => actions$)],
    });

    effects = TestBed.inject(SomethingEffect);
  });

We also tested with subscribes in the test itself with same error.

it('should log message when doSomething action is dispatched', () =>
    new Promise<void>((done) => {
      const action = doSomething({ something: 'test' });
      actions$ = of(action);

      effects.doSomething$.subscribe((act) => {
        expect(act).toEqual(action);
        done();
      });
    }));

Expected behavior

Order of private / public method should not have an impact on how tests are handled.

Versions of NgRx, Angular, Node, affected browser(s) and operating system(s)

NGRX: 19.0.1
NX: 20.4.4
Angular: ~19.1.6
Node: 20.17.0
Macos, windows, ubuntu

@analogjs/vite-plugin-angular: ~1.13.1,
@analogjs/vitest-angular: ~1.13.1,
vitest: 3.0.6

Other information

You can try it easily: create a nx project with angular and vitest. Just drop the files there, run tests.

As a counter-example to isolate issue and see if issue was caused by vitest or other, we did:

import { Injectable } from '@angular/core';

import { BehaviorSubject } from 'rxjs';

@Injectable()
export class SomeService {
  public getSomething() {
    return new BehaviorSubject('something');
  }
}

@Injectable()
export class OtherService {
  public printSomething() {
    console.log(this._someService.getSomething().value);
  }

  private _someService: SomeService = inject(SomeService);
}

with test like

import {
  createServiceFactory,
  SpectatorService,
  SpectatorServiceFactory,
} from '@ngneat/spectator/vitest';
import { Mock } from 'vitest';

import { OtherService } from './other.service';
import { SomeService } from './some.service';

describe('Other service', () => {
  let spectator: SpectatorService<OtherService>;

  const createService: SpectatorServiceFactory<OtherService> =
    createServiceFactory({
      service: OtherService,
      providers: [SomeService],
    });

  beforeEach(() => {
    spectator = createService();
  });

  describe('printSomething', () => {
    beforeEach(() => {
      vi.clearAllMocks();
    });

    describe('on print something', () => {
      const consoleSpy = vi.spyOn(console, 'log');

      it('should print result', () => {
        spectator.service.printSomething();
        expect(consoleSpy).toHaveBeenCalled();
      });
    });
  });
});

And then test succeeds, the order of inject is fine. We also tried without spectator and Tesbed it's also ok.

Here is our vitest config:

import { defineConfig } from 'vitest/config';
import path from 'path';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';

export default defineConfig({
  root: __dirname,
  cacheDir: '../../node_modules/.vite/apps/something',
  plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
  test: {
    watch: false,
    globals: true,
    environment: 'happy-dom', // Also tried jsdom with same results
    include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
    setupFiles: [
      'src/test-setup.ts',
    ],
    reporters: ['default'],
    coverage: {
      reportsDirectory: '../../coverage/apps/something',
      provider: 'v8',
    },
    server: {
      deps: {
        inline: ['@ngneat/spectator'],
      },
    },
  },
});

Our test-setup:

import '@analogjs/vitest-angular/setup-zone';

import { getTestBed } from '@angular/core/testing';
import {
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting,
} from '@angular/platform-browser-dynamic/testing';

getTestBed().initTestEnvironment(
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting(),
);

I would be willing to submit a PR to fix this issue

  • Yes
  • No
@blackholegalaxy blackholegalaxy changed the title provideMockActions doesn't work as intended under vitest environment and cannot produce actions$ if inject is happening after effect method provideMockActions doesn't work as intended under vitest environment and cannot produce actions$ if inject is declared after effect method Feb 19, 2025
@blackholegalaxy blackholegalaxy changed the title provideMockActions doesn't work as intended under vitest environment and cannot produce actions$ if inject is declared after effect method provideMockActions doesn't work as intended under vitest environment and cannot produce Actions if inject is declared after effect method Feb 19, 2025
@markostanimirovic
Copy link
Member

Hi @blackholegalaxy,

Can you provide a reproduction via the StackBlitz playground or GitHub repo?

@blackholegalaxy
Copy link
Author

blackholegalaxy commented Feb 21, 2025

Sure: https://github.com/blackholegalaxy/repro-ngrx-vitest/tree/main/apps/some/src/app/store/effects

Some effect: working. Proper public / private order. Two methods: spectator and Testbed.
Other effect: private at the end => tests failing. Also two methods.

To debunk the fact it could be all services in vitest behaving this way: see in dirservice-for-test-purpose

yarn
nx run some:test

In other.effect.ts you can try switch the private actions$ before public -> tests will go green again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants