ngrx / platform

Reactive State for Angular
https://ngrx.io
Other
7.96k stars 1.95k forks source link

Add testing guide for NgRx SignalStore #4206

Open markostanimirovic opened 6 months ago

markostanimirovic commented 6 months ago

Information

The new page should be created for the testing guide: @ngrx/signals > SignalStore > Testing

The guide should explain how SignalStore should be tested with examples.

Documentation page

No response

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

va-stefanek commented 6 months ago

Can take care of it.

Any specific best practices you would like to be included?

rainerhahnekamp commented 5 months ago

Can I join in this?

va-stefanek commented 5 months ago

@rainerhahnekamp sure. We need to clarify whether @markostanimirovic has some specific requirements he would love to put on that page

markostanimirovic commented 5 months ago

Hey 👋

Here are the main topics I'd like to be covered:

Optionally:

For more inspiration check how SignalStore Core Concepts and Custom Store Features pages are structured (attention to details, examples, etc.).

rainerhahnekamp commented 5 months ago

Okay, about testing without TestBed: I think the number of stores without DI, will be a minority.

You will very likely have an HttpClient or a service depending on it almost always. I think that kind a test without a TestBed only applies to testing the store itself (so tests which are part of NgRx), or if for stores managing a UI state.

I would include an example without TestBed at the beginning, but I'd see the main focus on test cases with DI.

When it comes to mocking, we should also have a chapter on mocking the Store itself when it is used inside a component.

rxMethod should show how to test it with marbles but also without it.

What do you think?

markostanimirovic commented 5 months ago

Sounds good to me 👍

jordanpowell88 commented 5 months ago

Excited for this documentation to land.

rainerhahnekamp commented 5 months ago

Just one addition: I think we should also include some type testing. I find that important for advanced use cases.

ajlif commented 5 months ago

excited as well for this documentation. Is there any workaround to spy on patchState and injected services in withMethods ? i have already tried to mock followig @markostanimirovic comment here in angular but it doesn't work:

import * as fromUsersStore from './users.store'; // test jasmine.spyOn(fromUsersStore, 'injectUsersStore').mockReturnValue({ /* fake users store */ });

rainerhahnekamp commented 5 months ago

Hi, you want to create a spy on patchState itself? I am afraid that will not work. patchState as standalone is very similar to inject, which we also cannot spy unless the framework itself provides mocking/spy functionalities.

What I would recommend instead, that you don't spy on the packState but check against the value of the state itself.

So something like:

Store:

const CounterStore = signalStore(
  {providedIn: 'root'},
  withState({counter: 1})
);

Service which you want to test:

@Injectable({providedIn: 'root'})
export class Incrementer {
  store = inject(CounterStore);

  increment() {
    patchState(this.store, value => ({counter: value.counter + 1}));
  }
}

The actual test:

it('should verify that increment increments', () => {
  const counterStore = TestBed.inject(CounterStore);
  const incrementer = TestBed.inject(Incrementer);

  expect(counterStore.counter()).toBe(1);

  incrementer.increment();
  TestBed.flushEffects();

  expect(counterStore.counter()).toBe(2);
})

I didn't try out that code. Might include even some syntax errors, but that's the general approach I would recommend.

Is it what you had in mind?

ajlif commented 5 months ago

ok that's clear, what about spy on injected services in withMethods . I want to check that a service is called . in Component Stores we used to use component injection and spy using spyOn:

   const spy = spyOn((store as any).injectedService, 'methodInsideInjectedService');
markostanimirovic commented 5 months ago

ok that's clear, what about spy on injected services in withMethods . I want to check that a service is called . in Component Stores we used to use component injection and spy using spyOn:

   const spy = spyOn((store as any).injectedService, 'methodInsideInjectedService');

In the same way as you do for any other service using TestBed:

TestBed.configureTestingModule({
  providers: [MyStore, { provide: MyService, useValue: { doSomething: jest.fn() } }],
});

const myStore = TestBed.inject(MyStore);
const myService = TestBed.inject(MyService);

myStore.doSomething();

expect(myService.doSomething).toHaveBeenCalled();
jits commented 5 months ago

In the same way as you do for any other service using TestBed:

TestBed.configureTestingModule({
  providers: [MyStore, { provide: MyService, useValue: { doSomething: jest.fn() } }],
});

const myStore = TestBed.inject(MyStore);
const myService = TestBed.inject(MyService);

myStore.doSomething();

expect(myService.doSomething).toHaveBeenCalled();

Has anyone got this to work? Using jasmine.createSpy() I get typing errors due to the Unsubscribable type required both on result type and on the spy itself. UPDATE: I misread this and the solution above still stands for mocking a method on a service used within the Store. My question/issue below is about mocking / spying on an rxMethod method.

I'm trying to implement a helper method to generate an rxMethod spy but stuck on making it work without manual typecasting etc.:

import type { Signal } from '@angular/core';
import type { Observable, Unsubscribable } from 'rxjs';

// This reproduces the same types from @ngrx/signals (which aren't exported)
type RxMethodInput<Input> = Input | Observable<Input> | Signal<Input>;
type RxMethod<Input> = ((input: RxMethodInput<Input>) => Unsubscribable) & Unsubscribable;

export const buildRxMethodSpy = <Input>(name: string) => {
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  const rxMethodFn = (input: RxMethodInput<Input>) => {
    return {
      unsubscribe: () => {
        return;
      },
    };
  };
  rxMethodFn.unsubscribe = () => {
    return;
  };
  const spy = jasmine.createSpy<RxMethod<Input>>(name, rxMethodFn);
  // Somehow add `.unsubscribe` to the spy in a way that TypeScript understands
  return spy;
};

Note: I already tried using jasmine.createSpy() directly but encountered the typing issues; the above code is likely overkill, assuming there's a solution to typing jasmine.createSpy() properly.


Update: for now I'm resorting to explicit typecasting:

import type { Signal } from '@angular/core';
import { noop, type Observable, type Unsubscribable } from 'rxjs';

// This reproduces the same types from @ngrx/signals (which aren't exported)
type RxMethodInput<Input> = Input | Observable<Input> | Signal<Input>;
type RxMethod<Input> = ((input: RxMethodInput<Input>) => Unsubscribable) & Unsubscribable;

export const buildRxMethodSpy = <Input>(name: string) => {
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  const rxMethodFn = (input: RxMethodInput<Input>) => {
    return {
      unsubscribe: noop,
    };
  };
  rxMethodFn.unsubscribe = noop;
  const spy = jasmine.createSpy<RxMethod<Input>>(name, rxMethodFn) as unknown as jasmine.Spy<
    RxMethod<Input>
  > &
    Unsubscribable;
  spy.unsubscribe = noop;
  return spy;
};

But would very much like to hear if there's a better way to do this.

rainerhahnekamp commented 4 months ago

@jits it looks like you want to spy on the Store's method itself, right?

The example you are referring to, is about a service which is used in the Store, and you want to spy on a method on that service.

jits commented 4 months ago

Hi @rainerhahnekamp — ahh yes, good spot, thanks. I missed that aspect. (I'll update my comment to reflect this).

I don't suppose you have a good way to build a mock or spy for an rxMethod method? (Without resorting to manual type casting).

rainerhahnekamp commented 4 months ago

@jits What do you think about that? https://github.com/ngrx/platform/issues/4256

jits commented 4 months ago

@rainerhahnekamp — that sounds great! (I'll continue with additional thoughts there)

AshMcConnell commented 2 weeks ago

Most things just work as normal (when you use TestBed), but is there a trick to get computed variables to fire?

withComputed(({request, patient}) => ({
        // Computed state from other parts of the state
        patientRequestDoctorModel: computed(() =>
            request() ? PatientRequestDoctorModel.fromRequest(request()) : PatientRequestDoctorModel.fromPatient(patient())
        )
    }))

I expected it to work when either request or patient signals changed, but it doesn't fire in the test.

EDIT Nevermind, I'm an idiot, I forgot that computed were lazy. I had just run the other tests that changed request and patient, but I hadn't asserted on patientRequestDoctorModel(), so it didn't run the computed. At least it is a cautionary tale for googlers 😂