Skip to content

HttpTestingController does not resolve async/await #25965

Open

Description

@buehler

I'm submitting a...


[ ] Regression (a behavior that used to work and stopped working in a new release)
[x] Bug report  
[ ] Performance issue
[ ] Feature request
[ ] Documentation issue or request
[ ] Support request => Please do not submit support request here, instead see https://github.com/angular/angular/blob/master/CONTRIBUTING.md#question
[ ] Other... Please describe:

Current behavior

When using async await with promises (and therefore: this.http.get(...).toPromise()), one can unittest everything like normal with the HttpTestingController. BUT if you have a service method that has multiple await in it, the testing controller is not aware of the later executed await and will just wait and fail.

Expected behavior

All await calls inside a method should be executed during async testing.

Minimal reproduction of the problem with instructions

  1. use a service with async await function like:
@Injectable()
class Service {
  constructor(private http: HttpClient){}
  public async foobar(): Promise<number> {
    await Promise.resolve();
    await Promise.resolve();
    await this.http.get('http://asdf').toPromise();
    return 1;
  }
}
  1. try to use the HttpTestingClient to catch the http://asdf request.

Environment


Angular version: 6.1.0


Browser:
- [x ] Chrome (desktop) version XX
- [ ] Chrome (Android) version XX
- [ ] Chrome (iOS) version XX
- [ ] Firefox version XX
- [ ] Safari (desktop) version XX
- [ ] Safari (iOS) version XX
- [ ] IE version XX
- [ ] Edge version XX
 

