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

feat: add advanceTimers option #907

Merged
merged 4 commits into from Apr 11, 2022
Merged

feat: add advanceTimers option #907

merged 4 commits into from Apr 11, 2022

Conversation

CreativeTechGuy
Copy link
Contributor

What:

Implement an advanceTimers option from #585 and the second half of this comment

Why:

The internal delays in the library don't play nice with fake timers in testing frameworks

How:

I'm not really sure how to answer this question. With code? haha

Checklist:

  • Documentation
  • Tests
  • Ready to be merged

@codesandbox-ci
Copy link

codesandbox-ci bot commented Apr 5, 2022

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit c2375eb:

Sandbox Source
userEvent-dom Configuration
userEvent-react Configuration

src/options.ts Outdated
*
* @example jest.advanceTimersByTime
*/
advanceTimers?: (delay: number) => void
Copy link
Member

Choose a reason for hiding this comment

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

I think this should return Promise<void>. The resulting await on this enables the user to let third-party code run after the timers were advanced and before we continue interacting. Otherwise asynchronous code during the timeout might not be executed until after the next interaction which might result in hard to debug issues.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The place where this advanceTimers function would be used is already inside of a promise and resolving the promise is already from a setTimeout. Can you give an example of some code which would need advanceTimers to return a promise?

Copy link
Member

Choose a reason for hiding this comment

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

function wait(t: number) {
    return new Promise<void>(r => setTimeout(r, t))
}
function waitVoidAdvance(t: number, advance: (t: number) => void) {
    return new Promise<void>(resolve => {
        setTimeout(() => resolve(), t)
        advance(t)
    })
}
function waitAwaitAdvance(t: number, advance: (t: number) => Promise<unknown> | unknown) {
    return Promise.all([
        new Promise<void>(resolve => setTimeout(() => resolve(), t)),
        advance(t)
    ])
}

async function impl(
    log: string[],
    waitImpl: (t: number) => Promise<unknown> | unknown
) {
    // event handler
    void (async () => {
        log.push('event')
        await wait(0)
        log.push('then')
        void wait(20).then(() => {
            log.push('after 20')
        })
        await wait(0)
        log.push('next')
        await wait(0)
        log.push('x')
        await wait(0)
        log.push('y')
        await wait(0)
        log.push('z')
        await wait(100)
        log.push('delayed')
    })()

    // delay
    await waitImpl(50)
    log.push('continue')
}

describe.each([false, true])('usePromise: %s', (useAwaitAdvance) => {
    test.each([false, true])('fakeTimers: %s', async (useFakeTimers) => {
        const log: string[] = []
    
        if (useFakeTimers) {
            jest.useFakeTimers()
        }
    
        await impl(
            log,
            useAwaitAdvance
                ? (t) => waitAwaitAdvance(
                    t,
                    useFakeTimers
                        ? async (u: number) => {
                            do {
                                jest.advanceTimersByTime(Math.min(u, 1))
                                for(let before;;) {
                                    before = jest.getTimerCount()
                                    await new Promise<void>(r => r())
                                    if (jest.getTimerCount() === before) {
                                        break
                                    }
                                    jest.advanceTimersByTime(0)
                                }
                            } while(--u > 0)
                        }
                        : () => Promise.resolve()
                )
                : (t) => waitVoidAdvance(
                    t,
                    useFakeTimers
                        ? jest.advanceTimersByTime
                        : () => undefined
                ),
        )
 
        jest.useRealTimers()
        await wait(10)

        expect(log).toEqual([
            'event',
            'then',
            'next',
            'x',
            'y',
            'z',
            'after 20',
            'continue',
        ])
    })

})

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Have you ever seen code like that in a real project? That's gnarly!

In the wait function, the promise that advanceTimers returns is not awaited though (nor would it make sense to be). Which is confusing to a user who passes a promise since if they pass:

async (delay: number) => {
    jest.advanceTimersByTime(delay);
   await new Promise((resolve) => /* do some additional work */);
}

It would appear as if the timers would wait additional time for that work to be completed before continuing. But because the advanceTimersByTime call happens first, then it wouldn't wait and the rest of the test would continue while the promise was still pending.

I think it'd be best to avoid a footgun like this. In the event that someone does want to write a crazy function here, and wants to use await, they can always do:

(delay: number) => {
    (async () => {
        /* do your crazy async work */
    })();
}

Which has the benefit of being clear that the caller (this library) doesn't care about your promise.

Copy link
Member

Choose a reason for hiding this comment

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

The code above just demonstrates the problem in a reproducible manner.
In real-world scenarios this usually isn't that obvious, the pushes to the event loop can be implemented in nested/imported modules and/or can involve event-driven APIs.

(delay: number) => {
   (async () => {
       /* do your crazy async work */
   })();
}

^ This would not allow the asynchronous code after any await to run before we continue with the next interaction that is supposed to happen "later".
The problem is that fake timers create a situation that normally doesn't exist: Microtasks being queued and not executed before picking the next "macrotask". This is because the next "macrotask" is actually queued synchronously while the microtask still is queued through the event loop.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Got it. I don't understand why anyone would ever do this. But sure, I can make it support promises too if someone wants to do this.

src/utils/misc/wait.ts Outdated Show resolved Hide resolved
@codecov
Copy link

codecov bot commented Apr 5, 2022

Codecov Report

Merging #907 (c2375eb) into main (2b9d00f) will not change coverage.
The diff coverage is 100.00%.

@@            Coverage Diff            @@
##              main      #907   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           85        85           
  Lines         1778      1782    +4     
  Branches       641       641           
=========================================
+ Hits          1778      1782    +4     
Impacted Files Coverage Δ
src/keyboard/keyboardAction.ts 100.00% <100.00%> (ø)
src/options.ts 100.00% <100.00%> (ø)
src/pointer/pointerAction.ts 100.00% <100.00%> (ø)
src/utils/misc/wait.ts 100.00% <100.00%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 2b9d00f...c2375eb. Read the comment docs.

@ph-fritsche ph-fritsche changed the title advance timers option feat: add advanceTimers option Apr 7, 2022
@CreativeTechGuy
Copy link
Contributor Author

@ph-fritsche let me know if there are any other changes you'd like to see. I think this is ready to go though! :)

Copy link
Member

@ph-fritsche ph-fritsche left a comment

Choose a reason for hiding this comment

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

LGTM. Thanks for the contribution.
Just giving everyone a chance to weigh in on this here or in the docs repo.

@stokesman
Copy link

This looks like a nice addition 👏 . I wonder if the name shouldn't be more general. I can't think of another use case besides advancing timers but who knows. I think calling it advance would be better. Maybe even a callback-sounding name would be worth a look: onAdvance onDelay onNext.

@CreativeTechGuy
Copy link
Contributor Author

@stokesman I'd totally agree with you if there were ideas on how else it could be used. But I'd rather not rename something to be more ambiguous (and possibly confusing) because of some hypothetical future that may not exist. 😄

If you can think of other places in the library where a similar, abstract, version of this pattern could be used, let's do it!

@ph-fritsche ph-fritsche linked an issue Apr 10, 2022 that may be closed by this pull request
@ph-fritsche
Copy link
Member

@all-contributors add @CreativeTechGuy code

@allcontributors
Copy link
Contributor

@ph-fritsche

I've put up a pull request to add @CreativeTechGuy! 🎉

@ph-fritsche ph-fritsche merged commit 627a5cf into testing-library:main Apr 11, 2022
@github-actions
Copy link

🎉 This PR is included in version 14.1.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

feat: advanceTimers callback
3 participants