Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

share operator causes Jest test to fail

I have an Angular service that makes HTTP requests. The main job of the service is to refresh the access token & retry the request if the request results in a 401. The service is also able to handle multiple concurrent requests with grace: If there are 3 requests that result in a 401, the token will be refreshed only once and all 3 requests will be replayed. The following GIF summarises this behaviour: enter image description here

My problem is that I can't seem to test this behaviour. Initially, my test was always failing with a timeout because my subscribe or error method were not being called. After adding fakeAsync I wasn't getting the timeout anymore, but my observer was still not being called. I also noticed that the subscription from my test is being called if I remove the share operator from the tokenObservable but by doing that I will lose the benefits of multicasting.

Here is the test that doesn't work properly

it('refreshes token when getting a 401 but gives up after 3 tries', fakeAsync(() => {
const errorObs = new Observable(obs => {
  obs.error({ status: 401 });
}).pipe(
  tap(data => {
    console.log('token refreshed');
  })
);
const HttpClientMock = jest.fn<HttpClient>(() => ({
  post: jest.fn().mockImplementation(() => {
    return errorObs;
  })
}));
const httpClient = new HttpClientMock();

const tokenObs = new Observable(obs => {
  obs.next({ someProperty: 'someValue' });
  obs.complete();
});

const AuthenticationServiceMock = jest.fn<AuthenticationService>(() => ({
  refresh: jest.fn().mockImplementation(() => {
    return tokenObs;
  })
}));
const authenticationService = new AuthenticationServiceMock();

const service = createSut(authenticationService, httpClient);

service.post('controller', {}).subscribe(
  data => {
    expect(true).toBeFalsy();
  },
  (error: any) => {
    expect(error).toBe('random string that is expected to fail the test, but it does not');
    expect(authenticationService.refresh).toHaveBeenCalledTimes(3);
  }
);
}));

This is how I am injecting mocks in my SUT:

  const createSut = (
    authenticationServiceMock: AuthenticationService,
    httpClientMock: HttpClient
  ): RefreshableHttpService => {
    const config = {
      endpoint: 'http://localhost:64104',
      login: 'token'
    };
    const authConfig = new AuthConfig();

    TestBed.configureTestingModule({
      providers: [
        {
          provide: HTTP_CONFIG,
          useValue: config
        },
        {
          provide: AUTH_CONFIG,
          useValue: authConfig
        },
        {
          provide: STATIC_HEADERS,
          useValue: new DefaultStaticHeaderService()
        },
        {
          provide: AuthenticationService,
          useValue: authenticationServiceMock
        },
        {
          provide: HttpClient,
          useValue: httpClientMock
        },
        RefreshableHttpService
      ]
    });

    try {
      const testbed = getTestBed();
      return testbed.get(RefreshableHttpService);
    } catch (e) {
      console.error(e);
    }
  };

Here is the relevant code for the system under test:

    @Injectable()
export class RefreshableHttpService extends HttpService {
  private tokenObservable = defer(() => this.authenthicationService.refresh()).pipe(share());
  constructor(
    http: HttpClient,
    private authenthicationService: AuthenticationService,
    injector: Injector
  ) {
    super(http, injector);
  }
  public post<T extends Response | boolean | string | Array<T> | Object>(
    url: string,
    body: any,
    options?: {
      type?: { new (): Response };
      overrideEndpoint?: string;
      headers?: { [header: string]: string | string[] };
      params?: HttpParams | { [param: string]: string | string[] };
    }
  ): Observable<T> {
    return defer<T>(() => {
      return super.post<T>(url, body, options);
    }).pipe(
      retryWhen((error: Observable<any>) => {
        return this.refresh(error);
      })
    );
  }

  private refresh(obs: Observable<ErrorResponse>): Observable<any> {
    return obs.pipe(
      mergeMap((x: ErrorResponse) => {
        if (x.status === 401) {
          return of(x);
        }
        return throwError(x);
      }),
      mergeScan((acc, value) => {
        const cur = acc + 1;
        if (cur === 4) {
          return throwError(value);
        }
        return of(cur);
      }, 0),
      mergeMap(c => {
        if (c === 4) {
          return throwError('Retried too many times');
        }

        return this.tokenObservable;
      })
    );
  }
}

And the class that it inherits from:

 @Injectable()
export class HttpService {
  protected httpConfig: HttpConfig;
  private staticHeaderService: StaticHeaderService;
  constructor(protected http: HttpClient, private injector: Injector) {
    this.httpConfig = this.injector.get(HTTP_CONFIG);
    this.staticHeaderService = this.injector.get(STATIC_HEADERS);
  }

For some unknown reason it's not resolving the observable returned by the refresh method the second time it is called. Oddly enough it works if you remove the share operator from the tokenObservable property from the SUT. It might have to do something with timing. Unlike Jasmine, Jest does not not mock Date.now which RxJs make use of. A possible way to go is to try to use the VirtualTimeScheduler from RxJs to mock time, although that's what fakeAsync is supposed to do.

Dependencies and versions:

  1. Angular 6.1.0
  2. Rxjs 6.3.3
  3. Jest 23.6.0
  4. Node 10.0.0
  5. Npm 6.0.1

The following article helped me implement the functionality: RxJS: Understanding the publish and share Operators

like image 475
Radu Cojocari Avatar asked Nov 08 '22 01:11

Radu Cojocari


1 Answers

I have looked into this and seems I have some ideas why it is not working for you:

1) Angular HttpClient service throws an error in async code but you did it synchronously. As a result it breaks share operator. If you can debug you can see the problem by looking at ConnectableObservable.ts

enter image description here

In your test connection will be still open while the connection in HttpClient async code unsubscribes and is closed so the next time new connection will be created.

To fix it you can also fire 401 error in async code:

const errorObs = new Observable(obs => {
   setTimeout(() => {
     obs.error({ status: 404 });
   });
...

but you have to wait while all async code has been executed by using tick:

service.post('controller', {}).subscribe(
  data => {
    expect(true).toBeFalsy();
  },
  (error: any) => {
    expect(error).toBe('Retried too many times');
    expect(authenticationService.refresh).toHaveBeenCalledTimes(3);
  }
);

tick(); // <=== add this

2) And you should remove the following expression in your RefreshableHttpService:

mergeScan((acc, value) => {
    const cur = acc + 1;
    if (cur === 4) { <== this one
      return throwError(value);
    }

since we don't want to throw error with value context.

After that you should catch all refresh calls.

I also created sample project https://github.com/alexzuza/angular-cli-jest

Just try npm i and npm t.

  Share operator causes Jest test to fail
    √ refreshes token when getting a 401 but gives up after 3 tries (41ms)

  console.log src/app/sub/service.spec.ts:34
    refreshing...

  console.log src/app/sub/service.spec.ts:34
    refreshing...

  console.log src/app/sub/service.spec.ts:34
    refreshing...

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        4.531s, estimated 5s
Ran all test suites.

You can also debug it through npm run debug

like image 177
yurzui Avatar answered Nov 14 '22 23:11

yurzui