-
Notifications
You must be signed in to change notification settings - Fork 138
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
Redux logic errors #1954
Redux logic errors #1954
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey, great start here!
For testing redux-logic, I was thinking about using https://github.com/jeffbski/redux-logic-test. Specifically, I would dispatch actions and then verify that the logic ran properly and resulted in the correct state. Not sure if this approach would assert on too many pieces outside of redux-logic. For example, it would depend on reducers being configured in a specific way. Would be interested in your thoughts!
I’d prefer to avoid this, for the reason you mention: asserting on too many pieces outside of redux-logic. My philosophy on testing is that we should be very intentional about what parts of the code are and are not under test, and generally the answer to that question should respect organic code boundaries. There is not one right answer to where those boundaries belong—unit testing (boundary around a single class or module); functional testing (boundary around a particular functional concern, e.g. “the entire Redux store as a black box”); and integration testing (boundary around more or less the entire application, e.g. mounting the <App />
component and using react-testing-library
to see what it does) are all useful testing tools.
While there is no right answer overall, and in fact the ideal testing strategy will almost certainly include test suites at more than one of these levels, Popcode’s current testing convention is to just write unit tests. This is for several reasons—partly historical (the React and Redux ecosystem strongly encouraged unit testing at the time Popcode’s testing conventions were established) but also because unit testing is the best way to test the behavior of our code under a wide range of cases. The further we get up the integration chain, the less natural it is to test edge-case behavior for each individual module; so, I’m of the opinion a good testing strategy is built on a foundation of comprehensive unit tests, with functional/integration tests giving us feedback about the integrated system, but with more of a focus on the happy path.
Given unlimited time, I would certainly want to build at least one integration testing suite for Popcode on top of the existing unit testing suite (after closing the current coverage gaps in unit testing), but since it’s a spare time project, I don’t have any expectation of that actually happening.
So: for now, I’d encourage you to write the tests for these logics in the same unit testing style as the existing logics, e.g. just test the behavior of the logic itself with any dependencies mocked out.
I'm having a little bit of trouble wrapping my head around the way the original errors saga was set up, to where all of these would be running in parallel, and if there was already validation happening for a language, it would then cancel the validation call for that language. In #1934, you mentioned that redux-logic can't cancel in-flight asynchronous actions. Is this one of of the cases you were talking about, or was that something else?
It’s definitely related, although cancelling an in-flight saga would generally mean aborting the asynchronous operation that the saga is waiting for; in this case all we need to do is ignore the result of validating a particular source if that source is modified again before the first validation completes. I hadn’t looked at this much before, but it looks like redux-logic
does have an explicit affordance for cancellation after all. The nuance here is that we’ll only want to do this if there’s an in-flight validation for the same language; from skimming over the docs it’s not clear what the best way to do that is, but I’d love to hear about what you can find! And happy to dig in further if you get stuck.
Any thoughts on the organization of the redux-logic
errors
? I saw that @jwang1919 had split up to have one logic per file, but in terms of theerrors
saga, bothvalidateCurrentProject
andupdateProjectSource
called on the samevalidateSource
function that will now not be its ownlogic
, which is why I decided to organize it all into one file.
Addressed this one inline 🙂
src/logic/errors.js
Outdated
}, | ||
}); | ||
|
||
const updateProjectSource = createLogic({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would actually probably call this validateProjectOnChange
or something like that—generally I’d like to follow the principle of naming logics after what they actually do, rather than the action(s) they consume. (N.B. the sagas tend to follow the former naming convention, which is my fault!)
src/logic/errors.js
Outdated
const validate = validations[language]; | ||
const validationErrors = await validate(source, projectAttributes); | ||
await dispatch(validatedSource(language, validationErrors)); | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rather than putting multiple logics in one module, let’s move this into its own module, probably inside a src/logic/helpers
package that we can use in the general case when we need to share code between different logics. Then we can put each of the logics defined here in its own module.
src/logic/errors.js
Outdated
import {validatedSource} from '../actions/errors'; | ||
import retryingFailedImports from '../util/retryingFailedImports'; | ||
|
||
export async function importValidations() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this need to be exported?
@outoftime so sorry, this took longer than expected but I've implemented most of your feedback! The only thing I still wasn't sure about was the cancellation/ignoring the validation of a source if there is already one in flight. I tried to experiment with and research on the topic a little more but I think I'm still a little confused on what the current problem is vs what the end goal should be when it comes to these cancellations. Is it that these validations are firing way more than necessary? What would be the best way to test that we've achieved the correct cancellation logic? Thanks for your help! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey! Unfortunately running out of time to totally totally finish the review here but hopefully this is enough feedback to take another pass. As for the cancellation thing, I think using the latest
option is probably sufficient for our purposes (you are using this in validateProjectOnSave
but not validateCurrentProject
; I would add it to the latter as well). What we’re looking to avoid is something like:
- Project source updates to A
- We fire validation on A
- Project source updates to B
- We fire validation on B
- Validation on B completes
- Validation on A completes
- Thus we display outdated validation errors
Even with latest
, this could still happen if the first validation were fired by validateCurrentProject
and the second by validateProjectOnSave
, or vice versa, since they won’t respect each others’ latest
. If you want to be really ironclad, one approach would be to save a copy of the current project source before firing off the validation, and then when the validation returns, check that it hasn’t changed (because of the strong guarantees we have around immutability/referential stability in our Redux store, I think you should be able to do this with a very cheap referential equality check). I’m not 100% sure that that approach would cover all cases (e.g. what if there is a race between a validateCurrentProject
and a validateProjectOnChange
, where 2 of the 3 validations of the former are still legit when it completes) but might be worth exploring.
Overall, great work on this! Your code/tests are really solid and thoughtful 🙂
src/logic/helpers/validateSource.js
Outdated
); | ||
} | ||
|
||
export default async ({language, source, projectAttributes}, dispatch) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Extreme nit / not a blocker, but all else being equal I’d make this a regular named function rather than an arrow function (since it’s at the top level of the module / not being inlined in a larger expression).
src/logic/validateCurrentProject.js
Outdated
const validatePromises = []; | ||
for (const language of Reflect.ownKeys(currentProject.sources)) { | ||
const source = currentProject.sources[language]; | ||
validatePromises.push( | ||
validateSource( | ||
{language, source, projectAttributes: analyzer}, | ||
dispatch, | ||
), | ||
); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: a map
would be a more idiomatic way to construct this array; I would probably use lodash’s map directly over currentProject.sources
since it will iterate over object entries.
src/logic/helpers/validateSource.js
Outdated
const validations = await importValidations(); | ||
const validate = validations[language]; | ||
const validationErrors = await validate(source, projectAttributes); | ||
await dispatch(validatedSource(language, validationErrors)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How come we’re awaiting the dispatch
here? dispatch
should be a void function; I wouldn’t expect it to return a promise…
const mockCssSource = 'div {}'; | ||
|
||
jest.mock('../../analyzers'); | ||
jest.mock('../helpers/validateSource'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So this I think may be going a little further than we want with mocking—in particular the fact that validateCurrentProject
delegates some of its logic to the validateSource
helper is essentially an implementation detail of validateCurrentProject
; if in the future we decided to pull all of that logic directly into validateCurrentProject
, I don’t think we would consider that breaking from the standpoint of the unit tests.
So my proposal would be to just test the behavior of the validateCurrentProject
logic here, including any behavior that happens to be implemented in validateSource
; and remove the unit test for the validateSource
helper.
jest.mock('../../selectors', () => ({ | ||
getCurrentProject: jest.fn(() => ({ | ||
sources: { | ||
html: mockHtmlSource, | ||
css: mockCssSource, | ||
}, | ||
})), | ||
})); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To be honest this also intuitively is probably not something I would have mocked if I were writing this test, although I think the argument is much less clear-cut than with the helper module commented on above. I could truly go either way on this, but wanted to flag it as something to think about, particularly in terms of “which decision is going to make this test more robust?”, with “robust” meaning “sensitive to breaking changes in the code under test, and insensitive to non-breaking changes in the code under test”.
const done = jest.fn(); | ||
await validateCurrentProject.process( | ||
{ | ||
getState: () => dummyState, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think eslint-plugin-lodash
(introduced after you created this branch) is going to tell you to use constant
here.
const analyzerPayload = getCurrentProject(dummyState); | ||
expect(Analyzer).toHaveBeenCalledWith(analyzerPayload); | ||
const analyzer = new Analyzer(analyzerPayload); | ||
expect(validateSource.mock.calls).toEqual([ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would probably use expect.arrayContaining
here since we’re not sensitive to the order of the calls.
test('should validate current project', async () => { | ||
const dispatch = jest.fn(); | ||
const done = jest.fn(); | ||
await validateCurrentProject.process( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, this is a bit of a pain in the ass, but await
ing the call to process
is actually testing implementation details of that method; from the standpoint of redux-logic
, process
does not have to be an async (or Promise-returning) function; it just needs to call done()
when it’s done. I think the best way to test the relevant interface is something like:
await new Promise((resolve) => {
validateCurrentProject.process(
{getState: constant(dummyState)},
dispatch,
resolve,
)
});
FWIW I was looking through other logic tests for examples and am realizing I totally failed to catch a bunch of other cases of what you did here, so I’ll need to go back and fix those—sorry!!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey so! I dug into fixing this issue for the existing tests, and ended up just writing a test helper that actually runs logics through the redux-logic
middleware, so the tests can focus on what the logics do rather than trying to mimic the implementation of redux-logic
. Check out #2026 for all the sordid details!
hi @outoftime! sorry just got back from winter break vacation. Thanks so much for the detailed and helpful feedback. Will take another pass and implement your feedback by end of week! |
…edux-logic-errors
@outoftime tried my best to implement your feedback. Please let me know if there was anything that I may have misunderstood! Also, I was thinking more the case where there might be a race condition between export default createLogic({
type: [
'CHANGE_CURRENT_PROJECT',
'GIST_IMPORTED',
'SNAPSHOT_IMPORTED',
'PROJECT_RESTORED_FROM_LAST_SESSION',
'TOGGLE_LIBRARY',
'UPDATE_PROJECT_SOURCE',
],
latest: true,
async process(
{
getState,
action: {
payload: {language, newValue},
},
},
dispatch,
done,
) {
const state = getState();
const currentProject = getCurrentProject(state);
const analyzer = new Analyzer(currentProject);
if (newValue) {
await validateSource(
{language, source: newValue, projectAttributes: analyzer},
dispatch,
getState,
} else {
const validatePromises = map(
Reflect.ownKeys(currentProject.sources),
language =>
validateSource(
{
language,
source: currentProject.sources[language],
projectAttributes: analyzer,
},
dispatch,
getState,
),
);
await Promise.all(validatePromises);
}
done();
},
}); Not sure how you feel about using the conditional there to prevent unnecessary validations, but I think this might address the race condition. |
…edux-logic-errors
Right on! Will take a look as soon as I can. Looks like the build failed so that might be something to check out in the meantime 🙂 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey! This looks pretty much great; just a few notes on the way we generate data for tests.
In terms of your idea, it’s a good one! Feel free to implement it, I would just encourage you to:
- Check the action name rather than the payload shape when deciding whether it’s single-source or full-project validation
- Put each type of validation in its own function, so that the
process()
hook is just a multiplexer between the two
{ | ||
text: 'Closing tag missing', | ||
}, | ||
]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems like a pretty good candidate for a factory? Generally any time we need sample data to use in a test that conforms to a particular well-defined shape, a factory is the move (since it’s then reusable and rosie makes it super-easy to customize the generated objects for the needs of particular tests)
}, | ||
]; | ||
|
||
jest.mock('../../util/retryingFailedImports', () => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we mock the validations
module itself rather than the retryingFailedImports
utility that we use to load it?
}, | ||
}, | ||
}, | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generally when we’re testing against a particular starting state, I think it’s best to generate that state by running a series of actions through a reducer, rather than hand-rolling it—that way if we make changes to the state shape, updating the reducers will automatically update all the tests. Here’s a good example from another logic test.
snapshotImportedAction, | ||
projectRestoredFromLastSessionAction, | ||
toggleLibraryAction, | ||
].forEach(async action => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
EXTREME NITPICK: Why not for…of
? 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sorry, totally missed this comment
…edux-logic-errors
…edux-logic-errors
@outoftime implemented your latest feedback! I'm not sure if I implemented your vision of a multiplexor Let me know if the way I went with is okay. I'm not sure if I ended up making it less verbose at the expense of readability. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sooooo close! Only blocking issue is the async iterator function in the tests (which I think I might have missed on the last review, sorry!) Multiplexer thing is just polish so feel free to take or leave that feedback 🙂
snapshotImportedAction, | ||
projectRestoredFromLastSessionAction, | ||
toggleLibraryAction, | ||
].forEach(async action => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having an un-awaited async
function as the iterator is going to prevent the assertions from being executed before the test completes—I would recommend iterating over the actions outside the test
declaration, e.g.
for (const action of [
changeCurrentProjectAction,
gistImportedAction,
// ...
]) {
test(`validates current project on ${action}`, async () => {
// …
}
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
makes sense! will fix!
src/logic/validateProject.js
Outdated
const sources = | ||
type === 'UPDATE_PROJECT_SOURCE' | ||
? {[language]: newValue} | ||
: currentProject.sources; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So what I was thinking in terms of a multiplexer would be something like:
if (type === 'UPDATE_PROJECT_SOURCE') {
await validateSource({language, source: newValue, projectAttributes: analyzer}, dispatch);
} else {
await validateSources(currentProject.sources, analyzer, dispatch)
}
Not a big deal either way, but the above approach saves us an unnecessary hop through validateSources
.
(Also, getting a bit nitpicky, I would probably try to be a bit more consistent about positional arguments vs. args object in the two function headers)
…popcode into redux-logic-errors
…edux-logic-errors
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks great!!
} | ||
|
||
async function validateSources({sources, projectAttributes}, dispatch) { | ||
const validatePromises = map(Reflect.ownKeys(sources), language => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Definitely not going to block on this, but a minor improvement here would be to map over sources
itself, which would give you (source, language)
as the arguments to the iterator.
This reverts commit 3314044.
Will need to add tests, but had some quick questions that I was hoping to get some feedback on.
For testing redux-logic, I was thinking about using https://github.com/jeffbski/redux-logic-test. Specifically, I would dispatch actions and then verify that the logic ran properly and resulted in the correct state. Not sure if this approach would assert on too many pieces outside of redux-logic. For example, it would depend on reducers being configured in a specific way. Would be interested in your thoughts!
I'm having a little bit of trouble wrapping my head around the way the original errors saga was set up, to where all of these would be running in parallel, and if there was already validation happening for a language, it would then cancel the validation call for that language. In Migrate from redux-saga to redux-logic #1934, you mentioned that redux-logic can't cancel in-flight asynchronous actions. Is this one of of the cases you were talking about, or was that something else?
Any thoughts on the organization of the redux-logic
errors
? I saw that @jwang1919 had split up to have one logic per file, but in terms of theerrors
saga, bothvalidateCurrentProject
andupdateProjectSource
called on the samevalidateSource
function that will now not be its ownlogic
, which is why I decided to organize it all into one file.Thanks!