Skip to content

node:test should be able to detect a plan has not been met #56758

Description

@jsumners

This issue stems from the conversation at fastify/fastify#5957 (review)

Given a test like:

test('a thing', (t) => {
  t.plan(2)

  doRequest('request 1', (error) => {
    t.assert.ifError(error)
  })

  doRequest('request 2', (error) => {
    t.assert.ifError(error)
  })
})

The test should wait until the defined timeout for both asserts to resolve. If only one resolves within the time limit, the test should fail due to the plan not being completed. From the linked conversation, this is not happening.

Activity

  1. pmarchini commented on Jan 25, 2025

    @pmarchini
    Member

    If nobody beats me to it, I'll try to check it out ASAP

  2. pmarchini commented on Jan 25, 2025

    @pmarchini
    Member

    @jsumners, would you be able to provide a minimum repro? I'd then add it directly to our tests

  3. cjihrig commented on Jan 25, 2025

    @cjihrig
    Contributor

    @pmarchini go for it. I think the only tricky part is:

    The test should wait until the defined timeout for both asserts to resolve

    Tests do not have a default timeout (we received push back over having a default test timeout when originally creating the test runner), so unmet plans would hang indefinitely when they currently do not. The simplest way to implement it without breaking anyone might be by adding a timeout or wait option to t.plan().

    Some other implementation notes (at least how I see it in my head):

    • There are three places where plan.actual++; is performed. This used to be a single place. We should create an internal count() function that does that.
    • The this.plan?.check(); line should become an if (this.plan !== null) so that we only pay the cost if someone is using a test plan.
    • Inside that if statement, we would want to do something like await SafePromiseRace([xxx, stopPromise]);, where stopPromise already exists in the code, and xxx is some Promise related to the test plan (possibly returned from check()).
  4. jsumners commented on Jan 26, 2025

    @jsumners
    ContributorAuthor

    @jsumners, would you be able to provide a minimum repro? I'd then add it directly to our tests

    'use strict'
    
    const test = require('node:test')
    const assert = require('node:assert')
    const http = require('node:http')
    
    const server = http.createServer((req, res) => {
      res.writeHead(200, { 'content-type': 'application/json' })
      res.end(JSON.stringify({ data: 'ok' }))
    })
    
    server.listen(0, '127.0.0.1', (listenError) => {
      assert.ifError(listenError)
    
      test.after(() => {
        server.close()
      })
    
      test('with a plan', t => {
        t.plan(2)
    
        doRequest(payload => {
          t.assert.deepStrictEqual(payload, { data: 'ok' })
        })
    
        doRequest(payload => {
          t.assert.deepStrictEqual(payload, { data: 'ok' })
        })
      })
    
      test('should pass', async t => {
        t.plan(2)
    
        await new Promise(resolve => {
          doRequest(payload => {
            t.assert.deepStrictEqual(payload, { data: 'ok' })
            resolve()
          })
        })
    
        await new Promise(resolve => {
          doRequest(payload => {
            t.assert.deepStrictEqual(payload, { data: 'ok' })
            resolve()
          })
        })
    
      })
    })
    
    function doRequest(cb) {
      const req = http.request(
        {
          host: '127.0.0.1', // server.address().address,
          port: server.address().port
        },
        res => {
          let data = ''
          res.on('data', d => { data += d.toString() })
          res.on('end', () => cb(JSON.parse(data)))
        }
      )
    
      req.end()
    }
  5. jsumners commented on Jan 26, 2025

    @jsumners
    ContributorAuthor

    The simplest way to implement it without breaking anyone might be by adding a timeout or wait option to t.plan().

    I hope we can define a default value, say 30_000, for this new plan option. Having to supply it for every plan will be a very poor developer experience.

  6. cjihrig commented on Jan 26, 2025

    @cjihrig
    Contributor

    Having to supply it for every plan will be a very poor developer experience.

    It doesn't seem like it's a very common option that needs to be on by default? My understanding is that Fastify has been using this feature for a while and this is the first time it has come up.

    My two cents is that even if it does get a default value of 30_000 or whatever, it should first land being disabled and then be enabled in a semver major bump to avoid breaking any existing code. I'll leave it up to the actual users of plan() to decide on a good default value.

  7. jsumners commented on Jan 26, 2025

    @jsumners
    ContributorAuthor

    Most of Fastify's tests that rely on a plan are synchronous and thus do not encounter this issue. It was very surprising to find that the plan is synchronous only.

  8. cjihrig commented on Mar 8, 2025

    @cjihrig
    Contributor

    @pmarchini should this be closed by #56765?

  9. pmarchini commented on Mar 8, 2025

    @pmarchini
    Member

    @cjihrig, yes! I'm closing the issue! Thanks for pointing it out!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions