Reputation: 448
I have a private function that is called from my constructor and uses toObservable on a computed Signal. It works great in practice, but trying to write unit tests has been a PITA. I'm sure it's just something simple on my part, but I can't get all the injection contexts to line up, or get jasmine to create a proper spy.
AuthService:
// check token state action
private expiredStatus$ = toObservable(this.permissionStore.expiredStatus)
constructor() {
console.log("Constructor started....");
this.expiredStatus$
.pipe(
tap(() => {
return console.log("Subscription Active");
})
)
.subscribe({
next: (tokenStatus) => {
console.log("Token refreshed successfully, new status:", tokenStatus);
},
error: (error) => {
console.error("Failed to refresh token:", error);
},
});
}
PermissionStoreService:
// true if we have an Expired state on the token
expiredStatus: Signal<boolean> = computed(() => this.permissionsState().token_status === TokenStatus.Expired);
I am trying to test expiredStatus$
by mocking this.permissionStore.expiredStatus
, and turn it from false to true, after the AuthService has been initialized.
Since expiredStatus$ is subscribed to in the AuthService constructor, I am running into issues where my mocks are in a different injection context or something weird. jasmine.createSpy / spyOn / spyObj also don't recognize the
If I force an acutal computed signal value, I can get the constructor to run.
TestBed.configureTestingModule({
providers: [
{ provide: HttpClient, useValue: httpClientMock }, // Used for jwtHttp
{ provide: HttpBackend, useValue: httpBackendMock }, // Used for cleanHttp
{
provide: PermissionsStoreService,
useValue: {
// create a real computed signal parameter to use with changing
// effect() subscriptions
expiredStatus: computed(() => signalMock()),
},
});
If I try to force the correct types for a Mock, I can get the test to run, but the value won't report as changed via Testbed.flushEffects() which is new in Angular 17.2.0.
expiredStatusMockSignal = signal(true);
expiredStatusMockComputed = computed(() => expiredStatusMockSignal());
// Define the type for a Signal that emits boolean values
type BooleanSignal = Signal<boolean>;
// Assuming permissionsStoreServiceMock is a mock object and signalMock is a mock function
const permissionsStoreServiceMock = {
expiredStatus: jasmine.createSpy("expiredStatus") as jasmine.Spy<() => BooleanSignal>,
};
// Create a spy on the expiredStatus method and provide a fake implementation
permissionsStoreServiceMock.expiredStatus.and.callFake(() => {
// If signalMock is a function that returns a value, you can return that value here
return expiredStatusMockComputed as BooleanSignal;
});
I have tried a dozen version of this test, and I think I need to address the injection context, when the constructor for AuthService is initially run, but I don't know how.
it("should react to token expiration", () => {
// Replace with your specific assertion logic
const tokenRefreshCalled = spyOn(authService, "refreshToken").and.callFake(() => {
// Do nothing or perform simple actions, depending on your needs
console.debug("Called the faker...");
return of(TokenStatus.Valid); // Or some mock response if needed
});
expiredStatusMockSignal.update(() => false);
// flush the signal change
TestBed.flushEffects();
authService.expiredStatus$.subscribe((next) => console.log("Expired Status: ", next));
console.log("starting shit show....");
expiredStatusMockSignal.update(() => true);
// flush the signal change
TestBed.flushEffects();
console.log("Status: ", expiredStatusMockSignal());
// Assert that the component reacted as expected
expect(tokenRefreshCalled).toHaveBeenCalled();
});
Upvotes: 1
Views: 2219
Reputation: 129
Ok, in your case I would try to test the effects rather than the internal implementation. In Testbed's own injection context, you have access to all the variables involved in the test module.
As long as you can control the values being emitted by the service, you can run checks on the initial values and the other one you decide to emit.
your flow looks to be as follows:
service => emits (reactive) value => gets caught by the entity => gets transformed into observable => gets consumed in some way, because I assume console log is not your end goal
your test case could be:
f(x)=> y
where x is the value in the service and f is any transformation that could occur in the context of the consumer pipeline.f(x1)=>y1
,In my opinion, in this case you don't need to check that toObservable
was called or anything like that, because what you really need to ensure is that the public interface of your service behaves as designed.
There are different philosophies about unit testing, some people think that the smallest unit is a class member or even a statement and see what I just suggested as "integration". Other people focus on the public interfaces of an entity (a.k.a. class?? ) and see those as the unit.
I personally try to use a hybrid approach, where the main goal is testing all the possible branches with simple, immutable test cases, and for certain edge cases like the one you're mentioning, I find easier to test the effects on the public interface, plus the fact that trying to test implementation (on private interfaces) will make your unit test brittle, and you will end modifying it on almost every single change, which might not be ideal.
I will not provide a full implementation because I do not know what kind of entity your consumer is, and the specific implementation varies from services to components, to guards... but this could be a rough approach.
import { provideTestingAuthService, AuthServiceMock } from 'auth/testing';
let service: MyConsumerService;
let authServiceMock: AuthServiceMock;
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [
...
provideTestingAuthService('class'),
],
});
service = TestBed.inject(MyConsumerService);
authServiceMock = TestBed.inject(AuthService) as unknown as AuthServiceMock ; // casting in case you want to use entities present only in the mock.
});
// ...
describe('public interface', ()=>{
it('should be default value if condition a meets',()=>{
expect(service.publicInterface).toEqual(defaultValue)
});
// one per every branch in your logic
it('should be x value if condition b meets',fakeAsync(()=>{
authServiceMock.desiredProperty.set(newValue);
tick();
expect(service.publicInterface).toEqual(transformedValue)
});
});
You can mock anything you want, from the auth service to your permissionStore
, so if the actual direct source of the values is the store and not the auth service, go and create a mock for the store that does not depend on anyone, and emit values directly from that source.
The pattern will not change in any case, and it is recommended that in unit testing we inspect only direct interactions, and mock all dependencies to avoid making inter-dep calls to get the result that our subject needs.
As a side note, I usually create mock classes that matches the interface of the dependency that I plan to inject, which could help you to make the module declaration readable and reuse that in your tests. the provideTestingThisOrThat
is a pattern followed by angular to provide dependencies and I would personally advice people to use that same pattern to provide their own entities, because new developers coming into the project will be familiar with what it means, and it increases readability of our code, one example for the provider in this case could be:
export class AuthServiceMock{...}
export const provideTestingAuthService = (...initialValue: ConstructorParameters<typeof AuthServiceMock>): Provider =>({
provide: AuthService,
useValue: new AuthServiceMock(...initialValue)
})
I apologize for not providing a more accurate code example, but I lack context of the entities and don't want to speculate
Two important side notes.
I'm not sure of the type of entity your consumer is, but I see you have an unsubscribed subscription in your consumer code, and that could lead to a memory leak, and if it is a component, I would advise moving any logic outside the constructor, given the way angular components are instantiated, because the more logic you put inside a constructor, the heavier you make instantiating that component. If that is the case for you, it could pay off take care of those details
Best of luck, hope this answer helps.
Upvotes: 0