Make react testing asynchronous

This commit is contained in:
Jerko Steiner 2019-11-04 15:02:02 -04:00
parent 96c2565887
commit adb3d877a4
13 changed files with 186 additions and 68 deletions

View File

@ -31,7 +31,7 @@
- [ ] Styled components - [ ] Styled components
- [x] SSR - [x] SSR
- [ ] Replace bulma/blommer css framework with styled components - [ ] Replace bulma/blommer css framework with styled components
- [ ] Check if restyped still significantly slows down TS compilation - [ ] Check if reakit still significantly slows down TS compilation
- [x] Use JSON schema instead of @Entity decorators - [x] Use JSON schema instead of @Entity decorators
- [x] Extract database into a separate module - [x] Extract database into a separate module
@ -55,7 +55,7 @@
- [ ] Open Source machine learning model for posts, if it exists - [ ] Open Source machine learning model for posts, if it exists
- [x] svg-captcha from npm. Could prevent blind users from creating an account. Also, new ML models can probably read this format. - [x] svg-captcha from npm. Could prevent blind users from creating an account. Also, new ML models can probably read this format.
- [x] Possible solution: use TTS like say.js to generate audio. Use tasq lib to prevent too much cpu usage if more users arrive. - [x] Possible solution: use TTS like say.js to generate audio. Use tasq lib to prevent too much cpu usage if more users arrive.
- [ ] FIXME debug why espeak sometimes does not stream responses:w - [x] FIXME debug why espeak sometimes does not stream responses:w
- [ ] Add privacy policy statement - [ ] Add privacy policy statement
- [ ] Email - [ ] Email

View File

@ -1,19 +1,19 @@
import React from 'react' import React from 'react'
import {MemoryRouter} from 'react-router' import { MemoryRouter } from 'react-router'
import {Route} from 'react-router-dom' import { Route } from 'react-router-dom'
import {Link} from './Link' import { TestUtils } from '../test-utils'
import {TestUtils} from '../test-utils' import { Link } from './Link'
describe('Link', () => { describe('Link', () => {
const t = new TestUtils() const t = new TestUtils()
function render( async function render(
to: string, to: string,
routePath = '/test', routePath = '/test',
routerEntry = '/test', routerEntry = '/test',
) { ) {
return t.render( return await t.render(
<MemoryRouter initialEntries={[routerEntry]}> <MemoryRouter initialEntries={[routerEntry]}>
<Route <Route
path={routePath} path={routePath}
@ -26,16 +26,16 @@ describe('Link', () => {
) )
} }
it('should set href to value', () => { it('should set href to value', async () => {
const {node} = render('/my/link') const {node} = await render('/my/link')
const a = node.querySelector('a') as HTMLElement const a = node.querySelector('a') as HTMLElement
expect(a.tagName).toEqual('A') expect(a.tagName).toEqual('A')
expect(a.getAttribute('href')).toEqual('/my/link') expect(a.getAttribute('href')).toEqual('/my/link')
expect(a.textContent).toEqual('Link Text') expect(a.textContent).toEqual('Link Text')
}) })
it('should format url to matched props', () => { it('should format url to matched props', async () => {
const {node} = render( const {node} = await render(
'/my/:oneId/link/:twoId', '/my/:oneId/link/:twoId',
'/one/:oneId/two/:twoId', '/one/:oneId/two/:twoId',
'/one/1/two/2', '/one/1/two/2',

View File

@ -1,3 +1,4 @@
test.todo('Implement and test Modal')
// import React from 'react' // import React from 'react'
// import T from 'react-dom/test-utils' // import T from 'react-dom/test-utils'
// import {Modal} from './Modal' // import {Modal} from './Modal'

View File

@ -8,8 +8,8 @@ const t = new TestUtils()
describe('Crumb', () => { describe('Crumb', () => {
describe('render', () => { describe('render', () => {
it('renders', () => { it('renders', async () => {
const {node} = t.render( const {node} = await t.render(
<MemoryRouter> <MemoryRouter>
<Crumb <Crumb
links={[{ links={[{

View File

@ -27,8 +27,8 @@ describe('configrueCrumbs', () => {
.withJSX(Component => <MemoryRouter><Component /></MemoryRouter>) .withJSX(Component => <MemoryRouter><Component /></MemoryRouter>)
describe('render', () => { describe('render', () => {
it('renders', () => { it('renders', async () => {
const {node} = createTestCase().render({}) const {node} = await createTestCase().render({})
expect(node.innerHTML).toMatch(/href="\/one"/) expect(node.innerHTML).toMatch(/href="\/one"/)
expect(node.innerHTML).toMatch(/href="\/two"/) expect(node.innerHTML).toMatch(/href="\/two"/)
@ -37,14 +37,14 @@ describe('configrueCrumbs', () => {
}) })
describe('BREADCRUMBS_SET', () => { describe('BREADCRUMBS_SET', () => {
it('updates breadcrumbs', () => { it('updates breadcrumbs', async () => {
const {render, store} = createTestCase() const {render, store} = createTestCase()
const actions = new Feature.CrumbsActions() const actions = new Feature.CrumbsActions()
store.dispatch(actions.setCrumbs({ store.dispatch(actions.setCrumbs({
links: [], links: [],
current: 'Crumbtest', current: 'Crumbtest',
})) }))
const {node} = render({}) const {node} = await render({})
expect(node.innerHTML).toMatch(/Crumbtest/) expect(node.innerHTML).toMatch(/Crumbtest/)
}) })
}) })

View File

@ -40,7 +40,7 @@ describe('configureLogin', () => {
const onSuccess = jest.fn() const onSuccess = jest.fn()
let node: Element let node: Element
let component: TestContainer let component: TestContainer
beforeEach(() => { beforeEach(async () => {
http.mockAdd({ http.mockAdd({
method: 'post', method: 'post',
url: '/auth/login', url: '/auth/login',
@ -48,7 +48,7 @@ describe('configureLogin', () => {
}, {id: 123}) }, {id: 123})
const t = createTestProvider() const t = createTestProvider()
const r = t.render({onSuccess}) const r = await t.render({onSuccess})
node = r.node node = r.node
component = r.component component = r.component
T.Simulate.change( T.Simulate.change(

View File

@ -46,14 +46,14 @@ describe('configureRegister', () => {
const onSuccess = jest.fn() const onSuccess = jest.fn()
let node: Element let node: Element
let component: TestContainer let component: TestContainer
beforeEach(() => { beforeEach(async () => {
http.mockAdd({ http.mockAdd({
method: 'post', method: 'post',
url: '/auth/register', url: '/auth/register',
data, data,
}, {id: 123}) }, {id: 123})
const r = createTestProvider().render({onSuccess}) const r = await createTestProvider().render({onSuccess})
node = r.node node = r.node
component = r.component component = r.component
T.Simulate.change( T.Simulate.change(

View File

@ -69,7 +69,7 @@ describe('TeamConnector', () => {
} }
it('it fetches user teams on render', async () => { it('it fetches user teams on render', async () => {
const {node, waitForActions} = createTestProvider().render({ const {node, waitForActions} = await createTestProvider().render({
history, history,
location: {} as any, location: {} as any,
match: {} as any, match: {} as any,
@ -94,7 +94,7 @@ describe('TeamConnector', () => {
} }
teamClientMock.create.mockResolvedValue(newTeam) teamClientMock.create.mockResolvedValue(newTeam)
const {render, store} = createTestProvider() const {render, store} = createTestProvider()
const {waitForActions, node} = render({ const {waitForActions, node} = await render({
history, history,
location: {} as any, location: {} as any,
match: {} as any, match: {} as any,
@ -113,7 +113,7 @@ describe('TeamConnector', () => {
it('displays an error', async () => { it('displays an error', async () => {
teamClientMock.create.mockRejectedValue(new Error('Test Error')) teamClientMock.create.mockRejectedValue(new Error('Test Error'))
const {render} = createTestProvider() const {render} = createTestProvider()
const {node, waitForActions} = render({ const {node, waitForActions} = await render({
history, history,
location: {} as any, location: {} as any,
match: {} as any, match: {} as any,

View File

@ -13,8 +13,8 @@ describe('Captcha', () => {
let node: Element let node: Element
let component: Captcha let component: Captcha
function render() { async function render() {
const result = t.render( const result = await t.render(
<Captcha <Captcha
audioUrl='/captcha.opus' audioUrl='/captcha.opus'
imageUrl='/captcha.svg' imageUrl='/captcha.svg'

View File

@ -0,0 +1,40 @@
import { TestUtils } from './index'
import React from 'react'
describe('TestUtils', () => {
TestUtils.defaultTheme = {}
const tu = new TestUtils()
const Functional: React.FunctionComponent<{}> = () => <span>fn</span>
class Class extends React.PureComponent<{}> {
render() {
return <span>class</span>
}
}
describe('render', () => {
it('renders a class component', async () => {
const { node, component } = await tu.render(<Class />)
expect(node).toBeTruthy()
expect(node.tagName).toBe('SPAN')
expect(node.innerHTML).toBe('class')
tu.Utils.findRenderedComponentWithType(component, Class)
})
it('renders a functional component', async () => {
const { node, component } = await tu.render(<Functional />)
expect(node).toBeTruthy()
expect(node.tagName).toBe('SPAN')
expect(node.innerHTML).toBe('fn')
expect(component).toBeTruthy()
})
it('renders a html component', async () => {
const { node, component } = await tu.render(<span>text</span>)
expect(node).toBeTruthy()
expect(node.tagName).toBe('SPAN')
expect(node.innerHTML).toBe('text')
expect(component).toBeTruthy()
})
})
})

View File

@ -15,7 +15,11 @@ interface RenderParams<State, LocalState> {
export class TestContainer extends React.Component<{}> { export class TestContainer extends React.Component<{}> {
ref = React.createRef<HTMLDivElement>() ref = React.createRef<HTMLDivElement>()
render() { render() {
return <div ref={this.ref}>{this.props.children}</div> return (
<div className='test-container' ref={this.ref}>
{this.props.children}
</div>
)
} }
} }
@ -28,20 +32,24 @@ export class TestUtils {
readonly createStore = createStore readonly createStore = createStore
readonly Utils = T readonly Utils = T
render(jsx: JSX.Element) { async render(jsx: JSX.Element) {
const $div = document.createElement('div') const $div = document.createElement('div')
const component = ReactDOM.render( const component: TestContainer | null = await new Promise(resolve => {
<TestContainer> ReactDOM.render(
<ThemeProvider theme={TestUtils.defaultTheme}> <TestContainer ref={instance => resolve(instance)}>
{jsx} <ThemeProvider theme={TestUtils.defaultTheme}>
</ThemeProvider> {jsx}
</TestContainer>, </ThemeProvider>
$div, </TestContainer>,
) as unknown as TestContainer $div,
const node = component.ref.current!.children[0] )
})
if (component === null) {
throw new Error('TestContainer is null, this should not happen')
}
return { return {
component, component,
node, node: $div.children[0].children[0],
} }
} }
@ -90,14 +98,14 @@ export class TestUtils {
let createJSX: CreateJSX | undefined let createJSX: CreateJSX | undefined
const render = (props: Props) => { const render = async (props: Props) => {
const recorder = waitMiddleware.record() const recorder = waitMiddleware.record()
const jsx = createJSX const jsx = createJSX
? createJSX(Component, props) ? createJSX(Component, props)
: <Component {...props} /> : <Component {...props} />
const result = this.render( const result = await this.render(
<Provider store={store}> <Provider store={store}>
{jsx} {jsx}
</Provider>, </Provider>,
@ -132,11 +140,11 @@ export class TestUtils {
} }
interface Self<Props, Store, Component, CreateJSX> { interface Self<Props, Store, Component, CreateJSX> {
render: (props: Props) => { render: (props: Props) => Promise<{
component: TestContainer component: TestContainer
node: Element node: Element
waitForActions(timeout?: number): Promise<void> waitForActions(timeout?: number): Promise<void>
} }>
store: Store store: Store
Component: Component Component: Component
withJSX: (localCreateJSX: CreateJSX) withJSX: (localCreateJSX: CreateJSX)

View File

@ -17,7 +17,7 @@ describe('WaitMiddleware', () => {
it('waits for certain async actions to be resolved', async () => { it('waits for certain async actions to be resolved', async () => {
const wm = new WaitMiddleware() const wm = new WaitMiddleware()
const store = getStore(wm) const store = getStore(wm)
const promise = wm.wait(['B', 'C']) const promise = wm.wait({B: 1, C: 1})
store.dispatch({ store.dispatch({
payload: undefined, payload: undefined,
type: 'A', type: 'A',
@ -40,7 +40,7 @@ describe('WaitMiddleware', () => {
it('times out when actions do not happen', async () => { it('times out when actions do not happen', async () => {
const wm = new WaitMiddleware() const wm = new WaitMiddleware()
const store = getStore(wm) const store = getStore(wm)
const promise = wm.wait(['B', 'C'], 5) const promise = wm.wait({B: 1, C: 1}, 5)
store.dispatch({ store.dispatch({
payload: undefined, payload: undefined,
type: 'A', type: 'A',
@ -58,7 +58,7 @@ describe('WaitMiddleware', () => {
it('errors out when a promise is rejected', async () => { it('errors out when a promise is rejected', async () => {
const wm = new WaitMiddleware() const wm = new WaitMiddleware()
const store = getStore(wm) const store = getStore(wm)
const promise = wm.wait(['B', 'C']) const promise = wm.wait({B: 1, C: 1})
store.dispatch({ store.dispatch({
payload: undefined, payload: undefined,
type: 'A', type: 'A',
@ -76,8 +76,8 @@ describe('WaitMiddleware', () => {
it('errors out when wait is called twice', async () => { it('errors out when wait is called twice', async () => {
const wm = new WaitMiddleware() const wm = new WaitMiddleware()
const store = getStore(wm) const store = getStore(wm)
const promise = wm.wait(['B']) const promise = wm.wait({B: 1})
const error = await getError(wm.wait(['B'])) const error = await getError(wm.wait({B: 1}))
expect(error.message).toMatch(/already waiting/) expect(error.message).toMatch(/already waiting/)
store.dispatch({ store.dispatch({
payload: new Error('test'), payload: new Error('test'),
@ -90,7 +90,7 @@ describe('WaitMiddleware', () => {
it('does nothing when pending is dispatched', async () => { it('does nothing when pending is dispatched', async () => {
const wm = new WaitMiddleware() const wm = new WaitMiddleware()
const store = getStore(wm) const store = getStore(wm)
const promise = wm.wait(['B'], 1) const promise = wm.wait({B: 1}, 1)
store.dispatch({ store.dispatch({
payload: undefined, payload: undefined,
type: 'B', type: 'B',
@ -102,7 +102,7 @@ describe('WaitMiddleware', () => {
it('resolved immediately when no actions are defined', async () => { it('resolved immediately when no actions are defined', async () => {
const wm = new WaitMiddleware() const wm = new WaitMiddleware()
await wm.wait([]) await wm.wait({})
}) })
}) })
@ -129,11 +129,54 @@ describe('WaitMiddleware', () => {
it('records custom actions', async () => { it('records custom actions', async () => {
const wm = new WaitMiddleware() const wm = new WaitMiddleware()
const store = getStore(wm) const store = getStore(wm)
const recorder = wm.record(action => action.type.startsWith('test')) const recorder = wm.record(
action => action.type.startsWith('test'),
action => action.type.startsWith('test.resolved'),
action => action.type.startsWith('test.rejected'),
)
store.dispatch({type: 'test1'} as any) store.dispatch({type: 'test1'} as any)
store.dispatch({type: 'tes'} as any) store.dispatch({type: 'tes'} as any)
store.dispatch({type: 'test3'} as any) store.dispatch({type: 'test3'} as any)
expect(recorder.getActionTypes()).toEqual(['test1', 'test3']) expect(recorder.getActionTypes()).toEqual({
test1: 1,
test3: 1,
})
})
it('does not wait for actions that have already resolved', async () => {
const wm = new WaitMiddleware()
const store = getStore(wm)
const recorder = wm.record()
store.dispatch({
payload: undefined,
type: 'B',
status: 'pending',
})
store.dispatch({
payload: undefined,
type: 'B',
status: 'resolved',
})
await wm.waitForRecorded(recorder)
})
it('errors out if an action is rejected', async () => {
const wm = new WaitMiddleware()
const store = getStore(wm)
const recorder = wm.record()
store.dispatch({
payload: undefined,
type: 'B',
status: 'pending',
})
const error = new Error('test')
store.dispatch({
payload: error,
type: 'A',
status: 'rejected',
})
const error2 = await getError(wm.waitForRecorded(recorder))
expect(error2).toBe(error)
}) })
}) })

View File

@ -16,8 +16,12 @@ export class WaitMiddleware {
/** /**
* Starts recording actions and returns the recorder. * Starts recording actions and returns the recorder.
*/ */
record(shouldRecord: ShouldRecord = defaultShouldRecord): Recorder { record(
const recorder = new Recorder(shouldRecord) shouldRecord: ShouldRecord = defaultShouldRecord,
shouldResolve: ShouldRecord = defaultShouldResolve,
shouldReject: ShouldRecord = defaultShouldReject,
): Recorder {
const recorder = new Recorder(shouldRecord, shouldResolve, shouldReject)
this.recorders.push(recorder) this.recorders.push(recorder)
return recorder return recorder
} }
@ -36,6 +40,10 @@ export class WaitMiddleware {
*/ */
async waitForRecorded(recorder: Recorder, timeout?: number): Promise<void> { async waitForRecorded(recorder: Recorder, timeout?: number): Promise<void> {
this.stopRecording(recorder) this.stopRecording(recorder)
const error = recorder.getError()
if (error) {
return Promise.reject(error)
}
await this.wait(recorder.getActionTypes(), timeout) await this.wait(recorder.getActionTypes(), timeout)
} }
@ -43,19 +51,17 @@ export class WaitMiddleware {
* Waits for actions to be resolved or rejected. Times out after 10 seconds * Waits for actions to be resolved or rejected. Times out after 10 seconds
* by default. * by default.
*/ */
async wait(actions: string[], timeout = 10000): Promise<void> { async wait(actions: Record<string, number>, timeout = 10000): Promise<void> {
if (this.notify) { if (this.notify) {
throw new Error('WaitMiddleware.wait - already waiting!') throw new Error('WaitMiddleware.wait - already waiting!')
} }
const actionsByName = actions.reduce((obj, type) => { let count = Object.keys(actions).reduce((count, type) => {
obj[type] = (obj[type] || 0) + 1 return count + actions[type]
return obj }, 0)
}, {} as Record<string, number>)
let count = actions.length
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!actions.length) { if (!count) {
resolve() resolve()
this.notify = undefined this.notify = undefined
return return
@ -67,14 +73,14 @@ export class WaitMiddleware {
}, timeout) }, timeout)
this.notify = (action: AsyncAction<unknown, string>) => { this.notify = (action: AsyncAction<unknown, string>) => {
if (!actionsByName[action.type]) { if (!actions[action.type]) {
return return
} }
switch (action.status) { switch (action.status) {
case 'pending': case 'pending':
return return
case 'resolved': case 'resolved':
actionsByName[action.type]-- actions[action.type]--
count-- count--
if (count === 0) { if (count === 0) {
clearTimeout(t) clearTimeout(t)
@ -100,19 +106,39 @@ export type ShouldRecord = (action: AnyAction) => boolean
export const defaultShouldRecord: ShouldRecord = export const defaultShouldRecord: ShouldRecord =
(action: AnyAction) => 'status' in action && action.status === 'pending' (action: AnyAction) => 'status' in action && action.status === 'pending'
export const defaultShouldResolve: ShouldRecord =
(action: AnyAction) => 'status' in action && action.status === 'resolved'
export const defaultShouldReject: ShouldRecord =
(action: AnyAction) => 'status' in action && action.status === 'rejected'
class Recorder { class Recorder {
protected actionTypes: string[] = [] protected actionTypes: Record<string, number> = {}
protected error?: Error
constructor(protected readonly shouldRecord: ShouldRecord) {} constructor(
protected readonly shouldRecord: ShouldRecord,
protected readonly shouldResolve: ShouldRecord,
protected readonly shouldReject: ShouldRecord,
) {}
getActionTypes(): string[] { getActionTypes() {
return this.actionTypes.slice() return {...this.actionTypes}
}
getError() {
return this.error
} }
record(action: AnyAction) { record(action: AnyAction) {
if (this.shouldRecord(action)) { if (this.shouldRecord(action)) {
this.actionTypes.push(action.type) this.actionTypes[action.type] = (this.actionTypes[action.type] || 0) + 1
}
if (this.shouldResolve(action)) {
this.actionTypes[action.type] = (this.actionTypes[action.type] || 0) - 1
}
if (this.shouldReject(action)) {
this.actionTypes[action.type] = (this.actionTypes[action.type] || 0) - 1
this.error = action.payload
} }
} }
} }