Make TestUtils.tsx type safe

After spending almost two days in finding the issue, I ran across a few
TypeScript issues on their GitHub page:

- Loss of type inference converting to named parameters object
  https://github.com/Microsoft/TypeScript/issues/29791

- Parameter of a callback without a specified type next to it breaks code.
  https://github.com/Microsoft/TypeScript/issues/29799

- Convert to named parameters
  https://github.com/Microsoft/TypeScript/pull/30089

It became clear that TypeScript is unable to infer method return
arguments if a generic type is used more than once in generic parameter
object. Instead it returns {}.

For example, the following would fail on line 28:

    type Convert<A, B> = (value: A) => B

    interface IParams<C, D> {
      value: C
      convert: Convert<C, D>
      doConvert: (value: C, convert: this['convert']) => D
    }

    function doSomething<E, F>(value: E, convert: Convert<E, F>) {
      return convert(value)
    }

    function build<G, H>(params: IParams<G, H>) {
      const {value, convert} = params
      return params.doConvert(value, convert)
    }

    const outerResult = build({
      value: {
        a: {
          value: 1,
        },
        b: 'string',
      },
      convert: value => value.a,
      doConvert: (value, convert) => {
        const innerResult = doSomething(value, convert)
        innerResult.value
        console.log('innerResult:', innerResult)
        return innerResult
      },
    })

    console.log('outerResult:', outerResult)

With the message:

    Property 'value' does not exist on type '{}'.

If we replace parameter object IParams with regular ordered function
parameters, the compilation succeeds.

RyanCavanough (TS project lead) from GitHub commented:

> We don't have a separate pass to say "Go dive into the function and
> check to see if all its return statements don't rely on its parameter
> type" - doing so would be expensive in light of the fact that extremely
> few real-world functions actually behave like that in practice.

Source: https://github.com/Microsoft/TypeScript/issues/29799#issuecomment-464154659

These modifications bring type safety to TestUtils.tsx, and therefore
client-side tests of React components, while keeping almost the same
ease of use as before.
This commit is contained in:
Jerko Steiner 2019-03-23 15:48:52 +08:00
parent 0af4aa9554
commit 3595a71cec
4 changed files with 87 additions and 36 deletions

View File

@ -15,18 +15,21 @@ describe('LoginForm', () => {
const createTestProvider = () => test.withProvider({
reducers: {Login: Feature.Login},
connector: new Feature.LoginConnector(loginActions),
select: state => state.Login,
customJSX: (Component, props) =>
<MemoryRouter><Component {...props} /></MemoryRouter>,
})
.withComponent(
select => new Feature.LoginConnector(loginActions).connect(select),
)
.withJSX((Component, props) =>
<MemoryRouter><Component {...props} /></MemoryRouter>,
)
beforeAll(() => {
(window as any).__MOCK_SERVER_SIDE__ = true
})
it('should render', () => {
createTestProvider().render()
createTestProvider().render({})
})
describe('submit', () => {

View File

@ -13,16 +13,18 @@ describe('RegisterForm', () => {
const createTestProvider = () => test.withProvider({
reducers: {Login: Feature.Login},
connector: new Feature.RegisterConnector(loginActions),
select: state => state.Login,
})
.withComponent(
select => new Feature.RegisterConnector(loginActions).connect(select),
)
beforeAll(() => {
(window as any).__MOCK_SERVER_SIDE__ = true
})
it('should render', () => {
createTestProvider().render()
createTestProvider().render({})
})
describe('submit', () => {

View File

@ -2,7 +2,8 @@ import * as Feature from './'
// export ReactDOM from 'react-dom'
// import T from 'react-dom/test-utils'
import {HTTPClientMock, TestUtils/*, getError*/} from '../test-utils'
import {IAPIDef, ITeam} from '@rondo/common'
import {IAPIDef, ITeam, IUserInTeam} from '@rondo/common'
import React from 'react'
const test = new TestUtils()
@ -13,18 +14,37 @@ describe('TeamConnector', () => {
const createTestProvider = () => test.withProvider({
reducers: {Team: Feature.Team},
connector: new Feature.TeamConnector(teamActions),
select: state => state.Team,
})
.withComponent(select =>
new Feature
.TeamConnector(teamActions)
.connect(select))
.withJSX((Component, props) => <Component {...props} />)
const teams: ITeam[] = [{id: 100, name: 'my-team', userId: 1}]
const users: IUserInTeam[] = [{
teamId: 123,
userId: 1,
displayName: 'test test',
roleId: 1,
roleName: 'ADMIN',
}]
it('it fetches user teams on render', async () => {
http.mockAdd({
method: 'get',
url: '/my/teams',
}, teams)
const {node} = createTestProvider().render()
http.mockAdd({
method: 'get',
url: '/teams/:teamId/users',
params: {
teamId: 123,
},
}, users)
const {node} = createTestProvider().render({editTeamId: 123})
await http.wait()
expect(node.innerHTML).toContain('my-team')
})

View File

@ -1,7 +1,7 @@
import React from 'react'
import ReactDOM from 'react-dom'
import T from 'react-dom/test-utils'
import {Connector, IStateSelector} from '../redux'
import {IStateSelector} from '../redux'
import {Provider} from 'react-redux'
import {createStore} from '../store'
import {
@ -13,15 +13,16 @@ import {
combineReducers,
} from 'redux'
interface IRenderParams<State> {
interface IRenderParams<State, LocalState> {
reducers: ReducersMapObject<State, any>
state?: DeepPartial<State>
connector: Connector<any>
select: IStateSelector<State, any>
customJSX?: (
Component: React.ComponentType<any>,
additionalProps: Record<string, any>,
) => JSX.Element
select: IStateSelector<State, LocalState>
// getComponent: (
// select: IStateSelector<State, LocalState>) => React.ComponentType<Props>,
// customJSX?: (
// Component: React.ComponentType<Props>,
// props: Props,
// ) => JSX.Element
}
export class TestUtils {
@ -47,29 +48,54 @@ export class TestUtils {
* Creates a redux store, connects a component, and provides the `render`
* method to render the connected component with a `Provider`.
*/
withProvider<State, A extends Action<any> = AnyAction>(
params: IRenderParams<State>,
withProvider<State, LocalState, A extends Action<any> = AnyAction>(
params: IRenderParams<State, LocalState>,
) {
const {reducers, state, select} = params
const store = this.createStore({
reducer: this.combineReducers(params.reducers),
})(params.state)
const Component = params.connector.connect(params.select)
reducer: this.combineReducers(reducers),
})(state)
const render = (additionalProps: Record<string, any> = {}) => {
const jsx = params.customJSX
? params.customJSX(Component, additionalProps)
: <Component {...additionalProps} />
return this.render(
<Provider store={store}>
{jsx}
</Provider>,
)
const withComponent = <Props extends {}>(
getComponent: (select: IStateSelector<State, LocalState>) =>
React.ComponentType<Props>,
) => {
const Component = getComponent(select)
type CreateJSX = (
Component: React.ComponentType<Props>,
props: Props,
) => JSX.Element
let createJSX: CreateJSX | undefined
const render = (props: Props) => {
const jsx = createJSX
? createJSX(Component, props)
: <Component {...props} />
return this.render(
<Provider store={store}>
{jsx}
</Provider>,
)
}
const withJSX = (localCreateJSX: CreateJSX) => {
createJSX = localCreateJSX
return self
}
const self = {
render,
store,
Component,
withJSX,
}
return self
}
return {
render,
store,
Component,
}
return {withComponent}
}
}