mochajs/mocha

Bind ES6 arrow functions to Context

michielbdejong opened this issue ยท 11 comments

disclaimer: I'm quite new to Mocha and to arrow functions, so feel free to close this if it's nonsense. :)

Consider these three tests:

test('Mocha Context available in normal ES5 functions', function() {
  expect(this.slow).to.be.a('function');
});
test('Mocha Context available ES6 arrow functions', () => {
  // Will fail, because it's bound to `window`:
  expect(this.slow).to.be.a('function');
});
test('Mocha Context available in nested, bound ES6 arrow functions', function() {
  (() => {
    expect(this.slow).to.be.a('function');
  })();
});

afaics this is because Mocha uses fn.call(ctx); to execute the function passed to test, which does not have the desired effect in case of ES6 arrow functions, as you can read here and also see if you run this in your browser console:

(() => { console.log(this); }).call({ foo: 'bar' });
// prints the Window object
(function() { console.log(this); }).call({ foo: 'bar' });
// prints { foo: 'bar' }
(function() { (() => { console.log(this); })(); }).call({ foo: 'bar' });
// prints { foo: 'bar' }

Looking especially at how that third test (arrow function wrapped inside a normal function) does pass, I think it could be fixed by wrapping the variable fn in a normal ES5 function before calling its .call method, i.e. replacing that fn.call(ctx); with (function() { fn(); }).call(ctx); on line 289 of Runnable.js

Or the easier option would of course be to add a note to the documentation saying: Don't put your test in an arrow function if you plan to use this.slow or other methods from the Context. :)

@michielbdejong The documentation suggestion works, or Mocha could pass the test context as an argument in addition to setting it as this. Also, it's not possible to change the value of this in an arrow function, since they are lexically scoped. If you look at the Babel's output you'll see this is the case:

https://babeljs.io/repl/#?experimental=false&evaluate=false&loose=false&spec=false&playground=true&code=()%20%3D%3E%20%7B%20this%20%7D%3B%0A

@michielbdejong Your suggested fix doesn't seem to work in practice

var assert = require('assert');

function it(str, fn) {
  var ctx = {
    skip: function() {}
  };

  (function() {
    fn().call(ctx)
  }).call(ctx);
}

it('Mocha Context available ES6 arrow functions', () => {
  assert.equal(typeof this.skip, 'function');
});
$ node --version
v4.0.0
$ node example.js

assert.js:89
  throw new assert.AssertionError({
  ^
AssertionError: 'undefined' == 'function'

The reason that (function() { (() => { console.log(this); })(); }).call({ foo: 'bar' }); works is because the inner arrow function is already bound to its parent function's context after the call()

How babel transpiles the codes should make it evident why this won't work:

(function() {
  it('test', () => {
    console.log(this);
  });
});

// => 

'use strict';

(function () {
  var _this = this;

  it('test', function () {
    console.log(_this);
  });
});

No matter how you bind/call the function passed to it, it still has a reference to the parent's context.

Closing for now. An issue/pr in https://github.com/mochajs/mochajs.github.io would be appreciated! :)

Your suggested fix doesn't seem to work in practice

Oh, of course, sorry. I see why now. Thanks for taking the time to write a code example!

I created mochajs/old-mochajs-site#14.

No problem - my commit referenced in this issue should hint that I thought it would work too haha.

And appreciate the PR! :)

If anyone finds this solution unsatisfactory, as I did, because you'd really prefer to use arrow functions, I have good news! You can just declare the top level describe with a normal function: e.g. describe('MyClass', function () {

It works because any arrow functions within will have this bound to their containing function's scope (i.e. the mocha context). So the follow will totally work...

describe('thing', function () {
  describe('does stuff', () => {
    beforeEach(() => {
      this.foo = 'bar'
    })
    it('should work', () => {
      assert(this.foo === 'bar')
    })
  })
})

๐Ÿ‘๐Ÿ‘๐Ÿ‘
...๐Ÿ‘Ž ๐Ÿคท

WARNING: that is not quite the same thing* and might not behave equivalent to the non-arrow-function code for:

  • nested suites
  • configuration (this.timeout(<milliseconds>) and the like)
  • currentTest in hooks
  • anything else this-related that would normally affect the specifically the current hook or test or otherwise be specially handled

* For better or for worse, Mocha is doing some magic regarding the this object to achieve all the uses it supports, so it isn't simply sharing the same context object between all suites and tests.

@ScottFreeCode thanks for the tip! I've edited my comment to reflect my reduced confidence.

Is there an option to pass a custom ctx so that it callbacks are bound to it?
We prefer binding our tests to a certain "library" as this.


My current workaround is:

const lib = global._lib;

before(async function() {
  // Any properties can be set on `this` for use in tests.
  // https://dev.to/open-wc/shared-behaviors-best-practices-with-mocha-519d
  //
  // There seems to be no better way of turning `this` into `lib` in tests.
  // https://github.com/mochajs/mocha/issues/1856#issuecomment-854206335
  //
  for (const key in lib) {
    this[key] = lib[key];
  }
});