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
- [x] SSR
- [ ] 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] Extract database into a separate module
@ -55,7 +55,7 @@
- [ ] 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] 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
- [ ] Email

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,8 +13,8 @@ describe('Captcha', () => {
let node: Element
let component: Captcha
function render() {
const result = t.render(
async function render() {
const result = await t.render(
<Captcha
audioUrl='/captcha.opus'
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<{}> {
ref = React.createRef<HTMLDivElement>()
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 Utils = T
render(jsx: JSX.Element) {
async render(jsx: JSX.Element) {
const $div = document.createElement('div')
const component = ReactDOM.render(
<TestContainer>
<ThemeProvider theme={TestUtils.defaultTheme}>
{jsx}
</ThemeProvider>
</TestContainer>,
$div,
) as unknown as TestContainer
const node = component.ref.current!.children[0]
const component: TestContainer | null = await new Promise(resolve => {
ReactDOM.render(
<TestContainer ref={instance => resolve(instance)}>
<ThemeProvider theme={TestUtils.defaultTheme}>
{jsx}
</ThemeProvider>
</TestContainer>,
$div,
)
})
if (component === null) {
throw new Error('TestContainer is null, this should not happen')
}
return {
component,
node,
node: $div.children[0].children[0],
}
}
@ -90,14 +98,14 @@ export class TestUtils {
let createJSX: CreateJSX | undefined
const render = (props: Props) => {
const render = async (props: Props) => {
const recorder = waitMiddleware.record()
const jsx = createJSX
? createJSX(Component, props)
: <Component {...props} />
const result = this.render(
const result = await this.render(
<Provider store={store}>
{jsx}
</Provider>,
@ -132,11 +140,11 @@ export class TestUtils {
}
interface Self<Props, Store, Component, CreateJSX> {
render: (props: Props) => {
render: (props: Props) => Promise<{
component: TestContainer
node: Element
waitForActions(timeout?: number): Promise<void>
}
}>
store: Store
Component: Component
withJSX: (localCreateJSX: CreateJSX)

View File

@ -17,7 +17,7 @@ describe('WaitMiddleware', () => {
it('waits for certain async actions to be resolved', async () => {
const wm = new WaitMiddleware()
const store = getStore(wm)
const promise = wm.wait(['B', 'C'])
const promise = wm.wait({B: 1, C: 1})
store.dispatch({
payload: undefined,
type: 'A',
@ -40,7 +40,7 @@ describe('WaitMiddleware', () => {
it('times out when actions do not happen', async () => {
const wm = new WaitMiddleware()
const store = getStore(wm)
const promise = wm.wait(['B', 'C'], 5)
const promise = wm.wait({B: 1, C: 1}, 5)
store.dispatch({
payload: undefined,
type: 'A',
@ -58,7 +58,7 @@ describe('WaitMiddleware', () => {
it('errors out when a promise is rejected', async () => {
const wm = new WaitMiddleware()
const store = getStore(wm)
const promise = wm.wait(['B', 'C'])
const promise = wm.wait({B: 1, C: 1})
store.dispatch({
payload: undefined,
type: 'A',
@ -76,8 +76,8 @@ describe('WaitMiddleware', () => {
it('errors out when wait is called twice', async () => {
const wm = new WaitMiddleware()
const store = getStore(wm)
const promise = wm.wait(['B'])
const error = await getError(wm.wait(['B']))
const promise = wm.wait({B: 1})
const error = await getError(wm.wait({B: 1}))
expect(error.message).toMatch(/already waiting/)
store.dispatch({
payload: new Error('test'),
@ -90,7 +90,7 @@ describe('WaitMiddleware', () => {
it('does nothing when pending is dispatched', async () => {
const wm = new WaitMiddleware()
const store = getStore(wm)
const promise = wm.wait(['B'], 1)
const promise = wm.wait({B: 1}, 1)
store.dispatch({
payload: undefined,
type: 'B',
@ -102,7 +102,7 @@ describe('WaitMiddleware', () => {
it('resolved immediately when no actions are defined', async () => {
const wm = new WaitMiddleware()
await wm.wait([])
await wm.wait({})
})
})
@ -129,11 +129,54 @@ describe('WaitMiddleware', () => {
it('records custom actions', async () => {
const wm = new WaitMiddleware()
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: 'tes'} 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.
*/
record(shouldRecord: ShouldRecord = defaultShouldRecord): Recorder {
const recorder = new Recorder(shouldRecord)
record(
shouldRecord: ShouldRecord = defaultShouldRecord,
shouldResolve: ShouldRecord = defaultShouldResolve,
shouldReject: ShouldRecord = defaultShouldReject,
): Recorder {
const recorder = new Recorder(shouldRecord, shouldResolve, shouldReject)
this.recorders.push(recorder)
return recorder
}
@ -36,6 +40,10 @@ export class WaitMiddleware {
*/
async waitForRecorded(recorder: Recorder, timeout?: number): Promise<void> {
this.stopRecording(recorder)
const error = recorder.getError()
if (error) {
return Promise.reject(error)
}
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
* by default.
*/
async wait(actions: string[], timeout = 10000): Promise<void> {
async wait(actions: Record<string, number>, timeout = 10000): Promise<void> {
if (this.notify) {
throw new Error('WaitMiddleware.wait - already waiting!')
}
const actionsByName = actions.reduce((obj, type) => {
obj[type] = (obj[type] || 0) + 1
return obj
}, {} as Record<string, number>)
let count = actions.length
let count = Object.keys(actions).reduce((count, type) => {
return count + actions[type]
}, 0)
return new Promise((resolve, reject) => {
if (!actions.length) {
if (!count) {
resolve()
this.notify = undefined
return
@ -67,14 +73,14 @@ export class WaitMiddleware {
}, timeout)
this.notify = (action: AsyncAction<unknown, string>) => {
if (!actionsByName[action.type]) {
if (!actions[action.type]) {
return
}
switch (action.status) {
case 'pending':
return
case 'resolved':
actionsByName[action.type]--
actions[action.type]--
count--
if (count === 0) {
clearTimeout(t)
@ -100,19 +106,39 @@ export type ShouldRecord = (action: AnyAction) => boolean
export const defaultShouldRecord: ShouldRecord =
(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 {
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[] {
return this.actionTypes.slice()
getActionTypes() {
return {...this.actionTypes}
}
getError() {
return this.error
}
record(action: AnyAction) {
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
}
}
}