Repository navigation
node:test APIs returning Promises clashes with no-floating-promises lint rule #51292
Description
Activity
- addedtest_runnerIssues and PRs related to the test runner subsystem.Issues and PRs related to the test runner subsystem.
on Dec 27, 2023 - changed the title
[-]node:test APIs returning Promises classes with no-floating-promises lint rule[/-][+]node:test APIs returning Promises clashes with no-floating-promises lint rule[/+]on Dec 27, 2023 /cc @nodejs/test_runner
I've never seen a test framework where
itreturns anything butvoid/undefined; this feels like an oversight.That said, an additional oversight would be that the promise currently returned from
itneeds to actually be handled by the test runner.Reacted by Moshe Atlow, Fernando Pasik, Fabian Meyer, Spencer Tuft, Kris Kaczor and Kræn HansenReacted by Fernando PasikI've personally found it's kinda confusing because there's the top level promises which you shouldn't await and then there's the inner promises you should await. But there's the global functions and the inner functions passes into your test closure - all of the same names.
Also sometimes I wanted to await the promise within a test closure - sometimes I didn't.
I had to try combinations of things to figure out exactly what to do and why certain things didn't work.
Is there anything speaking against just recommending (in examples in the docs) to
awaitthese promises, given we have top-level await? Since the test suite functions can be async (which is a useful feature because it means you can read fixtures from files etc) it seems logical to me thattest()/it()should beawaitedI think the no-floating-promises rule is problematic and sincerely limit the capability of frameworks.
The problem stem from the fact that promises could either be a request/response primitive, or a fire-and-forget one. The rule does not allow the maintainer/author of the library to indicate the distinction: there are certain promises that must be awaited, and others that can be completely avoided.
The no-floating—promises rule also apply to Thenables (which are not promise), which caused me various headaches in the past.
You can read the full explanation of all of this at typescript-eslint/typescript-eslint#2640.
What is missing in the rule is to allow maintainers to comment functions and let them bypass the rulezGiven the response in the linked issue, it does not seem there is any kind of will to have this sorted. Therefore, my recommendation is to stop using this rule, it prevents frameworks from using idiomatic and better DX.
Last but not least, I think the ship has sailed from changing this, because:
- it solves a user need elegantly
- changing it would be a semver-major change
I’m happy to talk to anybody on the tslint team about this.
Reacted by Warren HaldermanThanks for the detailed response @mcollina!
it solves a user need elegantly
Clarifying: what is that user need, and how often is it come up?
changing it would be a semver-major change
Clarifying: is that a blocker?
happy to talk
I would love to talk to you about this 😄. Will reach out to see what works best.
recommendation is to stop using this rule
Since you're posting a recommendation I feel obliged to explain why this rule has stayed so rigid despite its wide use 🙂:
there are certain promises that must be awaited, and others that can be completely avoided.
allow maintainers to comment functions and let them bypass the ruleThat's a problem that we've continuously bumped against in trying to make the rule palatable. The vast, vast majority of the time (>99%), Promises cannot be completely avoided - regardless of what the framework author says. If the framework has a bug then crashes in the "floating" Promise will be ignored by the calling code. It's almost always an indication of non-idiomatic code to see floating Promises.
Not saying your counterpoints aren't valid, but: there's a reason why this rule is so often used and recommended. So it'd be good for us to figure out if there's a good path forward!
Reacted by Kræn Hansenthere are certain promises that must be awaited, and others that can be completely avoided.
To be clear: the rule does not prevent usages of fire-and-forget promises.
By default it promotes a style of explicitly annotating these kinds of promises (void promise;). Such a style allows a developer to signal their intent - "I am not awaiting this promise on purpose" - unlike the alternative (promise;) where the intent is ambiguous to future readers.The rule does not allow the maintainer/author of the library to indicate the distinction
My question as a user of any framework would be "if I'm not supposed to await this promise - why did you return one to me?".
If the response is "you need to await it sometimes, RTFM" - things are no longer statically clear and we've added nuance to the API usage. Nuance is not necessarily an issue!
Instead the issue is that neither JS nor TS has a way to describe a "fire and forget" promise - so it's really hard to create generic static analysis for this case. This is doubly true when frameworks don't have hard-and-fast rules for what is fire-and-forget and what is not.
You're presenting this problem very "black and white" however in reality the problem is quite "grey".
As Josh mentioned - from our user's experiences and from our own experiences:
- the vast majority of promises should be awaited
- a small percentage that should be not awaited, but should be
.catched - a tiny fraction of promises can be "forgotten" (not awaited or caught).
From my personal experience it's easy to drop a lot of cases into that last bucket accidentally (by just forgetting an await/catch) or on purpose (by misunderstanding the usecase).
For example I upgraded my company's codebase from node 14 to node 16+ - which turned on the (great) flag--unhandled-rejections=strictbut meant I had to cleanup hundreds of such bad cases.The lint rule is great at flagging the first two cases and has a mechanism to explicitly annotate the 3rd.
From a user's (and framework author's) perspective the missing piece is "automated" skipping of the 3rd case as opposed to manual. Personally I prefer the explicit opt-out, but I do understand why people would like an implicit one.
Reacted by Steven Humphrey, ExE Boss, Josh Ghoulberg 👻 and Vas SudanaguntaAll that being said - we're open to an option.
Our response to feature requests in our repo is often terse - as you would understand as a maintainer of node responding deeply to issues takes time and as volunteers it's not always possible for us to dump all the necessary context when rejecting a request.
We're open but we're also very cautious about it. An option to ignore specific cases opens up a footgun in the rule - it opens up a big vector in a user's codebase.
If a framework has a truly "fire and forget" promise then there's no bug vector at all and allowing configuration to ignore it is good. Though one could argue (as mentioned above) why the framework even returns a promise if its never meant to be awaited - that is separate to the rule.
If the framework has a promise that is "sometimes F&F" and "sometimes await/catch" then always ignoring it is bad and is a footgun. False negatives are an insideous thing as they are invisible. We always prefer to create a small percentage of visible false positives rather than the same number of invisible false negatives.
FPs can be seen and acknowledged - even if they are annoying. FNs cannot be known until they cause a bug.
FNs also harm user trust in linting - a user thinks "It was not caught by the rule and caused a bug - what other cases were missed?". This has a negative impact on the entire ecosystem!With a nuanced rule like
no-floating-promise- I hope you can see why a false positive is generally better than a false negative.
Again all that being said - we're open to it, but we definitely want to be sure it's done for the right reasons and in a way that is less likely to cause false negatives.
I’m happy to talk to anybody on the tslint team about this
Nitpick. TSLint is dead and has been for over 5 years.
We are the typescript-eslint team -- and we are happy to talk to you at length 😄.For reference - calling us the tslint team is like if we were to call you guys the io.js team - in a round-about way it's "correct", but it's far from the preferred name.
For reference - calling us the tslint team is like if we were to call you guys the io.js team - in a round-about way it's "correct", but it's far from the preferred name.
I'm sorry about this, I didn't keep track!
Fun fact: the Node.js TSC email istsc@iojs.org, so calling us theio.jsteam might not be incorrect either!
From my personal experience it's easy to drop a lot of cases into that last bucket accidentally (by just forgetting an await/catch) or on purpose (by misunderstanding the usecase).
For example I upgraded my company's codebase from node 14 to node 16+ - which turned on the (great) flag --unhandled-rejections=strict but meant I had to cleanup hundreds of such bad cases.The lint rule is great at flagging the first two cases and has a mechanism to explicitly annotate the 3rd.
What's the mechanism? Can we fix it in DefinitelyTyped (I'm a reviewer there) so everybody has a good experience? What's the point of changing the API?
@JoshuaKGoldberg pointed me to typescript-eslint/typescript-eslint#7008. I can't wait for this to land, so at least we will have a way to remove friction from developers. I hope this is also enabled by default by adding some kind of additional symbol/property so that frameworks can opt-in and solve this problem for their users.
I want to stress that the rule is black-and-white and put a drain on maintainers. I've been asked these questions a gazillion times and replied: "no, it's ok not to await those promises; we are handling it within the Framework."
Why such a convoluted APIs are needed? Because many Node.js APIs are not promise-friendly (starting from CommonJS). Specifically, there are complex issues when using promises with EventEmitter/Node.js Streams. Complex APIs with optional promises are needed to keep a nice DX for everybody, minus those who enable this rule (or use the default settings of typescript-eslint). For them, there is a lot of
voidto add everywhere, especially when using thenables/fluent APIs.What's needed is an escape-hatch to allow maintainers to tell "it's ok to not await this promise/thenable", and turn that on by default.
The only alternative API design I could see for this issue is the following:
import { test, describe } from "node:test"; describe('something', async () => { await (test('aaa', async () => { // ... }).done) })
I consider this a worse DX.
The alternative (which I pursued elsewhere) is not type the function as returning a Promise in the types. I think that's an option too, but it's a worse solution than having a nice escape hatch.
There is a (documented) subtlety in Node.js API, which I believe is the source of this issue:
When you call
testinside adescribe, you are not supposed to await the returned promise (it's immediately resolved withundefined).It's tricky because I don't think we can reflect that in the typings (make the function return a
Promiseorvoiddepending on the context)?Reacted by Moshe Atlow, Matteo Collina and ExE BossThis requires
await:import { test } from 'node:test' import {setTimeout} from 'node:timers/promises' test('wrap', async t => { await test('some test', async t => { console.log('aa') await setTimeout(1000) }) console.log('bb') await test('some test 2', async t => { console.log('cc') await setTimeout(1000) }) })
Output:
aa bb cc ▶ wrap ✔ some test (1003.013209ms) ✔ some test 2 (1002.693959ms) ▶ wrap (2011.603333ms) ℹ tests 3 ℹ suites 0 ℹ pass 3 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 2016.177875Reacted by Michaël Zasso, Josh Ghoulberg 👻 and ExE BossThe lint rule is great at flagging the first two cases and has a mechanism to explicitly annotate the 3rd.
What's the mechanism?The mechanism is on the user side - the
voidoperator to signal "I am intentionally not awaiting this promise". This works fine for a lot of cases because "in general" the usecase of a promise you shouldn't await/catch ever is rarer.That being said though that's the general case.
It's black-and-white from your perspective because your APIs are black-and-white.
If users are using a framework (like fastify or RTK) which is built on these "ignorable promises" then for them the rare case is the general case. That's what your users feel and it's why this is such a big pain point for you.
Note: not suggesting the style is bad -- just that it's not the "common case".In contrast to the fastify case - the
node:testcase isn't black-and-white! There are times when you do want and need to awaittest, times you might want to, and times you don't want to. That's a grey case!
We generally build from the common case and then consider options for the less common cases as we receive user feedback.
Though there hasn't been too much feedback from users wanting an allowlist or similar - the intersection between (fastify, rtk, node:test) and (typescript-eslint, type-aware linting, no-floating-promises) is relatively small all things considered.I say "small" but that may still be hundreds of thousands or a million users - which is less than 1/10th of our overall userbase. These scales are what can skew our perceptions - often in the wrong way. It's hard for us to gauge things - we're getting better about leaving things open to better gather and evaluate community feedback - in the past we were quicker to close things which further skewed things in the wrong ways by blocking additional feedback.
Reacted by Josh Ghoulberg 👻Reacted by Matteo Collina22 remaining items
- added a commit that references this issue
on Jan 31, 2025 - added a commit that references this issue
on Jan 31, 2025 Hi 👋
The changes made in support of this issue broke our test framework for PostCSS plugins.
An artificial example that illustrates how we used to write code using
node:test:import test from 'node:test'; import assert from 'node:assert'; await test('outer', async (t1) => { let a = 1; let b = 1; // Run a first sub test await t1.test('inner 1', async (t2) => { await new Promise((resolve) => setTimeout(resolve, 1000)); assert.equal(a, b); t2.after(async () => { await new Promise((resolve) => { b = 2; resolve(); }); }); }); // Do some async workload in between sub tests await new Promise((resolve) => { b = 2; resolve(); }); // Run a second sub test await t1.test('inner 2', async () => { // Run some extra asserts assert.equal(b, 2); }); });
This always worked fine but fails with node 24:
✖ failing tests: test at foo.mjs:9:11 ✖ inner 1 (1001.453041ms) AssertionError [ERR_ASSERTION]: 1 == 2 at TestContext.<anonymous> (file:///...) at async Test.run (node:internal/test_runner/test:1069:7) { generatedMessage: true, code: 'ERR_ASSERTION', actual: 1, expected: 2, operator: '==' }
I suggest reverting the changes : #58282
And then to re-visit the use case described here and try to find a better way forwards.Reacted by Warren HaldermanFor anyone hitting this, this is the flat eslint config I needed to add:
{ rules: { "@typescript-eslint/no-floating-promises": [ "error", { allowForKnownSafeCalls: [ { from: "package", name: ["suite", "test"], package: "node:test" }, ], }, ], }, },Reacted by JonasDoe, Bohdan Lyzanets, Bart Louwers, Evert Pot, Avinash Dwarapu, Huw McNamara, Fran Herrero, Shukhrat Mukimov, Eyal Cherevatsky, loczek and 2 moreReacted by Vladislav Puzyrev and Théo LUDWIG- added 9 commits that reference this issue
on Nov 16, 2025 - added a commit that references this issue
on Nov 25, 2025 - added a commit that references this issue
on Feb 8, 2026
Version
21.5.0
Platform
n/a
Subsystem
node:test
What steps will reproduce the bug?
describe,it, ortestfunction imported fromnode:test@typescript-eslint/no-floating-promisesenabledI put a standalone repro on https://github.com/JoshuaKGoldberg/repros/tree/node-test-no-floating-promises. From its README.md:
How often does it reproduce? Is there a required condition?
100% of the time. The most common workarounds are to either disable the rule in test files or use the rule's
ignoreVoidoption.What is the expected behavior? Why is that the expected behavior?
@typescript-eslint/no-floating-promisesis enabled in theplugin:@typescript-eslint/recommended-type-checkedconfig that is recommended to users as part of typed linting. I was expecting that the built-in functions for Node.js wouldn't directly trigger complaints in the built-in presets for typescript-eslint.What do you see instead?
Lint failures out of the box.
As for what should be done: I'm not sure 🤔. If it were just up to me, I'd say switching the functions to have void returns ... or failing that, having their
@types/nodetypes changed to returnvoidto indicate the returned values shouldn't be used.How intentional is it that the functions' returned values are made available to users? I can see an argument that returning a Promise that rejects or resolves is a potentially useful feature for the functions...
Additional information
I wasn't sure whether to file this as an issue or discussion. It feels like an issue to me because common practice for many TypeScript projects has been to enable this rule, or previously TSLint's
no-floating-promises. But I'm not a Node.js expert - apologies if I'm off base 🙂. If the rule is wrong to complain for some general-to-JavaScript reason, then I'd be happy to take lead on a general bug in typescript-eslint.We first received this as a bug report in typescript-eslint/typescript-eslint#5231. We wontfixed that as it's not a good idea for a linter to add framework-specific behaviors. We're instead discussing adding an
allowoption to@typescript-eslint/no-floating-promises. But, that wouldn't be ideal for users IMO because then everyone would have to add an explicitallowto their ESLint configs or use a shared config/preset.