2

I have an ES6 module that needs jquery.

import $ from 'jquery';

export class Weather {
    /**
     * Constructor for Weather class
     *
     * @param latitude
     * @param longitude
     */
    constructor(latitude, longitude) {
        this.latitude  = latitude;
        this.longitude = longitude;
    }

    /**
     * Fetches the weather using API
     */
    getWeather() {
        return $.ajax({
            url: 'http://localhost:8080/weather?lat=' + this.latitude + '&lon=' + this.longitude,
            method: "GET",
        }).promise();
    }
}

Module works fine when use it in my main module but the issue is with the test that I am writing for it.

Here's the test:

import {Weather} from '../js/weather';
import chai from 'chai';
import sinon from 'sinon';

chai.should();

describe('weatherbot', function() {
    beforeEach(function() {
        this.xhr = sinon.useFakeXMLHttpRequest();

        this.requests = [];
        this.xhr.onCreate = function(xhr) {
            this.requests.push(xhr);
        }.bind(this);
    });

    afterEach(function() {
        this.xhr.restore();
    });

    it('should return a resolved promise if call is successful', (done) => {
        let weather = new Weather(43.65967339999999, -79.72506369999999);

        let data = '{"coord":{"lon":-79.73,"lat":43.66},"weather":[{"id":521,"main":"Rain","description":"shower rain","icon":"09d"}],"base":"stations","main":{"temp":15.28,"pressure":1009,"humidity":82,"temp_min":13,"temp_max":17},"visibility":24140,"wind":{"speed":7.2,"deg":30},"clouds":{"all":90},"dt":1496770020,"sys":{"type":1,"id":3722,"message":0.0047,"country":"CA","sunrise":1496741873,"sunset":1496797083},"id":5907364,"name":"Brampton","cod":200}';

        weather.getWeather().then((data) => {
            expect(data.main.temp).to.equal(15.28);
            done();
        });

        this.requests[0].respond("GET", "/weather?lat=43.659673399999996&lon=-79.72506369999999", [
            200, {"Content-Type":"application/json"}, JSON.stringify(data)
        ]);
    });
});

And here's my package.json:

{
  "devDependencies": {
    "babel-core": "^6.24.1",
    "babel-loader": "^6.1.0",
    "babel-polyfill": "^6.3.14",
    "babel-preset-es2015": "^6.1.18",
    "chai": "^3.5.0",
    "copy-webpack-plugin": "^0.2.0",
    "css-loader": "^0.28.0",
    "extract-text-webpack-plugin": "^2.1.0",
    "file-loader": "^0.11.1",
    "mocha": "^3.4.1",
    "mocha-webpack": "^1.0.0-beta.1",
    "qunitjs": "^2.3.2",
    "sinon": "^2.2.0",
    "style-loader": "^0.16.1",
    "svg-inline-loader": "^0.7.1",
    "webpack": "*",
    "webpack-dev-server": "^1.12.1",
    "webpack-node-externals": "^1.6.0"
  },
  "scripts": {
    "build": "webpack",
    "watch": "webpack --watch --display-error-details",
    "start": "webpack-dev-server --hot --inline --port 8383",
    "test": "mocha --compilers js:babel-core/register ./test/*.js",
    "test:watch": "npm run test -- --watch"
  },
  "babel": {
    "presets": [
      "es2015"
    ]
  },
  "dependencies": {
    "bootstrap": "^3.3.7",
    "jquery": "^3.2.1",
    "webpack": "*"
  }
}

As you can see I only have to do npm test to run the test.

When do npm test, I get this error:

TypeError: _jquery2.default.ajax is not a function
      at Weather.getWeather (js/weather.js:19:18)
      at Context.<anonymous> (test/index.js:26:17)

But I am importing the jquery in the module, why it might be happening?

thedeliciousmuffin
  • 784
  • 1
  • 10
  • 22

4 Answers4

2

There are two main problems here. The first is of course that you need to fix your import problem, but that is unrelated to testing. You will need to resolve this before going into testing, and this might have to do with the configuration of your build tool versus running in Node. You should open a separate question for this, although this might be of help. Probably all you need to do is replace the import with this import * as jQuery from 'jquery';

