-
-
Notifications
You must be signed in to change notification settings - Fork 306
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
[Breaking] support passing in an async function for the test callback #472
Conversation
This change makes tape work the same with synchronous and asynchronous functions. ``` test('my test', () => { throw new Error('oopsie') }) test('my async test', async () => { throw new Error('oopsie') }) ``` These two cases now have the same semantics which means you can safely use an async function because the unhandled rejection will be converted into a thrown exception. Failing a test when the return promise rejected will fail a test that was probably silently broken previously. Extra test cases have been added to reflect real world usage of tape with async functions which we preferably do not want to break.
This is a much requested feature by my peers @juliangruber @maxharris9 @davidmarkclements See related issues : |
fwiw this won’t actually happen by default; unhandled rejections are a normal part of the language, and by default, the program needs to continue when one occurs. The node warning message is overly ambitious and thus misleading. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let’s add some tests for normal promises as well (we can shim them with es6-shim, and test them i all node versions)
.eslintrc
Outdated
@@ -7,4 +7,7 @@ | |||
"named": "never", | |||
}], | |||
}, | |||
"parserOptions": { | |||
"ecmaVersion": 2017 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let’s enable this only for the tests that need it; otherwise breaking syntax could be added.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll see if I can opt in on a per file basis, maybe a nested .eslintrc
file in one of the test folders is sufficient.
lib/test.js
Outdated
typeof Promise === 'function' && | ||
callbackReturn && | ||
typeof callbackReturn.then === 'function' && | ||
typeof callbackReturn.catch === 'function' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
.catch isn’t required; any thenable should be supported.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Personally I only care about supporting async functions and not thenable returning functions.
However if you care strongly I can add support for thenable's
lib/test.js
Outdated
typeof callbackReturn.then === 'function' && | ||
typeof callbackReturn.catch === 'function' | ||
) { | ||
callbackReturn.catch(function onError(err) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
since it might not be a real promise, we should wrap it in Promise.resolve first.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand why that matters in this concrete case. It might be a good practice in general but I think for this patch it's unnecessary.
lib/test.js
Outdated
) { | ||
callbackReturn.catch(function onError(err) { | ||
nextTick(function rethrowError() { | ||
throw err |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
throw err | |
throw err; |
and throughout
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
willfix.
as-is, this is still a breaking change - anyone currently using an async function (or any promise-returning function) with an unhandled rejection will cause an error where none previously existed. |
lib/test.js
Outdated
typeof callbackReturn.catch === 'function' | ||
) { | ||
callbackReturn.catch(function onError(err) { | ||
nextTick(function rethrowError() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Im actually confused here - all this seems to be doing is ensuring that the returned promise can’t hook into node’s unhandledRejection event - it’s not actually failing the associated test and allowing tests to continue.
All this does is make exceptions in async functions work. unhandled rejections from the async function passed to
I was at the openjs summit recently and spoke to some node core folks, it seems like we want stricter semantics around unhandled rejections in core, forwarding unhandled rejections to uncaught exceptions might be a change we see in future node versions. Unhandled rejections are a bug and undefined behavior, they should be handled exactly like uncaught exceptions. |
If you have an async function that throws an exception its "silently" continueing. Your test suite is broken, it's just that the exitCode of the process does not reflect it. This patch makes the exception you threw visible. Technically it is a breaking change, it's a change in semantics. However it is a better user experience for exception handling in async functions and normal functions to be the same in |
It should be noted that there's very few cases of this happening, if you have a thrown exception in an async function you are probably not calling The behavior reported in #358 is more common for async functions with thrown exceptions, confusion about tests not ending and just hanging. Tape will report "expected t.end() to be called but test not finished" if you have no handles open in your test process, but most realistic tests will actually leak sockets or timers or something that keeps the event loop open when you throw in an async function. This leads to the output of the process just hanging and tape will not report anything, and the process wont exit because you didnt teardown your http server or whatever. |
They’re not a bug, and they’re quite defined - if node exits on them by default, it’s violating the intent of the spec. |
…est. Also, implicitly call `.end()` if not already called when a Promise is returned, because the promise itself marks the end of the test.
I've rebased this, and added a commit that changes the behavior to:
Thoughts? |
The existing tests I wrote had I think failing on "double end" is too heavy of a breaking change for this patch. It requires fixing tests and code that "just works" before when you upgrade tape. When we make a breaking change to report the rejected promise error at least you are fixing dubious tests that silently "failed" and are worth looking at and fixing. If you upgrade tape and it makes a breaking change I hope that the break will actually allow you identify tests that silently failed and improved your test suite. You don't want to upgrade tape and just refactor tests that are "correct"
This is actually a breaking change that will break "correct" tests that have passed before, I'm going to push a test case onto this branch to demonstrate this, but here is an example tape('my func', async function (t) {
var res = await fetch('http://localhost:3000')
var body = await res.json()
t.equal(body, '"ok"')
checkDb(function done(data) {
t.equal(data, 'is good')
t.end()
})
}) I've seen tests in the wild where people are migrating their code to async/await. They start using for example This means they are using I strongly recommend this patch implements support for async functions and makes minimal breaking changes based on how people are using
My original patch has two test cases, Your change preserves the I'm ok with failing the test but I still prefer having identical behavior for sync and async errors. Note that adding a try / catch to make sync exceptions fail the test is probably a bad idea and out of scope for this PR. |
CI fails because the anonymous stack frame needs to be stripped, I have another test with an example on how to strip it. I'm going to push a commit to add another test case and add |
To me this should make the test fail as you're mixing control flow primitives. Once the async function completes, the test is done. Anything happening after that is unsafe territory. How would the test harness be able to tell it should wait for an explicit |
For back compat reasons the test harness cannot implicitly call |
Ok. I'm not really interested in this patch unless it implicitly calls |
This patch is purely about fixing the exitCode of your test suite when using async functions so that you can trust CI and it doesn't "silently fail" This patch is not about improved user experience Accidentally using an I'm open to improving the user experience but that should be a seperate PR. This one is for the bugfix of the exitcode, not for the feature of improved user experience with async functions. |
I don’t think we can fix one without providing the other; it’s not currently a bug because tape has no promise support, and a rejected promise is not an error per the intention of the JS spec. |
The user experience for thrown exceptions in async functions is bad. The behavior is different from thrown exceptions in normal functions. I would recommend you look at this from the perspective of exceptions in functions and not the behavior of promises themselves. We can add a check to see if the fn is an AsyncFunction if you prefer. |
That being said I'm fine with supporting promises if that's what we need to get async functions to work |
There’s no way to determine reliably if something is an async function without parsing toString, and an async function is nothing more than a function that returns a Promise - async/await is promises, nothing more. |
Ok, I think this PR is ready for another review. There were a few issues mentioned that are worth discussing
|
Explicit is 👌🏻
Fail and call end. |
Let me double check to make sure all the non-major stuff that's ready is merged and released; and then we can just start bringing in semver-major stuff into master, including this one (i'll do a final rereview of this one as well first) |
@ljharb would you like some help rolling up a new major release ? |
Thanks, but i’m not quite ready yet :-) im making some changes in deep-equal first, so i can roll that into the major. Sometime next week i hope to have it all prepared. |
Sounds great, thanks for keeping me posted :) |
ftr: I'm waiting on a particular user to maybe hand over 4 package names; which I'd use to make robust brand checking methods to help add proper collection support to deep-equal, at which point i'd release a v2 of it, and then merge this along with updating that. Hopefully next week? I haven't forgotten! |
@ljharb friendly reminder :D |
Thanks, I haven't forgotten. I'm waiting on a few package names; once they're transferred I'll publish them, bump deep-equal's major, and then merge this and bump tape's major. |
bd77f63
to
197019c
Compare
I'll get all the pending breaking changes in, and publish a prerelease for v5 soon. |
Nice :) I will update a bunch of my apps & libraries to the pre-release once available. |
Changes since v5.0.0-next.0: - [Breaking] fail any assertion after `.end()` is called (#489( - [Breaking] tests with no callback are failed TODO tests (#69) - [Breaking] equality functions: throw when < 2 arguments are provided - [Breaking] add "exports" to restrict public API - [Breaking] `throws`: bring into line with node’s `assert.throws` - [Breaking] use default `require.extensions` collection instead of the magic Array `['.js']` (#396) - [Fix] error stack file path can contain parens/spaces - [Refactor] make everything strict mode - [Refactor] Avoid setting message property on primitives; use strict mode to catch this (#490) - [Refactor] generalize error message from calling `.end` more than once - [Dev Deps] update `eslint` - [Tests] improve some failure output by adding messages - [Tests] handle stack trace variation in node <= 0.8 - [Tests] ensure bin/tape is linted - [Tests] Fail a test if its callback returns a promise that rejects (#441) - [eslint] fix remaining undeclared variables (#488) - [eslint] Fix leaking variable in tests - [eslint] fix object key spacing Changes since v4.12.1: - [Breaking] `error` should not emit `expected`/`actual` diags (#455) - [Breaking] support passing in an async function for the test callback (#472) - [Breaking] update `deep-equal` to v2 - [Deps] update `resolve` - [meta] change dep semver prefix from ~ to ^
Changes since v5.0.0-next.3: - [Fix] `.catch` is a syntax error in older browsers - [Refactor] remove unused code - [Deps] update `resolve` Changes since v4.13.0: - [Breaking] update `deep-equal` to v2 - [Breaking] fail any assertion after `.end()` is called (#489) - [Breaking] `error` should not emit `expected`/`actual` diags (#455) - [Breaking] support passing in an async function for the test callback (#472) - [Breaking] tests with no callback are failed TODO tests (#69) - [Breaking] equality functions: throw when < 2 arguments are provided - [Breaking] add "exports" to restrict public API - [Breaking] `throws`: bring into line with node’s `assert.throws` - [Breaking] use default `require.extensions` collection instead of the magic Array `['.js']` (#396) - [meta] change dep semver prefix from ~ to ^
Changes since v5.0.0-next.4: - [Breaking] only `looseEqual`/`deepEqual, and their inverses, are now non-strict. - [Breaking] make equality functions consistent: - [Breaking] `equal`: use `==`, not `===`, to match `assert.equal` - [Breaking] `strictEqual`: bring `-0`/`0`, and `NaN` into line with `assert` - [patch] Print name of test that didnt end (#498) - [Refactor] remove unused code - [Deps] update `resolve` Changes since v4.13.2: - [Breaking] only `looseEqual`/`deepEqual, and their inverses, are now non-strict. - [Breaking] make equality functions consistent: - [Breaking] `equal`: use `==`, not `===`, to match `assert.equal` - [Breaking] `strictEqual`: bring `-0`/`0`, and `NaN` into line with `assert` - [Breaking] update `deep-equal` to v2 - [Breaking] fail any assertion after `.end()` is called (#489) - [Breaking] `error` should not emit `expected`/`actual` diags (#455) - [Breaking] support passing in an async function for the test callback (#472) - [Breaking] tests with no callback are failed TODO tests (#69) - [Breaking] equality functions: throw when < 2 arguments are provided - [Breaking] add "exports" to restrict public API - [Breaking] `throws`: bring into line with node’s `assert.throws` - [Breaking] use default `require.extensions` collection instead of the magic Array `['.js']` (#396) - [meta] change dep semver prefix from ~ to ^
I ran into the unhandled rejection issue with tape again. I thought I fixed this almost a year ago 😞 Can we release v5 officially as |
I'm now using |
Awesome, thanks! I appreciate it. |
v5.0.0 is released. |
Nice I released 🎉 |
Love the new feature! Should tape two more assertions to handle async throw? Like tape-promise does: https://github.com/jprichardson/tape-promise#new-assertions |
@3cp a) that seems like it would conflict with "the test ends when the returned-to-tape promise is settled", b) let's discuss new features in new issues :-) |
This PR adds rudimentary support for async/await in a fashion
that breaks back compat as little as possible.
Currently tape has no opinions about exception handling.
If you throw in a test block it will go straight to the
uncaught handler and exit the process.
However newer versions of node & v8 added async functions.
These functions when thrown return a rejected promise.
Currently node logs unhandled rejections with a warning
and in future versions of node, unhandled rejections may get
forwarded to uncaught exceptions.
This patch modifies tape so that an unhandled rejection will be
marked as a TAP failure and fails the tests with exit code 1.
Previously on master it would log to STDERR and then continue
onwards running the rests of the tests.
For example :
This patch is optional, there is a different work around you can do
But adding that one liner to the top of every test file
can get quite annoying and EASY to forget :(
Back compat is broken for some cases, only when the user of
tape
mixes promises & callbacks & async functions in a singletest
block. If the async function returns before you callt.end()
( aka you do notawait
the code that callst.end()
However no back compat breaks for users that
await util.promisify((cb) { ...; t.end() }