Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

initial implementation of "functional" interface #3399

Closed
wants to merge 1 commit into from

Conversation

boneskull
Copy link
Contributor

This is an initial attempt at a "functional" interface; those of us who prefer using arrow functions will hopefully appreciate this. I've called it the func UI; here's how it looks:

Usage

'use strict';

describe('behavior', suite => {
  // just like this.timeout()
  suite.timeout(200);

  it('should work synchronously', test => {
    expect(1 + 1, 'to be', 2);
    expect(2 + 2, 'to be', 4);
    // test.done() must be called unless a Promise is returned
    test.done();
  });

  it('should work asynchronously', test => {
    expect(1 - 1, 'to be', 0);
    expect(2 - 1, 'to be', 1);
    process.nextTick(function() {
      test.done();
    });
  });

  it('should work with a Promise', async () => {
    expect(1 - 1, 'to be', 0);
    expect(2 - 1, 'to be', 1);
    // test.done() needn't be called
  });

  it('should work with context methods', async test => {
    test.timeout(400);
    expect(1 - 1, 'to be', 0);
    expect(2 - 1, 'to be', 1);
  });
  
  afterEach(hook => {
    // hooks have same behavior
    hook.timeout(200);
    hook.done();
  });

  afterEach(async () {
    // and also support Promises
  });
});

Implementation Details

Mocha makes assumptions about how Runnables (Tests, Hooks) are executed; what function context they are called with, and what parameters. To address this, I needed to decouple some parts of Mocha's core.

So, I've introduced a new concept: the Behavior. We can think of this as a collection of mixins which may apply (currently) to Suites and Runnables. These mixins introduce alternative behavior for certain methods within those objects. This is not the mythical plugin API, but it could be a step in that direction.

There are currently a select few places where the Behaviors are delegated to, and I've broken out the default functionality into a Behavior (the Default one, fwiw).

Due to the way Mocha's Suites configure their children (Suites, Hooks, and Tests), in which all children "inherit" the configuration of their parent Suite, an Interface (bdd, tdd, qunit, etc.) only need configure the Behavior on the Root Suite. This configuration propagates throughout all Suites and children thereof.

This means an Interface can provide an alternate Behavior. It's not the only way to provide an alternate Behavior, but seems like a reasonable one; see the simplicity of lib/interfaces/func.js. (Maybe the interface is where the Functional Behavior should live?)

A Behavior may have one or more methods and needn't implement all of them. The default methods will always be present, and if multiple behaviors are configured, then the last one wins (think of how Object.assign() works).

Ultimately, I'm not married to this API. I'm open to considering an event-driven model, as long as the scope doesn't blow up. But once it's merged, we are married to it, so it behooves us to be confident about what we're doing.

See #1856, #2657, probably others.

@boneskull boneskull added the type: feature enhancement proposal label May 30, 2018
@boneskull
Copy link
Contributor Author

lol, guess I can't use Object.assign... I'll fix that.

@boneskull
Copy link
Contributor Author

this needs

  • docs
  • an integration test to see what happens when e.g. test.done() is called in a Promise-returning Test
  • more unit tests, probably

@wavebeem
Copy link

imo this is exactly what i would expect an arrow-func-centric Mocha to look like!

@coveralls
Copy link

Coverage Status

Coverage decreased (-0.6%) to 89.381% when pulling b96abc9 on experimental/bdd2 into 0d95e3f on master.

@eight04
Copy link

eight04 commented May 30, 2018

Is it possible to omit explicit ctx.done() for sync tests/hooks? Suppose that the test is sync if the return value is not a promise? If ctx.done() is required for sync tests, all existed tests have to be modified to switch from bdd to func:

describe("...", () => {
-  it("...", () => {
+  it("...", t => {
    // some asserts...
+    t.done();
  });
});

@boneskull
Copy link
Contributor Author

if we didn’t require it then how would we know a test is async? if we supported a test, done signature, then every async test that didn’t need to use methods on test would have test anyway.

probably just as effective to useasync keyword instead of test.done() ..

@wavebeem
Copy link

wavebeem commented May 31, 2018 via email

@eight04
Copy link

eight04 commented May 31, 2018

Something like this?

describe("...", () => {
  it("...", t => {
    t.async(true);
    setTimeout(t.done, 200);
  });
});

Copy link
Contributor

@Bamieh Bamieh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really love this feature. I still did not figure out how the done parameter is being passed to the tests, I do not see the implementation anywhere.

/**
* Default Mocha behavior
*/
exports.Default = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename this from Default to something else?

  • It is a little confusing with es6 default modules.
  • Whenever we want to change the default behavior to another one, we have to refactor 2 places now.
  • A semantic name indicating what it does is more meaningful IMO. noCtxAsArg or something in this context (pun intended 😄 ).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, probably a good idea to rename this, or just use module.exports and put the Functional behavior within the func interface... what do you think of that?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it Funtional is meaningful? How about Contextual?
I'm not sure that it is functional thing.

*/
run: function(done) {
var result = this.fn.call(this.ctx);
if (result && typeof result.then === 'function') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this code is just moved, but if we wrap the result in Promise.resolve(result).then(...) instead of doing this check, we'll always have the returned value treated as a promise, without the need for the extra hassle.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would require adding a Promise polyfill to the production bundle.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I'm not necessarily offering an opinion on that; I'm just telling you why Mocha historically does it this way)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm actually really annoyed about IE11/ES5, so maybe we can just write Mocha in ES6 going forward and pipe the distfile thru Babel...

* @private
* @returns {*} Whatever the behavior method returns
*/
Runnable.prototype.callBehavior = function(name) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure about backward compatibility but can't we use (name, ...args)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, Mocha is written in ES5 😄

@@ -0,0 +1,43 @@
'use strict';

var Promise = require('es6-promise');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this needed? Promises are supported for all node versions we support (anything above 4)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IE11

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

though, I don't think this test actually runs in the browser. it should.

@Bamieh
Copy link
Contributor

Bamieh commented May 31, 2018

I wonder if I can use this feature to pass a client to the functions (wd client for native testing in my projects).

@boneskull
Copy link
Contributor Author

@wavebeem We do check for a Promise return value, but not all async behavior is Promise-based.

@Bamieh I'm not entirely sure what you're asking, but if I had to guess, it's here. We call shouldRunWithCallback to determine which of the two methods (run or runWithCallback) we call. Mind you, this is not applicable to the func interface, which (currently) will always pass a single parameter.

@eight04 Is there a precedent for that--does another test framework do it that way? I'm basing my implementation on stuff like tape, which requires t.end() (or t.plan(), which we can't support) to be called unless a Promise is returned.

This is why I mentioned it'd actually be easier to use the async keyword for otherwise-synchronous tests:

it('should synchronously assert', t => {
  assert(true);
  t.done(); // might as well alias t.end() to this if we do it
});

is effectively:

it('should synchronously assert', async t => {
  assert(true);
});

FWIW, the Jasmine/Jest strategy seems to actually make timers Promise-returning or synchronous (not sure which) by monkeypatching them. I don't want that to be a choice Mocha makes for its users; libraries like Sinon can be added to make timers synchronous, if needed.

* @this {Runnable}
* @returns {boolean} `true` if we should run the test fn with a callback
*/
shouldRunWithCallback: function() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, IMO, this is too specific.

What may be better is supporting a function which answer the question "what arguments should we pass to the execution function?", and another "what context should the execution function run with?"

Obviously we're going to need to document this specification...

@eight04
Copy link

eight04 commented Jun 1, 2018

Is there a precedent for that--does another test framework do it that way?

No. Actually, I think (test, done) is better. It matches the old interface nicely.

The original purpose of passing the context as the first argument is to solve the problem that users can't access .skip(), .timeout() when using arrow functions. I don't think people would like to change all tests into async function just to gain the ability to access .skip(), .timeout() for maybe one or two sub-tests.

I prefer (test, done). Reasons:

  1. All existed sync tests don't have to be modified.
  2. All existed promise-based async tests don't have to be modified.
  3. All existed callback-based async tests just need to change the signature. (done -> (t, done))
  4. arrow-mocha users can migrate to the new UI without modification.

If we still want to keep async as the default, it is probably worth adding an autoend option that would automatically done() the test when the return value is not a promise, just like current mocha.

Copy link
Contributor

@outsideris outsideris left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this approach.

afterEach(function() {
return Promise.resolve();
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests don't be run.
We should add it in package-scripts.js like:

func: {
  script: test('func', '--ui func test/interfaces/func.spec'),
  description: 'Run Node.js Functional interface tests',
  hiddenFromHelp: true
},

/**
* Default Mocha behavior
*/
exports.Default = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it Funtional is meaningful? How about Contextual?
I'm not sure that it is functional thing.

};
var result = this.fn.call(null, this.ctx);
if (result && typeof result.then === 'function') {
this.resetTimeout();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this line should ve moved above if statement.
Because tests just exit if test.done() is not called if synchronous tests. Also we should fix the error message for func ui.

@plroebuck plroebuck mentioned this pull request Sep 24, 2018
4 tasks
@boneskull
Copy link
Contributor Author

This is pretty darn stale. I still like the idea, but will need more iteration after v6.

@boneskull
Copy link
Contributor Author

@outsideris I think I agree that Functional is a misnomer. Contextless might be more appropriate... though it's a mouthful.

* @param {Function} done - Callback
*/
run: function(done) {
this.ctx.done = function(err) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love this monkeypatching; Context should have a method which accepts a callback and assigns it to its own done method.

@nweldev
Copy link

nweldev commented Aug 18, 2019

@boneskull do you think there is a chance you work on this approach again? Could you explain why it was postponed / what's currently blocking it? I would be happy to help if I can.

@JoshuaKGoldberg
Copy link
Member

🤖 Closing out old PRs to keep the review queue small. Cheers!

@JoshuaKGoldberg JoshuaKGoldberg deleted the experimental/bdd2 branch December 2, 2023 21:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: feature enhancement proposal
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants