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 renderHook #991

Merged
merged 9 commits into from Apr 15, 2022
Merged

Conversation

eps1lon
Copy link
Member

@eps1lon eps1lon commented Nov 8, 2021

What:

Imlement minimal renderHook from @testing-library/react-hooks

Why:

@testing-library/react-hooks is not compatible with React 18 and it's questionable if certain APIs will make sense in React 18.
This ensures that the supported version of React that is used with your Testing Library APIs is always consistent.

error testing: We don't have this solved in @testing-library/react. It should be solved in the renderer testing library.
suspense: Can be solved with wrapper
waitForNextUpdate: Use waitFor instead. When an update is triggered and what is part of an update is not always clear and in 99% of the cases (even if hooks relying on data e.g. apollo-client) not relevant. See eps1lon/apollo-client#1

How:

Implement minimal renderHook with just rerender, rerender and unmount. Assertions are made on the committed result not the render result.
Renders are not something to test for since they're ultimately not detereministic depending on the component implementation. However, we don't want to test component implementation details.

For more granular testing one could always create a custom test component around the hook under test and pass it to our render. This has always been the recommended approach anyway. But it requires more writing and instead of insisting that this is bad practice we meet devs halfway, provide a render wrapper around creating a test component but then reduce the API of that render wrapper.

If your goal was to count renders then React.Profiler is the better approach anyway.

Checklist:

@eps1lon eps1lon added the enhancement New feature or request label Nov 8, 2021
@codesandbox-ci
Copy link

codesandbox-ci bot commented Nov 8, 2021

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 144b485:

Sandbox Source
React Configuration
react-testing-library-examples Configuration

@codecov
Copy link

codecov bot commented Nov 8, 2021

Codecov Report

Merging #991 (144b485) into main (2c451b3) will not change coverage.
The diff coverage is 100.00%.

@@            Coverage Diff            @@
##              main      #991   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            4         4           
  Lines          169       181   +12     
  Branches        33        34    +1     
=========================================
+ Hits           169       181   +12     
Flag Coverage Δ
experimental 100.00% <100.00%> (ø)
latest 100.00% <100.00%> (ø)
next 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Impacted Files Coverage Δ
src/pure.js 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 2c451b3...144b485. Read the comment docs.

@kentcdodds
Copy link
Member

I'm in support of this. What do you think @mpeyper?

@mpeyper
Copy link
Member

mpeyper commented Nov 10, 2021

I have some thoughts on this, but not enough time at the moment to write it all up (starting work now). I'll come back to it tonight when the kids go to bed (about 12 hours from now).

I'll tag @joshuaellis for his thoughts too.

In the interim:

@testing-library/react-hooks is not compatible with React 18 and it's questionable if certain APIs will make sense in React 18.

We are working on React 18 support, although admittedly it has not progressed much recently. I'm curious which APIs won't make sense in React 18, or are you are referring to React APIs (e.g. async act) rather than our API?

@eps1lon
Copy link
Member Author

eps1lon commented Nov 10, 2021

I'm curious which APIs won't make sense in React 18

waitForNextUpdate will have to re-implement all of React's heuristics for determining what is going to be batched, which type of effects are being worked on when etc. I don't see its value in our testing philosophy. It may make sense in Enzyme-style testing though.

@kentcdodds
Copy link
Member

Yeah, I'm definitely not a fan of waitForNextUpdate. That feels like too much of an implementation detail to me 😬

@joshuaellis
Copy link
Member

I think one of my major hesitations of this merge, is the loss for native support. As (and correct me if I'm wrong) I believe RTL supports Dom only?

So we would be somewhat doing the community a disservice by removing a valuable feature we know people actively use.

And on the topic of targets, would we be able to (and would we need to) support SSR rendering of the hooks, this was another major feature requested that we have since implemented.

Whilst I understand that the API may not be compatible and some elements of the library go against the philosophy of TL would it perhaps not be better for the community if we make the changes in the actual library whilst maintaining existing functionality?

I'm confused over the requirement to merge the two libraries or in this case duplicate functionality, is it that you think we at RHTL won't want to make these changes to become more uniform to the philosophy?

@eps1lon
Copy link
Member Author

eps1lon commented Nov 11, 2021

So we would be somewhat doing the community a disservice by removing a valuable feature we know people actively use.

We're not removing any feature that we think is valueable.

As to actively being used: I looked at react-spectrum and apollo-client. Neither repo needs anything beyond renderHook() and renderHook().rerender. All the waitForNextUpdate I've seen is testing implementation details that a package under the Testing Library umbrealla should not enable. I'm not saying that you should never test it that way. But this should happen in another package that doesn't advertise itself as "test like your software is used". It's more in line with Enzyme.

And on the topic of targets, would we be able to (and would we need to) support SSR rendering of the hooks, this was another major feature requested that we have since implemented.

Like error boundaries, we can consider adding this to the renderer library. But I don't see why we need SSR for hooks but not components. Especially since we need to find a pattern first how to test SSR(+hydration) properly. All the examples I've seen just use SSR API in a browser which is rarely how your hook/component is used.

The same applies to errors. This can be incredibly tricky to do right.

I'm confused over the requirement to merge the two libraries or in this case duplicate functionality, is it that you think we at RHTL won't want to make these changes to become more uniform to the philosophy?

It just doesn't fit in the testing-library org. We have a library for different renderers. But then we also have a library for a specific implementation detail that works for multiple renderers?

Regarding duplication: The goal is to reduce duplication. Right now RHTL needs to copy some testing utility from RTL and replay even more React implementation details if they want to be compatible with React 18.

There's just too much inconsistent behavior enabled by the current setup.

In the end, having renderHook available in @testing-library/react removes one setup step and ensures the APIs are consistent technically and mentality wise.

@mpeyper
Copy link
Member

mpeyper commented Nov 11, 2021

Firstly, renderHook (or testHook as it was then) was originally part of RTL, but it was decided it didn't belong here and we took over your API and it was removed. I'm curious what triggered this proposal to bring it back in. I get the feeling you disagree with the direction I (I usually say "we" in situations like this, but in this case I wont lump @joshuaellis in with all of the decision I've made over the years) have taken it?

Secondly, I gather from your comments above that you're proposing this renderHook will replace the need for @testing-library/react-hooks all together and not just be a basic version for the majority use case and entry point into out library for more advanced cases? Please correct me if I'm wrong on this.

I'll approach this with the intent I hope it was raised from, which is providing the best tools for the users and try to address some of the points raised above and highlight any "lessons learned" from my time at the helm.


waitForNextUpdate will have to re-implement all of React's heuristics for determining what is going to be batched, which type of effects are being worked on when etc. I don't see its value in our testing philosophy. It may make sense in Enzyme-style testing though.

Yeah, I'm definitely not a fan of waitForNextUpdate. That feels like too much of an implementation detail to me 😬

I think you are giving waitForNextUpdate more credit than it deserves. My mental model for it is essentially a thin layer over waitFor that looks something like:

const currentResult = result.current;
await waitFor(() => {
  expect(result.current).not.toBe(currentResult);
});

For transparency, the implementation is not quite this, but due to the nature of hooks, it does function this way for the majority of custom hooks (they frequently return new object, array or function references).

Perhaps it's my lack of understanding about what is to come in React 18, but I don't see why this would need to reimplement any batching logic or is Enzyme-esque in nature. In fact, the only compatibility issue we've had with react 18 so far seems to be that async act is not a thing anymore (or is a thing but is changing somehow), which is not something I think we could have reasonably planned on.

While I can see the concern about implementation details, I do feel like the intent of waitForNextUpdate is more like "I don't care how or when or what made it update, just wait for it to happen". In fact, it cares very little about any of the hooks implementation to do what it does. I do understand that the bigger concern is how people use it rather than specifically how it is implemented though.

I will concede that I personally find waitForValueToChange and waitFor to be more useful than waitForNextUpdate as they generally show better intent in the test code and less likely to fail when changes to the hook code are made that introduce new state stages (although I don't think this actually happens very often for most custom hooks). I have toyed with the idea of deprecating it and removing it, but the maintenance overhead is low and it caters to many peoples basic need, which is "I want to wait for the promise in my useEffect to resolve" (remember, the promise is often not returned or exposed in any meaningful way for the test to use without waiting for the component state to be updated). If it was removed, would you still feel this way?

The other main use case was for hooks using suspense. Again, they could use waitFor or waitForValueToChange instead but it has been convenient for them to use waitForNextUpdate to just wait until the first full render has completed. Suspense is not something that RTL handles out of the box (as far as I'm aware), so this is a case where users will need to handle this themselves in their test code with a custom wrapper.

Regarding duplication: The goal is to reduce duplication. Right now RHTL needs to copy some testing utility from RTL and replay even more React implementation details if they want to be compatible with React 18.

One thing about our async utils that you wont get from using your waitFor (and the reason we don't use dom-testing-library as our base utility) is it wont perform any checks if the result changes between interval checks because there is no mutation to the DOM (if my understanding of why the MutationObserver is being used in dom-testing-library). In our utils we trigger the checks any time the hook value is updated so that our users don't have to set the timing their interval or timeout settings appropriately to not miss their target state (as I view that as an implementation detail). I'm not sure how you intend to overcome that limitation (if you plan to at all).

So my take is that we haven't duplicated the utilities, but rather been inspired by them to write our own for our use case in the same way react-native-testing-library has written their own version, because the dom-testing-library version does not fair well when, shockingly, there is no DOM involved.

I actually wonder if perhaps there isn't space for a common async utils library that we could all collaborate on that has a more generic "something updated please check between intervals" implementation that dom-testing-library (and all it's offspring) can wrap with MutationObserver and other non-DOM based libraries can wrap with our own implementations.

I think one of my major hesitations of this merge, is the loss for native support.

This is a concern for me too. The truth is that that the vast majority of custom hooks are not tied to react-dom or to the DOM at all.

I feel like unless you can get react-native-testing-library onboard and take on the maintenance burden of renderHook as well, you are hanging a large chunk of users out to dry or forcing them into a DOM setup they don't want (or even potentially introduce subtle bugs for them because their test environment no longer matches their prod environment).

It just doesn't fit in the testing-library org. We have a library for different renderers. But then we also have a library for a specific implementation detail that works for multiple renderers?

I disagree. RTL tests React components for web. RNTL tests React components for native. RHTL tests hooks for React Components. As I said above, hooks are not implicitly for DOM or Native (or whatever) components, especially in the library space were they might be expected to work anywhere.

In fact, we moved to a "BYO renderer" model purely for convenience for our users that already had a perfectly good React renderer (react-dom) installed so they didn't have to install another (react-test-renderer) just for us.

But I don't see why we need SSR for hooks but not components.

This bit confuses me. Your API has an option for hydration which I cant think of any other reason to have except for testing some kind of SSR functionality.

The fact of the matter is that hooks behave differently on the server to how they do in the browser and where there is a difference in behaviour people are going to want to test that it behaves as they expect. If I was writing a hook in my library that I advertised as being SSR compatible I'd want to make sure I could back that claim with a test.

But this should happen in another package that doesn't advertise itself as "test like your software is used"

There's just too much inconsistent behavior enabled by the current setup.

I think it's important to remember that our main target audience is library authors (and I consider common utilities in a project to be library-lite code), so the "software" being used is the custom hooks and not the react app using it. As such, there are inherent inconsistencies both with how people think about their test strategy and in how they use the software because they are different. Unless you are suggesting that only libraries that test user interactions are valid testing-library libraries, in which case I'd point out we were invited to be here, so at least at some point that was not a requirement.

I've gone over this with you before and to a lesser extent with Kent, but for the benefit of this discussion I'll try to reiterate in context:

Hooks are not components and component are not hooks (I hope we can all agree on this). Therefore, to suggest that the utilities you would use to test them must be completely consistent doesn't ring true to me (this might be where we start to disagree). Components are visual, interactive and user facing, while hooks are data focussed and code facing.

In fact, I would go so far as to say that when testing a custom hook directly, the fact that is must be called within a component is an implementation detail we want to protect our users from, just like when you are testing a component directly, the fact that it is calling a hook is an implementation detail you want to protect your users from. After all we want to "test like your software is used" and our "software" is a function, not a component or an app.

For example, there is not a great global object, i.e. the DOM, we can run assertions against so utilities like screen and fireEvent don't make much sense for hooks. Instead the values are generally returned from the hooks and are much more localised to the individual calls to the hook. This is also why we return the async utils from renderHook instead of from the import of the library, so they can be aware of when things change to rerun the checks.

Another interesting example is errors. When a component throws an error the expectation is that it bubbles up to the nearest error boundary. It would be reasonable for you to expect your users to use the wrapper to add an error boundary if they wanted to test an error case. For us this isn't so simple. The expectation is that the error is thrown from the function. This is fine when the hook throws the error directly when rendering, but what if it throws in useEffect? This is only caught in error boundaries. What if an async effect sets some state that throws in the subsequent render? This does go to the error boundary as well if there is one, but if not, the render call doesn't throw - it's already passed that point. waitFor wont reject, it doesn't care about renders. This is why we introduced result.error and made result.current throw if result.error is set. It's not ideal but we added it to avoid users having to know exactly how and when the errors are being thrown in order to catch them effectively in their tests. I've speculated that result.error could be removed by throwing from renderHook/rerender and rejecting the async utils, but the techniques used to catch all the ways hooks can error would likely need to remain.

Basically what I'm saying is that by not supporting hooks as hooks, but just as extensions of components, you either leave a lot for users to implement as a wrapper or have to concern themselves with unnecessary implementation details. At this point I wonder if there is much point in having renderHook over just having a docs page on testing hooks that gives them the basic TestComponent implementation to copy/paste and modify. At least then they are far more aware of the component in the mix.


Phew, that was a lot... I hope I didn't come across too defensive. I must admit, reading the comments above I definitely feel a little attacked as if somehow we're not doing our best to align ourselves to the ethos of testing-library. We've put a lot of energy into trying to ensure we are focussed solely on inputs and outputs of the hooks and to test them as close to regular functions as we can. We're always the first to steer people away from RHTL and towards RTL when we think they are not using it for the right use cases.

I want to be clear though, I'm not completely against the idea of renderHook moving into the respective renderer libraries and, in all honesty, not having the maintenance and support burden any more would be somewhat of a relief.


Now some other thoughts...

I have long thought of intialProps to be a bit of a mistake and we've only kept it around for backwards compatibility. Having the callback accept no props and have variables for them be updated separately to rerender calls is not a huge overhead for the users and is easier for many to understand what is going on (less invisible lines between args and values), e.g.

const { result, rerender } = renderHook((message) => useSomeHook(message), { initialProps: 'hello' });

rerender('world');

vs.

let message = 'hello';
const { result, rerender } = renderHook(() => useSomeHook(message));

message = 'world';
rerender();

The second example is clearer to me that message will change when rerender is called.

I'll also add that I very rarely see rerender and unmount used in the wild. Most tests call renderHook and either use a returned function to trigger a stage change or are using the async utils to wait for a state change to happen before asserting the results.

Similarly, result.all was a mistake. I was on the fence about it, but eventually added it as it provided some pseudo SSR functionality before we had proper SSR support. Now I mostly see people abusing it. I haven't removed it yet because I do think there is an argument that the returned values between renders can be important in the context of hooks as, while they are not observable to users, they are observed by the components consuming them and could cause an error to be thrown if a bad value is returned. In practice though, I think this is rare and I doubt anyone is actually testing for it anyway.

I find your use of useEffect to set result.current interesting and I'm wondering if we should be doing that ourselves now. What is the benefit of that over just setting it in the render function? Is it possible that the final rendered value is not the same as the one that makes it to useEffect?

Finally, users destructuring result.current (i.e. const { result: current } = renderHook(...) or const [value, setValue] = result.current) are, easily, the biggest cause of bug reports and support requests. If we are going to introduce a new renderHook, perhaps it's a good time for some iteration on the API to see if we can't reduce the cognitive overload the ref-like (or actual ref in you case) style seems to cause so many people.


That is all for now. I hope this is all makes some sense from outside my head. I'm happy to clarify or dive deeper on any of it if you want.

@mpeyper
Copy link
Member

mpeyper commented Nov 11, 2021

Oh, I forgot to mention waitForValueToChange which did not seem to be part of your "useful features" analysis. This is similar to waitForNextUpdate but rather than waiting for result.current to change, it selects an arbitrary value. The intended and overwhelmingly majority of uses (to the point that I don't know of any examples not using it this way) is selecting a value from result.current. It is essentially this:

const currentValue = selector();
await waitFor(() => {
  expect(selector()).not.toBe(currentValue);
});

The canonical use case for this is to wait for some loading flag to flip before running assertions:

const { result, waitForValueToChange } = renderHook(() => useSomeRemoteData());

await waitForValueToChange(() => result.current.loading);

expect(result.current.someValue).toBe(...);

This is a convenience utility and serves a similar purpose to your own waitForElementToBeRemoved, but could quite easily be replaced with an education piece and conversion to waitFor based implementations.

Another note about our async utils that differ from the RTL ones is that, as hooks are primarily data based, we handle a few more cases with raw value instead or relying on assertions. In RTL you might do something like

render(<Details />);

await waitFor(() => {
  expect(screen.getByLabelText('Name')). toHaveTextContent('Michael');
});

While in RHTL it would be something more like

const { result, waitFor } = renderHook(() => useDetails());

await waitFor(() => {
  expect(result.current.name).toBe('Michael');
});

However, in the context of an data instead of components, it can often feel more natural and less verbose to write it like this:

const { result, waitFor } = renderHook(() => useDetails());

await waitFor(() => result.current.name === 'Michael'));

In RTL's waitFor this will resolve even if result.current.name was not 'Michael' as it doesn't throw like the assertion would.

This is another case where we have diverged from RTL in order to better tailor the tool in the context of hooks. This is probably also just an education piece instead of functionality you want to carry over, but I know people are using this variation and will be something you need to factor in.

@eps1lon
Copy link
Member Author

eps1lon commented Nov 15, 2021

I do understand that the bigger concern is how people use it rather than specifically how it is implemented though.

Yep, that is my main concern. The secondary concern is that we don't have waitForNextUpdate in @testing-library/react and since hooks are an implementation detail, it doesn't make sense to have it in any @testing-library package. By removing waitForNextUpdate we remove an API that is just not needed under the testing-library paradigm which is generally a good thing.

Put it another way: Why do we need waitForNextUpdate generally and why do we need it for hooks but not components?

Suspense is not something that RTL handles out of the box (as far as I'm aware), so this is a case where users will need to handle this themselves in their test code with a custom wrapper.

There isn't anything to handle for us. Suspense itself is an implementation detail for various concerns. What you should be testing is the behavior the user sees e.g. "1. click a thing 2. fallback shows 3. ??? 4. data shows". That's what you want to test. Not that a hook throws a promise that resolves eventually. Or that your hook renders for too long and results in React showing a fallback.

We don't need any extra API for it because it's as simple as render(<Suspense fallback="loading"><Component /></Suspense>). Everything we could add are convenience wrappers like render(<Component />, fallback: <SomeFallback />).

In our utils we trigger the checks any time the hook value is updated so that our users don't have to set the timing their interval or timeout settings appropriately to not miss their target state (as I view that as an implementation detail)

Whether an intermediate state is painted is not an implementation detail. It is important to check to test for e.g. tearing, flashing etc.

Hooks are not components and component are not hooks (I hope we can all agree on this).

It doesn't matter under the testing-library paradigm. We want to test what the user sees.

Regarding API choices:
The goal here is that people can easily swap the renderHook implementation. I appreciate the input about potential solutions but I would rather do this in a future change. For now I'm mostly concerned with enabling people to test React 18. And the interface and implementation of @testing-library/react-hooks prevents that.

I get the point of a React renderer agnostic library. Though I don't think the "hooks are mostly renderer agnostic" is very accurate. Similar points can be made about components. But we end up arguing about percentages and I don't think that discussion is very helpful

This is another case where we have diverged from RTL in order to better tailor the tool in the context of hooks.

I want to emphasize that I find this to be generally a bad thing since it requires people to learn multiple approaches and shift between them when testing hooks vs components. By simplifying the API people can learn once and then apply that knowledge elsewhere. I want to caution against writing too specialized APIs for every specific testing need just so that it's convenient. Your point can just as well work as a counter argument since people might accidentally flip the patterns since they're not context aware of the thing they test. You have to constantly check whether you're testing a component or hook instead of just using waitFor like you're used to.

@eps1lon
Copy link
Member Author

eps1lon commented Nov 15, 2021

I'm very much interested in tests that couldn't be implemented with this new API. So far I've looked through react-spectrum and apollo-client and repositories work pretty well with this new API. I'd even argue that the tests are more robust (across React 16-18).

@mpeyper
Copy link
Member

mpeyper commented Nov 16, 2021

The secondary concern is that we don't have waitForNextUpdate in @testing-library/react and since hooks are an implementation detail, it doesn't make sense to have it in any @testing-library package.

Just to be clear, "hooks are an implementation detail" for components is exactly why I believe renderHook doesn't belong in this library. I completely agree with you that they are when testing a component but when you are testing a hook in isolation of any specific component, can you seriously tell me with a straight face that they are just an implementation detail?

Of course they're not. So now we have to adjust our thinking to test them appropriately for the characteristics and constraints that they present, some of which are consistent with components and some of which are not. And similarly, they are also very similar to regular functions, but we also can't test them as if they are standard functions, so we have to adjust out thinking and test them appropriately.

Put it another way: Why do we need waitForNextUpdate generally and why do we need it for hooks but not components?

The short version is we probably don't. As described from both sides, in most cases it can be accomplished with waitFor or waitForValueToChange instead and they will generally lead to more robust tests in the long run. It only exists now because:

  1. It was the first async util we had and predates the others
  2. It has been convenient for some use cases (but could be replaced)

I'm not opposed to deprecating waitForNextUpdate in the current version and removing it in a future version if that's the deal breaker here.

There isn't anything to handle for us. Suspense itself is an implementation detail for various concerns. What you should be testing is the behavior the user sees e.g. "1. click a thing 2. fallback shows 3. ??? 4. data shows". That's what you want to test. Not that a hook throws a promise that resolves eventually. Or that your hook renders for too long and results in React showing a fallback.

We don't need any extra API for it because it's as simple as render(). Everything we could add are convenience wrappers like render(, fallback: ).

No, I'm arguing the opposite. For a hook, there is no fallback state or too long or any of that component concern when the hook suspense. The only thing the hook needs to do is wait for the promise to resolve so it can continue rendering.

And I 100% completely agree with you that adding these things doesn't make sense when testing a component because wrapping components is natural usage and makes sense in that context. In a component context, you do care and control what the user sees during while suspending so it makes sense to make that part of of your test, but in the hook context, there is nothing visual so making it part of the test's concerns just adds noise.

Hooks that suspend while loading data is an increasingly common pattern and it's only going to become more common as time goes by. Even now, react-query swr and react-async all have an options to suspend instead of return a loading state and plenty of people are writing their own data fetching hooks or wrapping these libraries with some custom logic to be shared across lots of components in their projects.

Every renderHook test that ends up suspending will have to add a wrapper so they can suspend so the test can then use waitFor to wait for the data to load and they can assert something about it. On the flip side, a hook that does not suspend doesn't care at all if it's rendered inside a suspense boundary or not. So we decided to simplify the usage for the first group by inserting the suspense boundary for them. You'll even find that our fallback for this boundary is null because what it renders while suspending is completely irrelevant in a renderHook test.

My philosophy has been to try to prevent the users of renderHook from having to write any JSX in order to test their function that, other than being a called within one, doesn't have any knowledge of the component wrapping it. If there was another way of wrapping the hook with context providers for useContext, I wouldn't even expose wrapper at all.

Whether an intermediate state is painted is not an implementation detail. It is important to check to test for e.g. tearing, flashing etc.

My point was that when and how the value updates is not something that should leak out of the test and without the ability to run intermediary checks between intervals, we create situations where the timing matters and changes to the hook result in changes to the test which is something I want to avoid my users from doing. This is why our async utils run the checks again whenever the result changes, so they just work when the conditions of waitFor are met, and the hook can be more easily refactored without changing the test.

It doesn't matter under the testing-library paradigm. We want to test what the user sees.

Once again, you're approaching this with you component blinkers on. There is nothing for the user to see when you write a test with renderHook. All there is is data for the component to see. Hooks exist within the render, not around it like the rendered output of a component. If a hook throws or returns an unexpected value or suspends or anything within any specific render, painted or not, the component will get it and have to deal with it.

If the requirement for being a @testing-library library is that it is 100% user facing all the time then you should not add renderHook as it's going against your principles. I'm struggling to reconcile how you can seriously view testing hooks through the same lens as testing components. Hooks are like utility functions for components. Would you approach testing lodash or date-fns the same way you would test a component?

I want to emphasize that I find this to be generally a bad thing since it requires people to learn multiple approaches and shift between them when testing hooks vs components.

Again, I find this whole feature to be against your own principles and beliefs. You want consistency across all the testing libraries, yet you're adding a feature that others are not, for a feature the others don't have. Why? Why is renderHook required in @testing-library/react when isn't in @testing-library/vue or @testing-library/angular or, more pertinently, @testing-library/react-native? Why not just test them within components using render "like the user sees it" if they are just implementation details of a component?

If you truely believe this then don't add renderHook, move react-hooks-testing-library out the the testing-library org, we'll start publishing to NPM under the react-hooks-testing-library package again. start advising people not to use renderHook because it's not "what the user sees" and have a page in your docs about how to wrap their hook in a component.

I mean, the whole reason you are even proposing renderHook here is because you need a different API to test hooks in isolation. It's seems only natural to me that the next logical step is that to assume that there might be other nuances about them that require different APIs to test them appropriately. That doesn't mean there is not going to be a large overlap with what already exists for the majority use case, just that there may be niche use cases that could benefit from it and because there a niche tool for it (renderHook), it is more inclined to want to support them.

For now I'm mostly concerned with enabling people to test React 18. And the interface and implementation of @testing-library/react-hooks prevents that.

This is the bit that really eats at me. What is so wrong with our implementation that it can't work in react 18?

If you look through the messing around we do with multiple renderers, our renderHook looks very similar to yours. We're still rendering a component that calls the the hook and sticks the result into result.current.

Yes, we wrap the test component automatically in a suspense and and error boundary, but that is possibly with render too so shouldn't break anything.

Yes, we resolve a promise when result.current is updated so we can run the waitFor callback in between intervals, but running the callback shouldn't break in react 18.

If we removed waitForNextUpdate and waitForValueToChange then moved the setting of result.current into a useEffect (I assume this is to counteract something to do with concurrent rendering?) then what else is there that is actually different at a fundamental level?

I'm very much interested in tests that couldn't be implemented with this new API. So far I've looked through react-spectrum and apollo-client and repositories work pretty well with this new API. I'd even argue that the tests are more robust (across React 16-18).

Let's look at a scenario described in the react-query docs:

In addition to queries behaving differently in suspense mode, mutations also behave a bit differently. By default, instead of supplying the error variable when a mutation fails, it will be thrown during the next render of the component it's used in and propagate to the nearest error boundary, similar to query errors. If you wish to disable this, you can set the useErrorBoundary option to false. If you wish that errors are not thrown at all, you can set the throwOnError option to false as well!

So the sequence would be this:

  1. Call hook in renderHook
  2. Hook suspends
  3. Promise rejects
  4. Rejected error throws on the next render

Now imagine you want to test that the rejected error is thrown from your hook.

And just to be clear, it's entirely possible you can get a passing test with the simplified API, but what I'm interested in from a renderHook API is how much boilerplate is required, how much of component logic is required to make the hook test run and how the test reads afterwards. Essentially, is easy to understand what the test is trying to test.

In our version, this would be:

import { renderHook } from '@testing-library/react-hooks';
import { useQuery } from '..';

test('should raise error when query fails', async () => {
  const { result, waitFor } = renderHook(() =>
    useQuery(['error-test'], Promise.reject(Error('Oh no!')), { suspense: true })
  );

  await waitFor(() => {
    expect(result.error).toEqual(new Error('Oh no!'));
  });
});

I imagine with the simplified API, this test would look something like:

import { renderHook, waitFor } from '@testing-library/react';
import { useQuery } from '..';

test('should raise error when query fails', async () => {
  let caughtError;

  class ErrorBoundary extends React.Component {
    constructor(props) {
      super(props);
      this.state = { hasError: false };
    }

    static getDerivedStateFromError(error) {
      return { hasError: true };
    }

    componentDidCatch(error, errorInfo) {
      caughtError = error;
    }

    render() {
      if (this.state.hasError) {
        return null;
      }

      return this.props.children;
    }
  }

  const wrapper = ({ children }) => (
    <Suspense fallback={null}>
      <ErrorBoundary>{children}</ErrorBoundary>
    </Suspense>
  );

  renderHook(() => 
    useQuery(['error-test'], Promise.reject(Error('Oh no!')), { suspense: true }),
    { wrapper }
  );

  await waitFor(() => {
    expect(caughtError).toEqual(new Error('Oh no!'));
  });
});

@kentcdodds
Copy link
Member

I'm afraid I don't have time to participate in this discussion (just got a new job), but I wanted to drop a note to say I appreciate you both putting so much tone into finding the solution that's best for the community regardless of any and all sunk costs. Whatever the best path forward is important (even if it means I have to add a note about changes in TestingJavaScript.com 😅). Thank you both for all your efforts here. It makes an enormous positive impact on the community and I trust you both to come to a good decision 👍

@mpeyper
Copy link
Member

mpeyper commented Nov 16, 2021

Thanks @kentcdodds and good luck with the new gig. I legitimately look forward to evaluating remix when it's released after following its development for so long.

Honestly, I'm not sure we will come to a consensus here. Our views are just too different and we're both just reiterating our same points to each other, worded slightly differently hoping we can make the other understand. Truthfully, I think we both do understand each others points and there is just enough grey area in testing hooks that both sides are valid to some degree and the truth lies somewhere in the middle. I think this discussion would be very much improved with a few more voices and points of view.

I do feel like this is going to happen whether I like it or not and now I need to decide just how much more effort I want to put into this discussion, my implementation and OSS in general (I've spoken in the past about feeling quite burnt out on it all, so this is not specifically triggered by this, just bringing it to a head). I don't mean that to sound passive aggressive (although I would understand if it is read that way), I just don't know how to word it any differently.

@eps1lon, for the record, our entire test suite (which covers a lot more hooks and edge cases than the 2 tests in this PR) is passing with the beta version of react and using a concurrent root (ReactDOM.createRoot), except async tests because of the changes around async act and us wrapping waitFor (and friends) in it. I've tried following the reactwg discussions (act changes and IS_REACT_ACT_ENVIRONMENT) around it and it is clear as mud to me whether we are supposed to wrap waitFor in act or not and what the hell IS_REACT_ACT_ENVIRONMENT means or how we are supposed to use it in React 18 (for now, I have just set it to true because I know I'm running React 18 in a node environment for my tests) while also supporting React 16 and 17.

@mpeyper
Copy link
Member

mpeyper commented Nov 16, 2021

And just as an aside, I'm also finding the rhetoric that "the API is different and so people will have to learn a new tool which is bad" to be largely unfounded and just your opinion on it. We get very few support tickets or questions in discord (bordering on none) about how to use our library. The vast majority is people trying to test their components with it and think we have some super secret magical API that can extract the internal values of hooks out of them, to which we redirect them to @testing-library/react and attempt to change how they are thinking about their test to not focus on those implementation details, or there is a bug in their hook code which is preventing their tests from passing. I could count the number of times our renderHook or our API was the actual issue on one hand.

@joshuaellis
Copy link
Member

Maybe this would benefit from an unbiased RFC issue that we could share with the wider community to get feedback on.

Although it would be good before doing that understanding the full scope of the proposal which to me (and feel free to correct me @eps1lon) is:

The depreciation of @testing-library/react-hooks and subsequent inclusion of a leaner renderHook API in @testing-library/react because...(argument for moving)... however...(argument for not moving)...

@mpeyper
Copy link
Member

mpeyper commented Nov 17, 2021

That's not a bad idea @joshuaellis. I'd be very interested in hearing the thoughts of the @testing-library/react-native @testing-library/preact and @testing-library/preact-hooks maintainers too as there is likely a ripple effect on their libraries too.

@joshuaellis
Copy link
Member

Absolutely, I can draft up a proposal and send it to you both in Discord to avoid curation noise in this PR if you're happy with that @eps1lon?

Conceptually the state is pending to be committed. So this makes more sense than tying it to "render"
@edkimmel
Copy link

edkimmel commented Aug 4, 2022

I would like to echo a desire to swap renderHook from react-hooks to react ahead of the full React 17 -> 18 migration.

Is it something feasible to patch into a new 12.x release, or to be done as part of a yarn-patch?

@Mone71
Copy link

Mone71 commented Aug 4, 2022

Hi

@jtbandes
Copy link

jtbandes commented Aug 6, 2022

Hi,
I appreciate the thoughtful discussion in this thread and it's been interesting to read about some of the technical challenges or design decisions behind the changes.

I just spent some time moving a medium-sized (130k loc) TypeScript codebase to this new API in preparation for a React 18 upgrade, and just wanted to share my experience as a real-world example of the effects these API changes: foxglove/studio#4063 (Note: this PR doesn't actually move from @testing-library/react-hooks to @testing-library/react yet, but it was created by doing the React 18 upgrade and this transition in a branch and then backporting the changes, which are largely backwards compatible, so we can merge them independently of the actual upgrade.)

As I worked on these changes, it struck me as a bit unfortunate that in order to upgrade to React 18, in addition to changing the actual React API calls, I had to get sidetracked and change a bunch of tests that were using renderHook's result.all, result.error, and using dynamically-changing props in the wrapper (other than children). All of these required workarounds that feel messy, and while not majorly disruptive, it would have been nice to provide a smooth transition path for those who are already spending a lot of effort in other places to make the 17→18 upgrade.

Specifically the changes I made were roughly of this form:

React 17 @testing-library/react-hooksReact 18 @testing-library/react
const { result, rerender } = renderHook(
  ({a, b}) => useFoo({a, b}),
  {
    initialProps: { a, b, x, y },
    wrapper: ({ a, b, x, y, children }) =>
      <Wrapper a={a} b={b} x={x} y={y}>{children}</Wrapper>,
  }
);

...
rerender({ a: a2, b: b2, x: x2, y: y2 });
let a, b, x, y;
const { result, rerender } = renderHook(
  ({a, b}) => useFoo({a, b}),
  {
    initialProps: { a, b },
    wrapper: ({ children }) =>
      <Wrapper a={a} b={b} x={x} y={y}>{children}</Wrapper>,
  }
);

...
a = a2; b = b2; x = x2; y = y2;
rerender({ a: a2, b: b2 });
const { result } = renderHook(() => useFoo());
...
act(() => result.current.doSomething());
expect(result.all).toEqual(...);
let all = [];
const { result } = renderHook(() => {
  const value = useFoo();
  all.push(value);
  return value;
});
...
act(() => result.current.doSomething());
expect(all).toEqual(...);
const { result } = renderHook(() => useFoo());
...
expect(result.error).toEqual(...);
let error;
const { result } = renderHook(
  () => useFoo(),
  {
    wrapper: class Wrapper extends React.Component<React.PropsWithChildren<unknown>> {
      override componentDidCatch(err) {
        error = err;
      }
      override render() {
        return this.props.children;
      }
    },
  }
);
...
expect(error).toEqual(...);

None of the changes alone were particularly hard, but it made the code messier and more verbose.

@pvandommelen
Copy link

@mpeyper In your post last year (#991 (comment)) you mentioned that result.all would not yet be removed as it can be useful to test all states that a hook produces. Like the post above, we are currently running into an issue upgrading to react 18 as the property does not exist. I'm not aware of any other discussion that has occurred on this topic, but is there any chance this can still be (re)added?

The original issue has some context on why this feature is useful (testing-library/react-hooks-testing-library#461)

We are currently getting a lot of benefit out of asserting result.all in our tests. Hooks rerendering with problematic temporary values or rendering unnecessarily is a recurring issue in our (or library) implementations. A basic example:

it("will initially load the value", async () => {
	const { result, waitFor } = renderHook(() => {
		return useAsync(() =>  Promise.resolve("foo"), []);
	});

	await waitFor(() => result.current.loading === false);
	assertFrames(result.all, [
		{ value: void 0, loading: true },
		{ value: "foo", loading: false },
	]);
});

You mention the addition of result.all being a mistake, something that would only be useful in the context of SSR. Is that Server Side Rendering? I'm not sure how that would be important to this functionality, we do not use server side rendering. Is it possible to elaborate (or maybe include a link) on why it is a problematic feature?

We are investigating whether we can implement some wrapper around our hooks so that we can still assert all states. Or maybe reimplement the renderHook functionality itself. As we think it is a common pattern, we would prefer to solve it using the react-(hooks-)testing-library.

@mpeyper
Copy link
Member

mpeyper commented Sep 16, 2022

Hi @pvandommelen,

That decision is entirely up to the maintainers of react-testing-library. I suggest raising a new issue with a feature request, explaining the scenarios that waitFor is not sufficient for with examples for consideration.

You mention the addition of result.all being a mistake, something that would only be useful in the context of SSR.

Not only useful in the context SSR, just that before we had SSR rendering supported, it gave the ability to see what the initial value of the hook was before effects fired, as if it was SSR’d.

I say it was a mistake because it often encourages people to put too much emphasis on the transient values of their hooks and ending up with brittle tests that take a more effort to maintain, when most of the time, the final value is all they really cared about.

@JohnColvin
Copy link

JohnColvin commented Sep 21, 2022

We have a situation in our app where testing the initial value is important.

We have a hook that performs GET requests that can be enabled/disabled via a when prop. That hook returns a value called isFetching. If the hook is initially called with when={true}, the initial value of isFetching should also be true.

We had a bug in our code where the initial value was being set to false, but then quickly changed in an effect like this:

const useRequest = ({ when, ...queryOptions }) => {
  const [isFetching, setIsFetching] = useState(false)

  const fetch = useEvent(...)

  useEffect(() => {
    if (when) setRequestId(generateUuid)
  }, [when])

  useEffect(() => {
    if (!requestId) return
    setIsFetching(true)
    fetch(queryKey)
  }, [fetch, queryKey, requestId])

The brief time where isFetching is initially false is a big problem for places relying on that status to show loading state instead of content.

The bug is easily fixed by changing the initial value of isFetching:

  const [isFetching, setIsFetching] = useState(!!when)

Having result.all[0] allows us to easily test that we don't have this bug:

      test('sets isFetching to true', async () => {
        const scope = nock(url).get('/').reply(200, JSON.stringify('OK'))
        const { result, waitFor } = renderHook(() => useGet(url, options), {
          wrapper: _.createCleanCache()
        })

        expect(result.all[0].isFetching).toEqual(true)
        await waitFor(() => expect(scope.isDone()).toEqual(true))
      })

If you change this test to use result.current, the test incorrectly passes when the bug is present. Because the effects run fast enough that by the time we read result.current, the ref has changed to the correct value.

What's the recommendation for testing this without result.all?

So far, the best I have come up with feels much worse:

      test('sets isFetching to true', async () => {
        const Component = () => {
          const { isFetching } = useGet(url, options)
          const initialIsFetching = useRef(isFetching)
          return `initial isFetching: ${initialIsFetching.current}`
        }

        const Wrapper = _.createCleanCache()
        render(
          <FetchMock mocks={[{ url: '/', status: 200, response: 'OK' }]}>
            <Wrapper>
              <Component />
            </Wrapper>
          </FetchMock>
        )

        expect(screen.getByText('initial isFetching: true')).toBeInTheDocument()
      })

@jtbandes
Copy link

@JohnColvin check out my comment at #991 (comment) for some examples of how to replace result.all.

@JohnColvin
Copy link

Thanks @jtbandes. I'll try your second example of tracking the all array yourself. For our case, we can just track the initial since we don't need to test subsequent values. That solution is much better than my use of ref in my comment.

But I'm hoping that my comment shows a case where the initial value is important as a vote to return some of the previous functionality of renderHook.

@JohnColvin
Copy link

Thanks again @jtbandes! We've implemented something based on your approach and our tests are looking good now, with very little change.

There's a small bug in your version posted above. The render function needs to be passed the props given to it. If you use the initialProps option, you'll see that the useFoo call does not receive them.

This is what we've done to wrap renderHook to return a results array:

const customRenderHook = (renderFn, options) => {
  const results = []
  const renderFnWithResultHistory = props => {
    const result = renderFn(props)
    results.push(result)
    return result
  }

  return {
    results,
    ...renderHook(renderFnWithResultHistory, options)
  }
}

and to avoid handling the arguments to renderFn, we switched renderFnWithResultHistory to a ramda pipe instead:

  const renderFnWithResultHistory = R.pipe(
    renderFn,
    R.tap(result => results.push(result))
  )

@NathanVss
Copy link

NathanVss commented Oct 31, 2022

Hi, I appreciate the thoughtful discussion in this thread and it's been interesting to read about some of the technical challenges or design decisions behind the changes.

I just spent some time moving a medium-sized (130k loc) TypeScript codebase to this new API in preparation for a React 18 upgrade, and just wanted to share my experience as a real-world example of the effects these API changes: foxglove/studio#4063 (Note: this PR doesn't actually move from @testing-library/react-hooks to @testing-library/react yet, but it was created by doing the React 18 upgrade and this transition in a branch and then backporting the changes, which are largely backwards compatible, so we can merge them independently of the actual upgrade.)

As I worked on these changes, it struck me as a bit unfortunate that in order to upgrade to React 18, in addition to changing the actual React API calls, I had to get sidetracked and change a bunch of tests that were using renderHook's result.all, result.error, and using dynamically-changing props in the wrapper (other than children). All of these required workarounds that feel messy, and while not majorly disruptive, it would have been nice to provide a smooth transition path for those who are already spending a lot of effort in other places to make the 17→18 upgrade.

Specifically the changes I made were roughly of this form:

React 17 @testing-library/react-hooks React 18 @testing-library/react

const { result, rerender } = renderHook(
  ({a, b}) => useFoo({a, b}),
  {
    initialProps: { a, b, x, y },
    wrapper: ({ a, b, x, y, children }) =>
      <Wrapper a={a} b={b} x={x} y={y}>{children}</Wrapper>,
  }
);

...
rerender({ a: a2, b: b2, x: x2, y: y2 });
let a, b, x, y;
const { result, rerender } = renderHook(
  ({a, b}) => useFoo({a, b}),
  {
    initialProps: { a, b },
    wrapper: ({ children }) =>
      <Wrapper a={a} b={b} x={x} y={y}>{children}</Wrapper>,
  }
);

...
a = a2; b = b2; x = x2; y = y2;
rerender({ a: a2, b: b2 });
const { result } = renderHook(() => useFoo());
...
act(() => result.current.doSomething());
expect(result.all).toEqual(...);
let all = [];
const { result } = renderHook(() => {
  const value = useFoo();
  all.push(value);
  return value;
});
...
act(() => result.current.doSomething());
expect(all).toEqual(...);
const { result } = renderHook(() => useFoo());
...
expect(result.error).toEqual(...);
let error;
const { result } = renderHook(
  () => useFoo(),
  {
    wrapper: class Wrapper extends React.Component<React.PropsWithChildren<unknown>> {
      override componentDidCatch(err) {
        error = err;
      }
      override render() {
        return this.props.children;
      }
    },
  }
);
...
expect(error).toEqual(...);

None of the changes alone were particularly hard, but it made the code messier and more verbose.

I can relate that we are sadly facing the same issue migrating the https://github.com/openfun/richie repository from React 17 -> 18. Especially the case where props aren't shared anymore with the wrapper, which forces to use top-scoped variables as described in the great exemple you provided @jtbandes .

In any case thank you for the work put in this library.

@develohpanda
Copy link

I've written a small utility to aid in migrating waitForValueToChange over to RTL

import { waitFor } from '@testing-library/react';

const waitForValueToChange = async <T,>(getValue: () => T) => {
    const original = getValue();

    await waitFor(async () => {
        expect(await original).not.toEqual(await getValue());
    });
};

@mpeyper
Copy link
Member

mpeyper commented Nov 18, 2022

For anyone else that gets here looking for migration help, there is a WIP migration guide here. Any contributions to it with breaking changes, advice, clarifications and/or shims are more than welcome.

brandoncroberts pushed a commit to brandoncroberts/redux that referenced this pull request Oct 13, 2023
wangcch added a commit to axios-use/axios-use-react that referenced this pull request Oct 20, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request released
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet