Sergio Mazzoleni
Sergio Mazzoleni

Reputation: 1608

How to force `onDestroy` hook invocation from a ngrx signal store in unit test?

I'm using a SignalStore from @ngrx/signals in an Angular project.

My store has onInit and onDestroy hooks invoking methods from a dependency injected in the store.

import { signalStore, withHooks } from '@ngrx/signals';

class SomeDependency {
    start() {
        throw new Error('real implementation not relevant');
    }
    stop() {
        throw new Error('real implementation not relevant');
    }
}

const MyStore = signalStore(
    withHooks((_store, dependency = inject(SomeDependency)) => ({
        onInit() {
            dependency.start();
        },
        onDestroy() {
            dependency.stop();
        },
    })),
);

I'm able to test that dependency.start() is properly invoked when creating the store

it('should start dependency when created', () => {
    const fakeDependency = { start: jest.fn(), stop: jest.fn() };
    TestBed.configureTestingModule({
        providers: [MyStore, { provide: SomeDependency, useValue: fakeDependency }],
    });

    TestBed.inject(MyStore);

    expect(fakeDependency.start).toHaveBeenCalledOnce(); // test pass ✅
});

I am looking for a way to test that dependency.stop() is invoked when the store is somehow destroyed, but how can I force the store destruction?

it('should stop dependency when destroyed', () => {
    const fakeDependency = { start: jest.fn(), stop: jest.fn() };
    TestBed.configureTestingModule({
        providers: [MyStore, { provide: SomeDependency, useValue: fakeDependency }],
    });
    const store = TestBed.inject(MyStore);

    // MISSING: force store descruction here

    expect(fakeDependency.stop).toHaveBeenCalledOnce(); // test fail ❌
});

Upvotes: 2

Views: 209

Answers (2)

Sergio Mazzoleni
Sergio Mazzoleni

Reputation: 1608

Edit 2024-12-29

I came up with a simpler solution. No need to create a host component to force onDestroy hook invokation on the store. All you need is to call TestBed.resetTestingModule()

class SomeDependency {
  stop() {
    throw new Error('real implementation not relevent');
  }
}

const MyStore = signalStore(
  withHooks((_store, dependency = inject(SomeDependency)) => ({
    onDestroy() {
      dependency.stop();
    },
  })),
);

describe('Better way', () => {
  it('should stop dependency when destroyed', () => {
    const fakeDependency = { stop: jest.fn() };
    TestBed.configureTestingModule({
      providers: [
        MyStore,
        { provide: SomeDependency, useValue: fakeDependency },
      ],
    });
    TestBed.inject(MyStore); // needed for the store to be created

    TestBed.resetTestingModule(); // 💡 force destruction of all previously created providers

    expect(fakeDependency.stop).toHaveBeenCalledOnce(); // test now passes ✅
  });
});

initial answer

As proposed by Naren, creating a testing host solution does the trick.

class SomeDependency {
    stop() {
        throw new Error('real implementation not relevent');
    }
}

const MyStore = signalStore(
    withHooks((_store, dependency = inject(SomeDependency)) => ({
        onDestroy() {
            dependency.stop();
        },
    })),
);

describe('standard way, using TestBed', () => {
    @Component({
        standalone: true,
        providers: [MyStore],
    })
    class HostComponent {
        store = inject(MyStore);
    }

    it('should stop dependency when destroyed', () => {
        const spy = jest.fn();
        const fixture = TestBed.configureTestingModule({
            imports: [HostComponent],
            providers: [{ provide: SomeDependency, useValue: { stop: spy } }],
        }).createComponent(HostComponent);

        fixture.destroy();

        expect(spy).toHaveBeenCalledOnce();
    });
});

Also, using ng-mocks library can reduce boiler plate and also use a mocked HostComponent. That would be my preferred solution.

describe('alternative with ng-mocks', () => {
    @Component({
        standalone: true,
        providers: [MyStore],
    })
    class HostComponent {
        store = inject(MyStore);
    }

    MockInstance.scope();
    beforeEach(() => MockBuilder(MyStore).mock(SomeDependency));

    it('should stop dependency when destroyed', async () => {
        const spy = MockInstance(SomeDependency, 'stop', jest.fn());
        const fixture = MockRender(HostComponent);

        fixture.destroy();

        expect(spy).toHaveBeenCalledOnce();
    });
});

Upvotes: 1

Naren Murali
Naren Murali

Reputation: 57696

The store cannot be destroyed unless the host that holds it is destroyed.

So for testing this scenario, we create a component called TestingComponent, it's only duty is to have MyStore in the providers array, by doing so, we can destroy the component using fixture.destroy(), since the store is a part of the providers array of this component, the store onDestroy hook get's called and we are able to test it.

import { Component, inject } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';

import { signalStore, withHooks } from '@ngrx/signals';

export class SomeDependency {
  start() {
    throw new Error('real implementation not relevant');
  }
  stop() {
    throw new Error('real implementation not relevant');
  }
}

export const MyStore = signalStore(
  withHooks((_store, dependency = inject(SomeDependency)) => ({
    onInit() {
      dependency.start();
    },
    onDestroy() {
      dependency.stop();
    },
  }))
);

@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <h1>Hello from {{ name }}!</h1>
    <a target="_blank" href="https://angular.dev/overview">
      Learn more about Angular
    </a>
  `,
})
export class App {
  name = 'Angular';
}

bootstrapApplication(App);

Stackblitz Demo

Upvotes: 1

Related Questions