0

I have a simple directive that works fine (written with TypeScript but it does not really matter) that allows to get the content of files uploaded via <input type="file"> and HTML5 File API.

The use is simple:

<body ng-controller="MyCtrl as ctrl">
  <input type="file" file-read="ctrl.readInputFile(data)">
</body>

callback readInputFile() is being called with the files content.

interface IFileReadScope extends angular.IScope {
  fileRead(data: any): void;
}

class FileRead implements angular.IDirective {
  restrict = 'A';
  scope = {
    fileRead: '&'
  };

  link = (scope: IFileReadScope, element: angular.IAugmentedJQuery) => {
    element.on('change', (e: Event) => {
      const files: FileList = (<HTMLInputElement> e.target).files;

      for (let i = 0; i < files.length; i++) {
        const file = files[i];

        const reader = new FileReader();

        reader.onload = (e: Event) => {
          scope.$apply(() => {
            scope.fileRead({data: (<FileReader> e.target).result});
          });
        };

        reader.readAsText(file);
      }
    });
  };

  static factory(): angular.IDirectiveFactory {
    const directive = () => new FileRead();
    return directive;
  }
}

app.directive('fileRead', FileRead.factory());

And here the test for it:

describe('fileRead', () => {
  beforeEach(module('app'));

  let $compile: angular.ICompileService;
  let scope: any;

  beforeEach(inject((_$compile_, _$rootScope_) => {
    $compile = _$compile_;
    scope = _$rootScope_.$new();
  }));

  function compileTemplate(template: string): angular.IAugmentedJQuery {
    const el = $compile(angular.element(template))(scope);
    scope.$digest();
    return el;
  }

  it('should call a callback each time a file has been read', () => {
    const el = compileTemplate(
      '<input type="file" file-read="readInputFile(data)">'
    );

    const files = new Array<string>();

    scope.readInputFile = (data: any) => {
      files.push(data);
    };

    el.triggerHandler({
      type: 'change',
      target: {
        files: [
          new Blob(['data0']),
          new Blob(['data1'])
        ]
      }
    });

    scope.$digest();

    // Fails because it does not wait for scope.readInputFile() to be called
    expect(files.length).toEqual(2);
    expect(files[0]).toEqual('data0');
    expect(files[1]).toEqual('data1');
  });
});

The problem is that I don't know how to wait for scope.readInputFile() callback to be executed.

I've tried scope.$digest(), $rootScope.$digest(), $apply, Jasmine spyOn... I've also checked how popular directives do that. It seems like nobody do "that" :/

Any idea?

Edit:

Thanks to Hooray Im Helping and Matho, here the 2 possible solutions using Jasmine 2:

let files: string[];

beforeEach(done => {
  const el = compileTemplate(
    '<input type="file" file-read="readInputFile(data)">'
  );

  files = new Array<string>();

  scope.readInputFile = (data: any) => {
    files.push(data);
    if (files.length === 2) done();
  };

  el.triggerHandler({
    type: 'change',
    target: {
      files: [
        new Blob(['data0']),
        new Blob(['data1'])
      ]
    }
  });

  scope.$digest();
});

it('should call a callback each time a file has been read', () => {
  expect(files.length).toEqual(2);
  expect(files[0]).toEqual('data0');
  expect(files[1]).toEqual('data1');
});

And the more elegant one in this case:

it('should call a callback each time a file has been read', done => {
  const el = compileTemplate(
    '<input type="file" file-read="readInputFile(data)">'
  );

  const files = new Array<string>();

  scope.readInputFile = (data: any) => {
    files.push(data);
    if (files.length === 2) {
      expect(files[0]).toEqual('data0');
      expect(files[1]).toEqual('data1');
      done();
    }
  };

  el.triggerHandler({
    type: 'change',
    target: {
      files: [
        new Blob(['data0']),
        new Blob(['data1'])
      ]
    }
  });

  scope.$digest();
});

If scope.fileRead() is not executed inside the directive, then the test will fail:

FAILED fileRead should call a callback each time a file has been read
Error: Timeout - Async callback was not invoked within timeout specified by jasmine.DEFAULT_TIMEOUT_INTERVAL.

So if the code breaks the unit test will fail => job done :)

tanguy_k
  • 11,307
  • 6
  • 54
  • 58

3 Answers3

1

I don't use Jasime, i always use mocha + chaijs + sinon, but jasmine has the done function implemented as well, it should work like this:

it('async method', function(done){ 
//once we add the done param, karma will wait until the fn is executed

 service.doSomethingAsync()
  .then(function(){
    //assertions 
    // expect(result).to.be.something();
    done(); //Where we are telling karma that it can continue with the test
  }
  //Other assertions    
}

Using chai-as-promised:

it('async method', function(done){

 var result = service.doSomethingAsync();
 expect(result).to.eventually.be(something=;
  //Other assertions    
}
1

What version of Jasmine are you using?

If it's 1.3, you can use waitsFor and runs to wait a certain period of time then assert on the specs:

So

let files = new Array<string>();

scope.readInputFile = (data: any) => {
  files.push(data);
};

el.triggerHandler({
  type: 'change',
  target: {
    files: [
      new Blob(['data0']),
      new Blob(['data1'])
    ]
  }
});

scope.$digest();

becomes

let files = new Array<string>();

runs(function() {
    scope.readInputFile = (data: any) => {
        files.push(data);
    };

    el.triggerHandler({
        type: 'change',
        target: {
            files: [
                new Blob(['data0']),
                new Blob(['data1'])
            ]
        }
    });

    scope.$digest();
});

and

expect(files.length).toEqual(2);
expect(files[0]).toEqual('data0');
expect(files[1]).toEqual('data1');

becomes

waitsFor(function() {
    expect(files.length).toEqual(2);
    expect(files[0]).toEqual('data0');
    expect(files[1]).toEqual('data1');
}, "failure message", time_in_milliseconds);

If you're using Jasmine 2.0, you can use the done function:

You'd move the code you're trying to test (and any set up code it depends on) to beforeEach, call the async code there, then notify the framework the async operation is done by calling the done method when the async code finishes. Your specs get run automatically get run after done called. I have never used this before (I'm still on 1.3) and I don't use Angular nor Karma, so I'm in a bit of unfamiliar territory.

According to this answer, your beforeEach function should look like this:

beforeEach(function(done) {
    inject((_$compile_: angular.ICompileService, _$rootScope_: angular.IRootScopeService) => {
        $compile = _$compile_;
        $rootScope = _$rootScope_;
        scope = $rootScope.$new();
    });
    let el = compileTemplate('<input type="file" file-read="readInputFile(data)">');
    let files = new Array<string>();

    scope.readInputFile = (data: any) => {
        files.push(data);
    };

    el.triggerHandler({
        type: 'change',
        target: {
            files: [
                new Blob(['data0']),
                new Blob(['data1'])
            ]
        }
    });

    scope.$digest();

    done();
});

and your assertion should look something like:

it('should call a callback each time a file has been read by FileReader', (done) => {
    expect(files.length).toEqual(2);
    expect(files[0]).toEqual('data0');
    expect(files[1]).toEqual('data1');

    done();
});

Again, I don't use angular, TypeScript, or Karma, so you might need to tweak this. But this is the general idea; set everything up, run the async code, call done in set up, run assertions, call done in spec.

Community
  • 1
  • 1
Hooray Im Helping
  • 5,194
  • 4
  • 30
  • 43
0

You can put your expectations in the callback function.

HankScorpio
  • 3,612
  • 15
  • 27
  • 1
    The test will silently fail the day the callback is not being called anymore => you just killed the whole purpose of testing code. – tanguy_k Apr 07 '15 at 19:26