4

I have an angular component which contains the following parts:

my.component.html (excerpt)

<button pButton
    class="download ui-button ui-button-secondary"
    (click)="exportFile(logEvent)"
    icon="fas fa-file-download">
</button>

my.component.ts (excerpt)

import {saveAs} from 'file-saver';

exportFile(logEvent: LogEvent) {
    saveAs(new Blob([logEvent.details]), 'log-details.txt');
}

This works perfectly in my application. I now wanted to test this in my unit tests. Looking for a way to make sure saveAs() has been called, I stumbled across two stackoverflow articles: mocking - Testing FileSaver in Angular 5 and Do you need spies to test if a function has been called in Jasmine?. Based on that I wrote the following test:

my.component.spec.ts (excerpt)

import * as FileSaver from 'file-saver';

beforeEach(() => {
    spyOn(FileSaver, 'saveAs').and.stub();
});

it('should download a file if the download button is clicked', (fakeAsync() => {
  // fakeAsync because in my real test, there are httpClient test aspects as well
  advance(fixture);

  expect(page.downloadButton).toBeDefined();
  click(page.downloadButton);
  advance(fixture);

  expect(FileSaver.saveAs).toHaveBeenCalled();
}));

The two helper methods come from the Angular Testing Example:

export function advance(f: ComponentFixture<any>): void {
  tick();
  f.detectChanges();
}

export const ButtonClickEvents = {
  left: {button: 0},
  right: {button: 2}
};

export function click(el: DebugElement | HTMLElement, eventObj: any = ButtonClickEvents.left): void {
  if (el instanceof HTMLElement) {
    el.click();
  } else {
    el.triggerEventHandler('click', eventObj);
  }
}

My problem is, that test fails with the following output:

Error: Expected spy saveAs to have been called.

Error: 1 timer(s) still in the queue.

So it seems that neither the stubbing nor the assertion seem to work.

If I remove the click() call from my testm the 1 timer(s) still in the queue error no longer shows, so I gather that the click() method works and triggers the real saveAs() call - which I would have like to replace with the spy/mock.

How can I fix this?


Update, taking into account the proposed solutions:

I. Changing the import in my.component.spec.ts as suggested by SiddarthPal to:

import * as FileSaver from 'file-saver';

does not make any difference. The test still results in the two assertion errors.

II. Changing the spy setup as suggested by uminder to:

spyOn(FileSaver, 'saveAs').and.callFake(() => null);

does not make a difference either. The test still results in the two failed assertions.

I have tried writing the spy mock as follows:

spyOn(FileSaver, 'saveAs').and.callFake(() => {
    console.log('--- faking saveAs ---');
    return null;
});

Checking the output of the Karma Server output, I don't see this anywhere, so it looks like the spy does not catch my component's call to saveAs() at all.

III. The suggestion by uminder to replace:

click(page.downloadButton);
advance(fixture);

with

click(page.downloadButton);
flush();

does consume the pending timer error. Howver, that onyl hides the fact that the real saveAs() call is used, not the spy/mock. So I am still looking for ways to get that working.

Community
  • 1
  • 1
Urs Beeli
  • 746
  • 1
  • 13
  • 30
  • In my.component.ts can you please import as ```import * as FileSaver from 'file-saver';``` – Siddharth Pal Dec 11 '19 at 08:51
  • I was wondering why you're using a `fakeAsync` here. You need to use it when you're going work with asynchronous calls. Here it's not needed. – Sam Dec 11 '19 at 09:16
  • @SiddharthPal: why? I've tried it and it makes no difference, the test still fails, so there does not seem to be any benefit. However, IntelliJ now claims the import is unused and removes it every time I reorganise the imports. So, as far as I can see, that change has only a drawback but no benefit. Am I missing something? – Urs Beeli Dec 11 '19 at 12:04
  • @Sam: The code above is a stripped down version of my test (the real one includes `httpTestingController.expectOne()` setup, hence the fakeAsync) and I forgot to strip that out when copying it across. I've now removed it from the question. – Urs Beeli Dec 11 '19 at 12:06
  • You have `tick()` in your `advance` function but your test isn't running in the `fakeAsync` zone. Generally this doesn't work. – uminder Dec 12 '19 at 08:11
  • @uminder: see my comment Sam: in my real test I have async parts as well, so it runs inside fakeAsync, hence the tick() makes sense. I'll modify my question to take this into account – Urs Beeli Dec 12 '19 at 13:11
  • @Urs Beeli: When using `fakeAsync`, you should get rid of `Error: 1 timer(s) still in the queue.` replacing `tick()` by `flush()`. – uminder Dec 12 '19 at 13:17
  • @uminder: great, that works, thank you. However, I think that is just hiding my problem, namely that instead of using the spy/mock version of `saveAs`, my component still calls the real `saveAs` which triggers the timer (which I am now consuming with `flush()`). What I am looking for is a way to have the fake/mock being used, so I don't need the flush and so that I can assert that the mock was effectively called. – Urs Beeli Dec 12 '19 at 14:54
  • @Urs Beeli: `expect(FileSaver.saveAs).toHaveBeenCalled();` asserts that the `spy` has been called. If `FileSaver.saveAs` was not a `spy`, Jasmin would complain. – uminder Dec 12 '19 at 14:57
  • @uminder: yes, I am sure that the `FileSave.saveAs` used in the `expect()` statement is a spy. The issue is that the line `saveAs(new Blob([logEvent.details]), 'log-details.txt');` in `my.component.ts` seems to go to the real thing despite my test setup. I am looking for a solution how to make sure that the `saveAs()` call in my component is re-routed to the mock while testing the component. – Urs Beeli Dec 12 '19 at 15:18
  • for me @SiddharthPal's suggestion works – aj go Dec 19 '20 at 08:26

1 Answers1

1

Alright so I'm struggling with the exact same problem right now. My spy is also not working and it calls the real FileSaver methods. The suggestions here did also not solve the problem for me.

I believe the problem is, that the FileSaver is not part of a proper Angular Module, its just imported from FileSaver.js. I had a very similar problem with mocking, described in this thread. Tl;dr: I solved that problem by switching from a import usage of my Funnel to declaring a FunnelProvider and injecting that into the constructor of my real method like:

constructor(private funnelProvider: FunnelProvider) {}

In the spec file I was then able to mock that provider.

So I came up with these 2 Solutions:

1. Using angular-file-saver

I thought, ok maybe there is a FileSaver.js wrapper library that provides an Angular Service for me, and there is. However the dependencies are outdated...that could be a problem. But switching from FileSaver.js to angular-file-saver should be a simple change.

2. Building your own wrapper for FileSaver.js

The only other solution I can think of now, is to wrap the FileSaver usage into its own Angular Module yourself. Then import that Module and inject your newly built FileSaverService into your real class. In the spec file you could then do something like:

describe("MyComponent", () => {
    
    let component: MyComponent;
    let  fixture: ComponentFixture<MyComponent>;

    let fileSaverServiceSpy: jasmine.SpyObj<FileSaverService>;
   
    beforeEach(() => {
        fileSaverServiceSpy= jasmine.createSpyObj('FileSaverService', ['saveAs']);

        TestBed
        .configureTestingModule({
            declarations: [
                MyComponent
            ],
            providers: [
                { provide: FileSaverService, useValue: fileSaverServiceSpy }
            ]
        })
        .compileComponents()
        .then(() => {
            fixture = TestBed.createComponent(MyComponent);
            component = fixture.componentInstance;
        });
    });

    it('saveMyFile() should call FileSaver', () => {
        fileSaverServiceSpy.saveAs.and.stub();

        MyComponent.saveMyFile("testFileName");

        expect(fileSaverServiceSpy.saveAs).toHaveBeenCalled(); 
    });

});

However, all that seems like a lot of work just to get the spy working.

Edit: I am testing this on an angular 9 project, these are my relevant dependencies:

"@angular/core": "9.1.12",
"jasmine-core": "3.6.0",
"jasmine-spec-reporter": "6.0.0",
"karma": "5.2.3",
"karma-coverage-istanbul-reporter": "3.0.3",
"karma-firefox-launcher": "^2.0.0",
"karma-jasmine": "4.0.1",
"karma-jasmine-html-reporter": "1.5.4",
"karma-typescript": "5.2.0",
Bishares
  • 67
  • 1
  • 13
  • 1
    For this guy it also worked like the other thread suggested: https://github.com/eligrey/FileSaver.js/issues/414#issuecomment-376944089 – Bishares Mar 29 '21 at 03:24
  • I also stumbled across this solution: https://nils-mehlhorn.de/posts/angular-file-download-progress#decoupling-filesaverjs The author basically builds his own provider for file-saver.js. It should do the same thing as angular-file-saver. – Bishares Apr 07 '21 at 02:28