Activity

  1. added
    area: commonIssues related to APIs in the @angular/common package
    on Sep 14, 2018
  2. added this to the needsTriage milestone on Sep 14, 2018
  3. leandrogehlen commented on Oct 9, 2018

    @leandrogehlen

    My solution for this issue it was to use setTimeout:

    it('Test passed', async () => {
      setTimeout(() => {
        const req = http.expectOne('http://asdf');
        req.flush( {
          success: true,
          content: "response content"
        });
    
        http.verify();
      });
      await service.foobar();
    });
  4. dornfeder commented on Oct 11, 2018

    @dornfeder

    +1

    My workaround was to mock the httpClient manually and not inject the service, but create a new one with the mocked httpClient as parameter, but there surely should be a better way of doing this...

    import { of } from 'rxjs';
    
    import { Service } from './myservice';
    
    const mockResponse = {
      value: 'test'
    };
    
    describe('Service', () => {
    
      let service: Service;
    
      const http = {
        get: () => of(mockResponse)
      };
    
      beforeEach(async () => {
        titleService = new TitleService(http as any);
      });
    
      it('should get data from the server', async () => {
        const result = await titleService.getData();
        expect(result).toEqual(mockTitle.value);
      });
    
    });
  5. joshrizzo commented on Dec 14, 2018

    @joshrizzo

    It works, you just have to make the call and then expect the call, and then await it:

        it('should return true and set the token', async () => {
          // Arrange
          const call = service.login('Test2', 'Password2', 'Local');
          const testToken = 'testToken';
          const loginRequest = httpMock.expectOne(environment.apiUrl + 'auth/login');
          loginRequest.flush(testToken);
    
          // Act
          const result = await call;
    
          // Assert
          httpMock.verify();
          expect(result).toBeTruthy();
          expect(service.authToken).toBe(testToken);
        });
    
  6. modified the milestones: needsTriage, Backlog on Apr 25, 2019
  7. mcgoohan commented on Nov 6, 2019

    @mcgoohan

    It works, you just have to make the call and then expect the call, and then await it:

        it('should return true and set the token', async () => {
          // Arrange
          const call = service.login('Test2', 'Password2', 'Local');
          const testToken = 'testToken';
          const loginRequest = httpMock.expectOne(environment.apiUrl + 'auth/login');
          loginRequest.flush(testToken);
    
          // Act
          const result = await call;
    
          // Assert
          httpMock.verify();
          expect(result).toBeTruthy();
          expect(service.authToken).toBe(testToken);
        });
    

    This worked for me. The only thing I needed to add was converting it to the promise.

    const call = service.login('Test2', 'Password2', 'Local').toPromise();

  8. picolino commented on Nov 12, 2019

    @picolino

    Acceptable solution, but I would like to have a simplier solution, like this:

    httpMock.addResponse(environment.apiUrl + 'auth/login', 'testToken');
    
    const result = await service.login('Login', 'Password', 'Local');
    
    expect(service.authToken).toBe(testToken);

    The point is to define answers ahead the requests would be sent.
    I think that functionality must be out-of-the-box.

  9. picolino commented on Feb 19, 2020

    @picolino

    Seems like I found an issue that can't be fixed outside.

    Let's see on this code:

    async ngOnInit() {
      const test1 = await this.someService1.makeSomething1Async();
      const test2 = await this.someService2.makeSomething2Async();
    }

    Both of services calling http methods inside.

    Now, in the tests we have something like:

    // 1
    const call =  component.ngOnInit;
    // 2
    httpMock.expectOne('someService1Url').flush('');
    // 3
    httpMock.expectOne('someService2Url').flush('');
    // 4
    await call;

    So we have behaviour like:

    1. We calling ngOnInit async method, it going to await this.someService1.makeSomething1Async(); instruction and waiting for httpMock to flush.
    2. We flushing response so thread moving on the next instruction: await this.someService2.makeSomething2Async();.
    3. But because we cannot await call from 1. instruction httpMock.expectOne('someService2Url').flush('') will be executed before awaiting someService2.makeSomething2Async();. So request does not exists and we already trying to flush it's response.
    4. Thread waiting for someService2 request but error was throwed before on 3.

    This behaviour can't be fixed anyway without httpMock responses collection like I mentioned above without breaking incapsulation or any a-lot-of-code-hacks.

  10. thibaud-sanchez commented on Apr 23, 2020

    @thibaud-sanchez

    It works, you just have to make the call and then expect the call, and then await it:

        it('should return true and set the token', async () => {
          // Arrange
          const call = service.login('Test2', 'Password2', 'Local');
          const testToken = 'testToken';
          const loginRequest = httpMock.expectOne(environment.apiUrl + 'auth/login');
          loginRequest.flush(testToken);
    
          // Act
          const result = await call;
    
          // Assert
          httpMock.verify();
          expect(result).toBeTruthy();
          expect(service.authToken).toBe(testToken);
        });
    

    This solution work if you don't have inner await in the method you are testing.
    In my case, If I use your pattern all is working, but If there is another await inside the "service.login('Test2', 'Password2', 'Local')" call to retrieve information from storage for example, then the expectOne fail because http cal was not done yet..

    The solution I found, is to add a delay/sleep to make the login able to execute all await before I make the expectOne, like this :

        it('should return true and set the token', async () => {
          // Arrange
          const call = service.login('Test2', 'Password2', 'Local');
          const testToken = 'testToken';
    
           await delay(1000)
    
          const loginRequest = httpMock.expectOne(environment.apiUrl + 'auth/login');
          loginRequest.flush(testToken);
    
          // Act
          const result = await call;
    
          // Assert
          httpMock.verify();
          expect(result).toBeTruthy();
          expect(service.authToken).toBe(testToken);
        });
    
      static async delay(ms: number) {
        return new Promise((resolve) => setTimeout(resolve, ms))
      }
    
  11. denis111 commented on Jun 5, 2020

    @denis111

    @thibaud-sanchez
    Good workaround! But it's not definitive solution. What if we have hundreds of tests like this and so making hundreds delays will make tests execution time more then 100 seconds. Here I tried to play with fakeAsync which works for simple example but didn't work for me for real world code (besides, what if we have variable number of promises before the real http call?) #19974 (comment)
    I'd suggest to make possible for httpMock to wait for actual http call with some timeout maybe or something like that

  12. thibaud-sanchez commented on Jun 5, 2020

    @thibaud-sanchez

    @thibaud-sanchez
    Good workaround! But it's not definitive solution. What if we have hundreds of tests like this and so making hundreds delays will make tests execution time more then 100 seconds. Here I tried to play with fakeAsync which works for simple example but didn't work for me for real world code (besides, what if we have variable number of promises before the real http call?) #19974 (comment)
    I'd suggest to make possible for httpMock to wait for actual http call with some timeout maybe or something like that

    Totally agree with you !

  13. kashban commented on Aug 24, 2020

    @kashban

    This is a kinda old issue, any news on solving this without resorting to workarounds?

  14. added
    P3An issue that is relevant to core functions, but does not impede progress. Important, but not urgent
    and removed on Oct 1, 2020
  15. cameronhudson8 commented on Jan 1, 2021

    @cameronhudson8

    I took a look at the source code of the HttpClientTestingBackend class that implements the HttpTestingController interface.

    Any requests that arrive at the testing backend are placed into a private array open: TestRequest[]. When you call the public expectOne function, the requests are removed from this array if they are present, or else an error is thrown. The removal is done by the match function, which is also public.

    So in order to block further test execution until the request has arrived at the HttpClientTestingBackend, I wanted to wait until there was a request pending in the open array. But I could not use match to check the contents of the open array, because upon a successful match, the request is removed from the open array, and expectOne will subsequently fail. So instead, I had to open up the private array and poll it until it was no longer empty. (I cast mockHttp: HttpTestingController to any rather than HttpClientTestingBackend because HttpClientTestingBackend does not appear to be importable).

    This approach works is comprehensible to those who will follow, but I'm not happy with it. The proper solution would be to implement expectOne and verify as @picolino suggested. IMO expectOne and flush should be merged into the same function, so that you can expect a request to be made and provide the response that should be sent. verify could then be used to confirm that all responses were sent.

    But at least it's now clear to my why the existing package is broken.

    it('Test passed', async () => {
    
      // Send the request
      const pendingRequest = service.foobar();
      
      // Wait for the request to arrive
      const INTERVAL = 100; // ms
      while ((httpMock as any).open.length === 0) {
        await new Promise((resolve) => setTimeout(resolve, INTERVAL));
      }
      
      // Receive and confirm the request 
      const req = http.expectOne('http://asdf');
    
      // Send the response
      req.flush( {
        success: true,
        content: "response content"
      });
      
      // Wait for the response and perform assertions on it
      const response = await pendingRequest;
      expect(response.body).toEqual('hello');
      
      // Call verify to confirm that the `open` array is now empty.
      http.verify();
    
    });
  16. RobinMeow commented on Sep 20, 2024

    @RobinMeow

    I have the same issue. I really like the promise based api, so I convert most of my http requests into async functions using firstValueFrom() operator.

    async loginAsync(signIn: SignInRequest): Promise<void> {
        const url = this.AUTH_URL + 'login'
    
        const response = await firstValueFrom(
          this.httpClient.post<AccessTokenResponse>(url, signIn, {
            headers: this.defaultHeaders,
          }),
        )
        const roles = await firstValueFrom(
          this.httpClient.get<Roles>(this.AUTH_URL + 'roles', {
            headers: this.defaultHeaders,
          }),
        )
    }

    A workaround would be:

    async loginAsync(signIn: SignInRequest): Promise<void> {
        const url = this.AUTH_URL + 'login'
    
        const promise1 = firstValueFrom(
          this.httpClient.post<AccessTokenResponse>(url, signIn, {
            headers: this.defaultHeaders,
          }),
        )
        const promise2 = firstValueFrom(
          this.httpClient.get<Roles>(this.AUTH_URL + 'roles', {
            headers: this.defaultHeaders,
          }),
        )
        await promise1
        await promise2
    }

    I dont really like it tho. I most languages you can call async stuff as "fire and forget" and expect it to run eventually. (it might even run synchronously!) So there is no guarentee that the latter will not run first.
    In this case it works, because there is no http request sent until it is subscribed to (which will only happen when it is actually awaited)

    In another use case, you might need the response from the first request to even create the second request. Often done using switchMap. Then this workaround would help.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3An issue that is relevant to core functions, but does not impede progress. Important, but not urgentarea: common/httpIssues related to HTTP and HTTP Clientfreq1: lowtype: bug/fix

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      Sponsor
      SponsoredKunjungi sekarang
      Promo