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

chore: hook ADR round 1 #69

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
136 changes: 136 additions & 0 deletions docs/decisions/000x-internal-editor-testability-decisions.md
@@ -0,0 +1,136 @@
# Internal editor testability decision
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: as this ADR contains more information about general code structure in the name of improved testability, it might be worth noting that in the title of the ADR, so those confused by the code structure in this repo know to look here.

Copy link
Contributor

Choose a reason for hiding this comment

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

"Testability decisions" to the lay public might get skipped over because to them it means "we are shooting for 100% and we use Jest"

Copy link
Member

Choose a reason for hiding this comment

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

Maybe a name more related to code structure would be better, since that is what is decided here

Copy link
Member

Choose a reason for hiding this comment

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

"decision on structuring code for testability" maybe or something similar


# Increased complexity for the sake of testability
Copy link
Member

Choose a reason for hiding this comment

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

I am not sure that idea makes sense. What usually happens is that hard-to-test components are relatively complex, and reducing that complexity makes them easy to test. If you need to increase code complexity to test better, it is more likely a sign that the code is already too complex (and then you make matters worse.) I know testing is hard but the better way is to decouple your code enough that it's testable, develop better testing tools, and also testing the right things (I recommend starting to use react-testing-library to make your life easier here.)

Copy link
Contributor

Choose a reason for hiding this comment

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

I think what we're trying to do is create/maintain separation of logic and rendering; basically, recreating a view/controller split in react. This (in theory) simplifies testing, by allowing the dom rendering to be tested in a static fashion, while actual javascript logic can be tested in a straightforward way. I think there's value in this, even if we don't maintain 100% separation.

The internally-managed editors in this repo (as of now planned to include text, video, and problem types) follow a number of patterns that increase the complexity of parts of the code slightly, in favor of providing increased testability around their behavior.
Copy link
Contributor

Choose a reason for hiding this comment

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

I would say that separating out the behavior in a hook files helps with readability as well.


## Note - Strict Dictionaries
Javacript is generally fairly lackadaisical with regards to dictionary access of missing/invalid keys. This is fine and expected in many cases, but also prevents us from using dictionary access on something like a key store to ensure we are calling something that actually exists.

For this purpose, there are a pair of utilities in this repo called `StrictDict` and `keyStore`.

`StrictDict` takes an object and returns a version that will complain (throw an error) if called with an invalid key.

`keyStore` takes an object and returns a StrictDict of just the keys of that object. (this is useful particularly for mocking and spying on specific methods and fields)
Comment on lines +9 to +13
Copy link
Member

Choose a reason for hiding this comment

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

👍🏾 We should add this in the comments where we have declared these utilities.


## Note - Self imports
Javascript webpack imports can be problematic around the specific issue of attempting to mock a single method being used in another method in the same file.

Problem: File A includes methodA and methodB (which calls methodA). We want to be able to test methodA and then test methodB *without* having to re-test methodA as part of that test. We want to be able to mock methodA *only* while we are testing methodB.

Solution: Self-imports. By importing the module into itself (which webpack handles nicely, don't worry), we provide tests the ability to spy on and mock individual methods from that module separately on a per-test basis.
Copy link
Member

Choose a reason for hiding this comment

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

I consider this unacceptable. Self-imports make code much more complex and any circular imports are very dangerous and bug-prone. I haven't seen other JS projects adopt or recommend any pattern like this. The way to go is to simplify, not build huge workarounds. You can just put the method into another file, and that has a lot of benefits, for example you can automock the whole file with jest.

Copy link
Contributor

Choose a reason for hiding this comment

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

very dangerous and bug-prone
I'm sure it can be bug prone, how is it dangerous. As someone who has only scratched the surface of javascript I am curious

Copy link
Contributor

@kenclary kenclary Jan 22, 2023

Choose a reason for hiding this comment

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

I agree that self-imports is needlessly complex and makes things worse.

Furthermore, I think that using it to mock the functions we are testing when testing higher-level code is exactly what's making our tests horribly complicated and difficult to read, maintain, or update.

Edit, for clarity: I think the issue here is black box testing vs glass box testing. I think this should (generally) shift to black box testing, to avoid the added complexity.


Ex:
```javascript
// myFile.js
import * as module from './myFile';

export const methodA = (val) => // do something complex with val and return a number
export const methodB = (val) => module.methodA(val) * 2;

// myFile.test.js
import * as module from './myFile';
import { keyStore } from './utils';

cosnt moduleKeys = keyStore(module);

describe('myFile', () => {
describe('methodA', () => ...);
describe('methodB', () => {
const mockMethodA = (val) => val + 3
const testValue = 23;
beforeEach(() => {
jest.spyOn(module, moduleKeys).mockImplementationValueOnce(mockMethodA);
});
it('returns double the output of methodA with the given value', () => {
expect(module.methodB(testValue)).toEqual(mockMethodA(testValue) + 3);
});
});
});
```

## Hooks and Snapshots - Separation from components for increased viability of snapshots
As part of the testing of these internal editors, we are relying on snapshot testing to ensure stability of the display of the components themselves. This can be a fragile solution in certain situations where components are too large or complex to adequately snapshot and verify.

For this purpose, we have opted for a general pattern of separating all of the behavior of components withing these editors into separate `hooks` files.
Copy link
Contributor

Choose a reason for hiding this comment

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

also sometimes not just files, but static objects defined in the same file as the component, when small.

Copy link
Member

Choose a reason for hiding this comment

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

hooks files in general are fine, but they are named hooks because they contain hooks. This is a pattern adopted throughout the React community, and we should follow well-established patterns and semantic naming. You should not export any non-hook functions (like handleClick) from these files. Instead, add a file with a different name for other functions.

Copy link
Contributor

Choose a reason for hiding this comment

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

What's the effective utility of having two separate files for "non-hook helper functions" and "hooks"? Can we just name these files "logic.js" or something like that instead of Hooks (which I agree is a term we have overloaded)?

Copy link
Member

Choose a reason for hiding this comment

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

Sure, that works


These hook files contain methods that utilize both `react` and `react-redux` hooks, along with arguments passed directly into the component, in order to generate the resulting desired behaviors.

From there, components are tested by mocking out the behavior of these hooks to return verifyable data in the snapshots.

Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
In the React world, functions that call other hooks - e.g. `useState` or `useSelect` - are called custom hooks. It is important to distinguish between custom hooks and non-hook functions so that you know when state or lifecycle is affected. For that reason, the React community has decided to prepend the name of any custom hook with "use". For example, a function that calls useState and is not a React component will not be called "myName" but instead "useMyName". We need to accept the React community's decision and adopt this pattern, otherwise we are just sowing confusion.

As part of this separation, there are a number of additional patterns that are followed

### Snapshot considerations
#### Callbacks
Any callback that is included in render in a component should be separated such that is is either passed in as a prop or derived from a hook, and should be mocked with a `mockName` using jest, to ensure that they are uniquely identifyable in the snapshots.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
Any callback that is included in render in a component should be separated such that is is either passed in as a prop or derived from a hook, and should be mocked with a `mockName` using jest, to ensure that they are uniquely identifyable in the snapshots.
Any callback that is included in render in a component should be separated such that it is either passed in as a prop or derived from a hook, and should be mocked with a `mockName` using jest, to ensure that they are uniquely identifyable in the snapshots.


Ex:
```javascript
const props = {
onClick: jest.fn().mockName('props.onClick');
}
expect(shallow(<MyElement {...props} />)).toMatchSnapshot();
```

#### Imported components
Imported compoents are mocked to return simple string components based on their existing name. This results in shallow renders that display the components by name, with passed props, but do not attempt to render *any* logic from those components.

This is a bit more complex for components with sub-components, but we have provided a test utility in `src/testUtils` called `mockNestedComponent` that will allow you to easily mock these for your snapshots as well.

Ex:
```javascript
jest.mock('componentModule', () => {
const { mockNestedComponent } = jest.requireActual('./testUtils');
return {
SimpleComponents: () => 'SimpleComponent',
NestedComponent: mockNestedComponent('NestedComponent', {
NestedChild1: 'NestedComponent.NestedChild1',
NestedChild2: 'NestedComponent.NestedChild2',
}),
});
```
#### Top-level mocked imports
We have mocked out all paragon components and icons being used in the repo, as well as a number of other common methods, hooks, and components in our module's `setupTests` file, which will ensure that those components show up reasonably in snapshots.

### Hook Considerations
#### useState and mockUseState
React's useState hook is a very powerful alternative to class components, but is also somewhat problematic to test, as it returns different values based on where it is called in a hook, as well as based on previous runs of that hook.

To resolve this, we are using a custom test utility to mock a hook modules state values for easy testing.

This requires a particular structure to hook modules that use the useState, for the sake of enabling the mock process (which is documented with the utility).
Copy link
Member

Choose a reason for hiding this comment

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

This doesn't make sense, because we are making the code much much more complex and hard to understand. This violates the clean code principles but also is dangerous as you don't understand the state logic of the component easily and it can produce bugs. Changing implementations to make them easier testable is really not a good idea, instead, you can improve the way you test. It is absolutely possible to mock React's useState without changing the code, it is just very hard. Which is okay, because you are not supposed to mock it in general. When you need to mock it, it may be a sign that your test does not test the right thing. Unit testing means not breaking into the implementation of a component but testing it from outside, more like a user uses it. React recommends trying to test a component much like a user would - e.g. you would simulate a click on a dom element rather than overwriting the state, which is not very helpful. To make your life easier with this, I recommend react-testing-library in many cases over enzyme.
If you have trouble testing complex state logic, consider: Redux in part is designed to make this easy and make state testable. So maybe use a reducer and test that separately. Also, React has its own useReducer hooks if you don't want to interact with global state for some reason.

Copy link
Contributor

Choose a reason for hiding this comment

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

ah, here is an interesting question I have with your take:

We often write custom hooks, which use react-hooks, in particular, useState. How would you go about testing those? Sure, React recommends UI level testing, but that often is in fact an integration test, not a unit test.

Copy link
Member

Choose a reason for hiding this comment

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

That's a very good point, and I would have to figure it out a bit for myself - in previous projects I tested hooks as part of component and that's it. But there are a lot of good resources for testing custom hooks, I think even whole libraries for this purpose. A bit of research provided this very good post about it by Kent C.Dodds, who is a well-known authority in the React community, and I think his approach is very valid: https://kentcdodds.com/blog/how-to-test-custom-react-hooks

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 testing hooks without using a React component is a bit beside the point because you are actually trying to test the component lifecycle, and that would be very far removed from reality. Any non-lifecycle related logic you can factor out of those hooks and test very easily. Personally so far I used react-testing-library (https://testing-library.com/docs/react-testing-library/intro/) for all of my components, and added enzyme sometimes for fine-grained tests.

Copy link
Member

Choose a reason for hiding this comment

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

By the way the renderHook method from @testing-library/react Dodds uses for testing custom hooks is the same thing as react-testing-library

Copy link
Member

Choose a reason for hiding this comment

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

a point I overlooked here in Dodd's article is "I'm not talking about the one-off custom hook you pull out just to make your component body smaller and organize your code (those should be covered by your component tests)" - I agree with that


Ex:
```javascript
import * as module from './hooks';
const state = {
myStateValue: (val) => useState(val),
};
const myHook = () => {
const [myStateValue, setMyStateValue] = module.state.myStateValue('initialValue');
};
```
Examples on how to use this for testing are included with the mock class in `src/testUtils`

#### useCallback, useEffect
These hooks provide behavior that calls a method based on given prerequisite behaviors.
For this reason, we use general-purpose mocks for these hooks that return an object containing the passed callback and prerequisites for easy test access.

#### Additional Considrations
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: spelling: Considerations

*useIntl not available*

We are using react-intl under the hood for our i18n support, but do not have access to some of the more recent features in that library due to the pinned version in frontend-platform. Specifically, this includes a `useIntl` hook available in later versions that is still unavailable to us, requiring us to use the older `injectIntl` pattern.

*useDispatch*

React-redux's `useDispatch` hook does not play nicely with being called in a method called by a component, and really wants to be called *in* the component. For this reason, the dispatch method is generated in components and passed through to hook components.

## Notes for integration testing
Because of the top-level mocks in setupTest, any integration tests will need to be sure to unmock most of these.

Ex:
```javascript
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@edx/paragon');
jest.unmock('@edx/paragon/icons');
jest.unmock('react-redux');
```
3 changes: 1 addition & 2 deletions src/editors/utils/index.js
@@ -1,4 +1,3 @@
/* eslint-disable import/prefer-default-export */
export { default as StrictDict } from './StrictDict';
export { default as keyStore } from './keyStore';
export { keyStore, StrictDict } from '../../utils';
export { default as camelizeKeys } from './camelizeKeys';
2 changes: 1 addition & 1 deletion src/testUtils.js
@@ -1,6 +1,6 @@
/* istanbul ignore file */
import react from 'react';
import { StrictDict } from './editors/utils';
import { StrictDict } from './utils';
/**
* Mocked formatMessage provided by react-intl
*/
Expand Down
File renamed without changes.
File renamed without changes.
2 changes: 2 additions & 0 deletions src/utils/index.js
@@ -0,0 +1,2 @@
export { default as StrictDict } from './StrictDict';
export { default as keyStore } from './keyStore';
File renamed without changes.