The other big issue is that you are running it inside of Node (using npm test that triggers Mocha) while your code requires a browser. The fake server implementation of Sinon is meant to be used in a browser environment, and you are running the tests in a server environment. That means neither jQuery nor the fake server setup will work, as Node does not have a XHR object.

So although the Sinon XHR setup seems fine, unless you are willing to change your test runner to run your tests inside of a browser environment (Karma is great for doing this from the CLI!), you need to handle this in another way. I seldom reach for faking XHR, and instead I stub out dependencies at a higher level. The answer from @CarlMarkham is touching upon this, but he does not go into details on how this would work with your code.

You are basically left with two options when running your code in Node:

  1. Intercept calls that import the JQuery module and replace it with your own object that has a stubbed version of ajax. This requires a module loader intercepter such as rewire or proxyquire.
  2. Use dependency injection directly in your module.

The Sinon homepage has a good article by Morgan Roderick on the first option, as well as several links to other articles elsewhere on the net, but no how to that explains how to do the first option. I should write one when I have time ... but here goes:

Using dependency injection on the instance level

The least invasive way is to just expose the ajax method on the instance you are testing. That means you would not need to inject anything into the module itself, and you don't have to think about cleanup afterwards:

// weather.js
export class Weather {
    constructor(latitude, longitude) {
        this.ajax = $.ajax;
        this.latitude  = latitude;
        this.longitude = longitude;
    }

    getWeather() {
        return this.ajax({ ...

// weather.test.js

it('should return a resolved promise if call is successful', (done) => {
    const weather = new Weather(43.65, -79.725);
    const data = '{"coord":{"lon":-79.73, ... }' // fill in
    weather.ajax = createStub(data);

I have written a more elaborate example of this technique on the Sinon issue tracker.

There is another way, that is more invasive, but lets you keep the class code unaltered by directly modifying the dependencies of the module:

Using dependency injection on the module level

Just modify your Weather class to export a setter interface for your dependencies so that they can be overwritten:

export const __setDeps(jQuery) => $ = jQuery;

Now you can simplify your test to read like this:

import weather from '../js/weather';
const Weather = weather.Weather;

const fakeJquery = {};
weather.__setDeps(fakeQuery);

const createStub = data => () => { promise: Promise.resolve(data) };

it('should return a resolved promise if call is successful', (done) => {
    const weather = new Weather(43.65, -79.725);
    const data = '{"coord":{"lon":-79.73, ... }' // fill in
    fakeQuery.ajax = createStub(data);

    weather.getWeather().then((data) => {
        expect(data.main.temp).to.equal(15.28);
        done();
    });
}

One problem with this approach is that you are tampering with the internals of the module, and so you need to restore the jQuery object in case you need to use the Weather class in other tests. You could of course also do the inverse: instead of injecting a fake jQuery object you can export the actual jQuery object and modify the ajax method directly. You would then delete all the injection code in the sample code above and modify it to read something like

// weather.js
export const __getDependencies() => { jquery: $ };


// weather.test.js

it('should return a resolved promise if call is successful', (done) => {
    const weather = new Weather(43.65, -79.725);
    const data = '{"coord":{"lon":-79.73, ... }' // fill in
    
    __getDependencies().jquery.ajax = createStub(data);

     // do test
     
     // restore ajax on jQuery back to its original state
oligofren
  • 20,744
  • 16
  • 93
  • 180
  • I haven't had time to look at this properly since I am traveling a lot these days but I think that's the answer I was more or less looking for. Quick question, what would be a good build tool to get started with modern web app development? Webpack? – thedeliciousmuffin Jun 13 '17 at 13:59
  • Webpack is fine, but setup is always a chore. Try `create-react-app` to get the whole shebang setup. – oligofren Jun 13 '17 at 14:40
0

Unless it is ES6 module that has default export that is being imported (which jQuery is not), the proper way to import it is

import * as $ from 'jquery';

The way how CommonJS modules are treated with import is the responsibility of build tool. default imports for non-ES6 modules was (erroneously) supported at least in older Webpack versions. And no version restriction for

"webpack": "*"

dependency is a direct way to broken builds.

Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • I still get `TypeError: $.ajax is not a function`. :( – thedeliciousmuffin Jun 06 '17 at 20:07
  • How exactly do you test? If it's like it's stated, `"test": "mocha --compilers js:babel-core/register ./test/*.js"`, then this would explain a lot, and obviously you can't get away with that. – Estus Flask Jun 06 '17 at 20:14
  • `mocha` runner runs in Node, not in browser. There's no DOM for jQuery in Node. jQuery is factory function in Node, https://bugs.jquery.com/ticket/14549 , it doesn't have `ajax` property indeed. If you're willing to run tests in Node, a proper strategy is to stub/mock all jQuery stuff, it's better to make `$` Angular provider instead, this will improve testability. – Estus Flask Jun 06 '17 at 20:20
  • This is not correct. Treating the `module.exports` object as the default export is the suggested approach. Expecting the `* as $` object to be `module.exports` is something Babel and Webpack do, but it is not what Node will do, and isn't something you should rely on moving forward. – loganfsmyth Jun 06 '17 at 20:56
  • @loganfsmyth To my knowledge, this is ad hoc behaviour that never was standardized, looks more like a bug rather than a feature to me. What about CJS modules that have `export.default`? This certainly doesn't add clarity. Any way, import is likely not a primary problem here. – Estus Flask Jun 06 '17 at 21:07
  • I agree that it isn't critical for this question, but I also didn't want to leave it unmentioned. `* as FOO` is guaranteed by the ES spec to return `FOO` as an object, so for instance that makes it unusable in jQuery cases like `$(".foo")` where it must be a function. If Webpack passes it though as a function, that's not spec compliant, whether the linkage of CommonJS and ES6 is defined or not. – loganfsmyth Jun 06 '17 at 21:58
0

In my project, I stub all the jQuery methods I use, so, I would stub $.ajax since all you need to test are the returned promises.

Something like this in another file:

module.exports = {
  ajax: sinon.stub(global.$, 'ajax')
}

Then, in your test

import { ajax } from 'stubs'

This way, the ajax method is stubbed and you can define what it should return on a per test basis:

ajax.returns({
  done: (callback) => { callback(someParam) },
  fail: () => {},
  always: (callback) => { callback() }
});
oligofren
  • 20,744
  • 16
  • 93
  • 180
Cjmarkham
  • 9,484
  • 5
  • 48
  • 81
  • This will not help the OP. jQuery is bound in the code to be tested, and so the poster either needs to intercept the require calls or inject jQuery into the module. Only then will your code be of value. – oligofren Jun 11 '17 at 08:21
0

I ran into a similar problem recently. I found that when I ran a test with mocha and webpack, there was no 'window' in the scope for jquery to bind to and as a result it was undefined. To solve this problem, I found that I could follow this advice and replace import * as $ from jquery in the source file with:

// not the winning solution
const jsdom = require("jsdom");
const { window } = new jsdom.JSDOM();
const $ = require('jquery')(window);

But then the source file would no longer properly bundle with webpack, so I gave up on using mocha and babel for client-side javascript tests. Instead, I found that I could test my client-side code properly using a combination of karma and phantomjs.

First, I installed all the dependencies:

npm install -D babel-loader @babel/core
npm install -D mocha chai sinon mocha-webpack
npm install -D phantomjs-prebuilt
npm install -D webpack
npm install -D karma karma-mocha karma-chai karma-sinon 
npm install -D karma-mocha-reporter karma-phantomjs-launcher karma-webpack

Then I setup a config file in the root called karma.config.js with:

module.exports = function(config) {
  config.set({
    browsers: ['PhantomJS'],
    files: [
      './test/spec/*.js'
    ],
    frameworks: ['mocha', 'chai', 'sinon'],
    reporters: ['mocha'],
    preprocessors: {
      './test/spec/*.js': ['webpack']
    },
    webpack: {
      module: {
        rules: [
          { test: /\.js/, exclude: /node_modules/, loader: 'babel-loader' }
        ]
      },
      watch: true,
      mode: 'none'
    },
    webpackServer: {
      noInfo: true
    },
    singleRun: true
  });
};

Finally, I added "test": "karma start karma.config.js" to scripts in package.json. All the spec tests can now be run with npm test.

R J
  • 4,473
  • 2
  • 22
  • 29