From bdf0aa57be9e7a6847daad462ceebdd063fefbb1 Mon Sep 17 00:00:00 2001 From: Jerko Steiner Date: Sun, 20 Jan 2019 18:11:18 +0100 Subject: [PATCH] Add TestUtils and a generic way to isolate connected components --- packages/client/src/components/Button.tsx | 4 + packages/client/src/components/Input.tsx | 9 ++- packages/client/src/http/HTTPClient.ts | 29 +++++-- .../client/src/http/HTTPClientMock.test.ts | 26 ++++++ packages/client/src/http/HTTPClientMock.ts | 44 ++++++++++ packages/client/src/http/IRequest.ts | 8 ++ packages/client/src/http/IResponse.ts | 3 + packages/client/src/login/LoginConnector.tsx | 44 +++++----- packages/client/src/login/LoginForm.tsx | 15 ++-- packages/client/src/login/LoginReducer.ts | 2 + packages/client/src/login/index.test.tsx | 22 +++++ packages/client/src/login/index.ts | 4 + .../src/middleware/PromiseMiddleware.test.ts | 12 +-- packages/client/src/redux/Connector.ts | 36 +++++++++ packages/client/src/redux/IStateSelector.ts | 2 + packages/client/src/redux/IStateSlicer.ts | 3 - packages/client/src/redux/index.ts | 3 +- packages/client/src/redux/temp-test.tsx | 34 ++++++++ packages/client/src/test-utils/TestUtils.tsx | 81 +++++++++++++++++++ packages/client/src/test-utils/getError.ts | 10 +++ packages/client/src/test-utils/index.ts | 2 + 21 files changed, 334 insertions(+), 59 deletions(-) create mode 100644 packages/client/src/http/HTTPClientMock.test.ts create mode 100644 packages/client/src/http/HTTPClientMock.ts create mode 100644 packages/client/src/http/IRequest.ts create mode 100644 packages/client/src/http/IResponse.ts create mode 100644 packages/client/src/login/index.test.tsx create mode 100644 packages/client/src/login/index.ts create mode 100644 packages/client/src/redux/Connector.ts create mode 100644 packages/client/src/redux/IStateSelector.ts delete mode 100644 packages/client/src/redux/IStateSlicer.ts create mode 100644 packages/client/src/redux/temp-test.tsx create mode 100644 packages/client/src/test-utils/TestUtils.tsx create mode 100644 packages/client/src/test-utils/getError.ts create mode 100644 packages/client/src/test-utils/index.ts diff --git a/packages/client/src/components/Button.tsx b/packages/client/src/components/Button.tsx index bb9720e..4f8c821 100644 --- a/packages/client/src/components/Button.tsx +++ b/packages/client/src/components/Button.tsx @@ -1,5 +1,9 @@ import React from 'react' +export interface IButtonProps { + type: string +} + export class Button extends React.PureComponent { render() { return ( diff --git a/packages/client/src/components/Input.tsx b/packages/client/src/components/Input.tsx index 703b7cd..cebf0fa 100644 --- a/packages/client/src/components/Input.tsx +++ b/packages/client/src/components/Input.tsx @@ -2,15 +2,16 @@ import React from 'react' export interface IInputProps { name: string - type: 'text' | 'password' | 'hidden' + type: 'text' | 'password' | 'hidden' | 'submit' value?: string - onChange?: (name: string, value: string) => void + onChange?: (name: this['name'], value: string) => void + readOnly?: boolean } export class Input extends React.PureComponent { handleChange = (e: React.ChangeEvent) => { if (this.props.onChange) { - this.props.onChange(e.target.name, e.target.value) + this.props.onChange(this.props.name, e.target.value) } } render() { @@ -19,6 +20,8 @@ export class Input extends React.PureComponent { name={this.props.name} type={this.props.type} value={this.props.value} + onChange={this.handleChange} + readOnly={!!this.props.readOnly} /> ) } diff --git a/packages/client/src/http/HTTPClient.ts b/packages/client/src/http/HTTPClient.ts index ed34eb7..eff5585 100644 --- a/packages/client/src/http/HTTPClient.ts +++ b/packages/client/src/http/HTTPClient.ts @@ -1,17 +1,30 @@ import assert from 'assert' -import axios, {AxiosInstance} from 'axios' +import axios from 'axios' import {IHTTPClient} from './IHTTPClient' import {IHeader} from './IHeader' import {IMethod, IRoutes} from '@rondo/common' +import {IRequest} from './IRequest' +import {IResponse} from './IResponse' import {ITypedRequestParams} from './ITypedRequestParams' -export class HTTPClient implements IHTTPClient { - protected readonly axios: AxiosInstance +interface IRequestor { + request: (params: IRequest) => Promise +} - constructor(baseURL = '', headers?: IHeader) { - this.axios = axios.create({ - baseURL, - headers, +export class HTTPClient implements IHTTPClient { + protected readonly requestor: IRequestor + + constructor( + protected readonly baseURL = '', + protected readonly headers?: IHeader, + ) { + this.requestor = this.createRequestor() + } + + protected createRequestor(): IRequestor { + return axios.create({ + baseURL: this.baseURL, + headers: this.headers, }) } @@ -27,7 +40,7 @@ export class HTTPClient implements IHTTPClient { return params.params![key] }) - const response = await this.axios.request({ + const response = await this.requestor.request({ method: params.method, url, params: params.query, diff --git a/packages/client/src/http/HTTPClientMock.test.ts b/packages/client/src/http/HTTPClientMock.test.ts new file mode 100644 index 0000000..a916907 --- /dev/null +++ b/packages/client/src/http/HTTPClientMock.test.ts @@ -0,0 +1,26 @@ +import {HTTPClientMock} from './HTTPClientMock' +import {getError} from '../test-utils' + +describe('HTTPClientMock', () => { + + const http = new HTTPClientMock() + + describe('mockAdd and mockClear', () => { + it('adds a mock', async () => { + const value = {a: 1} + http.mockAdd({ + method: 'get', + url: '/test', + }, value) + + const result = await http.get('/test') + expect(result).toBe(value) + + http.mockClear() + + const error = await getError(http.get('/test')) + expect(error.message).toMatch(/mock/i) + }) + }) + +}) diff --git a/packages/client/src/http/HTTPClientMock.ts b/packages/client/src/http/HTTPClientMock.ts new file mode 100644 index 0000000..a56449d --- /dev/null +++ b/packages/client/src/http/HTTPClientMock.ts @@ -0,0 +1,44 @@ +import {HTTPClient} from './HTTPClient' +import {IRoutes} from '@rondo/common' +import {IRequest} from './IRequest' +import {IResponse} from './IResponse' + +export class HTTPClientMock extends HTTPClient { + mocks: {[key: string]: any} = {} + + constructor() { + super() + } + + createRequestor() { + return { + request: (r: IRequest): Promise => { + return new Promise((resolve, reject) => { + const key = this.serialize(r) + if (!this.mocks.hasOwnProperty(key)) { + setImmediate(() => { + reject(new Error('No mock for request: ' + key)) + }) + return + } + setImmediate(() => { + resolve({data: this.mocks[key]}) + }) + }) + }, + } + } + + serialize(r: IRequest) { + return JSON.stringify(r, null, ' ') + } + + mockAdd(r: IRequest, response: any) { + this.mocks[this.serialize(r)] = response + } + + mockClear() { + this.mocks = [] + } + +} diff --git a/packages/client/src/http/IRequest.ts b/packages/client/src/http/IRequest.ts new file mode 100644 index 0000000..9cfdf23 --- /dev/null +++ b/packages/client/src/http/IRequest.ts @@ -0,0 +1,8 @@ +import {IMethod} from '@rondo/common' + +export interface IRequest { + method: IMethod, + url: string, + params?: {[key: string]: any}, + data?: any, +} diff --git a/packages/client/src/http/IResponse.ts b/packages/client/src/http/IResponse.ts new file mode 100644 index 0000000..41964ab --- /dev/null +++ b/packages/client/src/http/IResponse.ts @@ -0,0 +1,3 @@ +export interface IResponse { + data: any +} diff --git a/packages/client/src/login/LoginConnector.tsx b/packages/client/src/login/LoginConnector.tsx index 78b09a0..3960739 100644 --- a/packages/client/src/login/LoginConnector.tsx +++ b/packages/client/src/login/LoginConnector.tsx @@ -1,35 +1,27 @@ import {ILoginState} from './LoginReducer' import {LoginActions} from './LoginActions' import {LoginForm} from './LoginForm' -import {bindActionCreators, Dispatch} from 'redux' -import {connect} from 'react-redux' -import {IStateSlicer} from '../redux' +import {bindActionCreators} from 'redux' +import {IStateSelector} from '../redux' +import {Connector} from '../redux/Connector' -export class LoginConnector { - constructor( - protected readonly loginActions: LoginActions, - protected readonly slice: IStateSlicer, - ) {} +export class LoginConnector extends Connector { - connect() { - return connect( - this.mapStateToProps, - this.mapDispatchToProps, - )(LoginForm) + constructor(protected readonly loginActions: LoginActions) { + super() } - mapStateToProps = (globalState: GlobalState) => { - const state = this.slice(globalState) - return { - csrfToken: '123', // TODO this should be read from the state too - error: state.error, - user: state.user, - } - } - - mapDispatchToProps = (dispatch: Dispatch) => { - return { - onSubmit: bindActionCreators(this.loginActions.logIn, dispatch), - } + connect(getLocalState: IStateSelector) { + return this.wrap( + getLocalState, + state => ({ + error: state.error, + user: state.user, + }), + dispatch => ({ + onSubmit: bindActionCreators(this.loginActions.logIn, dispatch), + }), + LoginForm, + ) } } diff --git a/packages/client/src/login/LoginForm.tsx b/packages/client/src/login/LoginForm.tsx index 20eba22..af2ac97 100644 --- a/packages/client/src/login/LoginForm.tsx +++ b/packages/client/src/login/LoginForm.tsx @@ -4,7 +4,6 @@ import {ICredentials} from '@rondo/common' export interface ILoginFormProps { error?: string - csrfToken: string onSubmit: (credentials: ICredentials) => void } @@ -25,7 +24,7 @@ export class LoginForm extends React.PureComponent< handleSubmit = () => { this.props.onSubmit(this.state) } - handleChange = (name: keyof ILoginFormState, value: string) => { + handleChange = (name: string, value: string) => { this.setState( {[name]: value} as Pick, ) @@ -33,21 +32,23 @@ export class LoginForm extends React.PureComponent< render() { return (
- +
) } diff --git a/packages/client/src/login/LoginReducer.ts b/packages/client/src/login/LoginReducer.ts index 568749c..eced190 100644 --- a/packages/client/src/login/LoginReducer.ts +++ b/packages/client/src/login/LoginReducer.ts @@ -22,5 +22,7 @@ export function Login( return {...state, user: undefined} case LoginActionKeys.USER_LOG_IN_REJECTED: return {...state, error: action.error.message} + default: + return state } } diff --git a/packages/client/src/login/index.test.tsx b/packages/client/src/login/index.test.tsx new file mode 100644 index 0000000..3a851c0 --- /dev/null +++ b/packages/client/src/login/index.test.tsx @@ -0,0 +1,22 @@ +import * as Feature from './' +// import React from 'react' +import {TestUtils} from '../test-utils' + +const test = new TestUtils() + +describe('Login', () => { + + const loginActions = new Feature.LoginActions({} as any) + + const t = test.withProvider({ + reducers: {Login: Feature.Login}, + state: {Login: {user: {id: 1}}}, + connector: new Feature.LoginConnector(loginActions), + select: state => state.Login, + }) + + it('should render', () => { + t.render() + }) + +}) diff --git a/packages/client/src/login/index.ts b/packages/client/src/login/index.ts new file mode 100644 index 0000000..9f11045 --- /dev/null +++ b/packages/client/src/login/index.ts @@ -0,0 +1,4 @@ +export * from './LoginActions' +export * from './LoginConnector' +export * from './LoginForm' +export * from './LoginReducer' diff --git a/packages/client/src/middleware/PromiseMiddleware.test.ts b/packages/client/src/middleware/PromiseMiddleware.test.ts index e94fe4f..fd5ac88 100644 --- a/packages/client/src/middleware/PromiseMiddleware.test.ts +++ b/packages/client/src/middleware/PromiseMiddleware.test.ts @@ -1,19 +1,9 @@ import {createStore, applyMiddleware, Store} from 'redux' import {PromiseMiddleware} from './PromiseMiddleware' +import {getError} from '../test-utils' describe('PromiseMiddleware', () => { - async function getError(promise: Promise): Promise { - let error: Error - try { - await promise - } catch (err) { - error = err - } - expect(error!).toBeTruthy() - return error! - } - describe('constructor', () => { it('throws an error when action types are the same', () => { expect(() => new PromiseMiddleware('a', 'a', 'a')).toThrowError() diff --git a/packages/client/src/redux/Connector.ts b/packages/client/src/redux/Connector.ts new file mode 100644 index 0000000..bb52e1b --- /dev/null +++ b/packages/client/src/redux/Connector.ts @@ -0,0 +1,36 @@ +import {IStateSelector} from './IStateSelector' +import {connect, Omit} from 'react-redux' +import {Dispatch} from 'redux' +import {ComponentType} from 'react' + +// https://stackoverflow.com/questions/54277411 +export abstract class Connector { + + abstract connect( + getLocalState: IStateSelector, + ): ComponentType + + protected wrap< + State, + LocalState, + StateProps, + DispatchProps, + Props + >( + getLocalState: IStateSelector, + mapStateToProps: (state: LocalState) => StateProps, + mapDispatchToProps: (dispatch: Dispatch) => DispatchProps, + Component: React.ComponentType, + ): ComponentType< + Omit + > { + + return connect( + (state: State) => { + const l = getLocalState(state) + return mapStateToProps(l) + }, + mapDispatchToProps, + )(Component as any) as any + } +} diff --git a/packages/client/src/redux/IStateSelector.ts b/packages/client/src/redux/IStateSelector.ts new file mode 100644 index 0000000..f75dc98 --- /dev/null +++ b/packages/client/src/redux/IStateSelector.ts @@ -0,0 +1,2 @@ +export type IStateSelector + = (state: GlobalState) => StateSlice diff --git a/packages/client/src/redux/IStateSlicer.ts b/packages/client/src/redux/IStateSlicer.ts deleted file mode 100644 index 77577b1..0000000 --- a/packages/client/src/redux/IStateSlicer.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type IStateSlicer - = (state: GlobalState) => StateSlice - diff --git a/packages/client/src/redux/index.ts b/packages/client/src/redux/index.ts index ff10871..60a3dee 100644 --- a/packages/client/src/redux/index.ts +++ b/packages/client/src/redux/index.ts @@ -1 +1,2 @@ -export * from './IStateSlicer' +export * from './Connector' +export * from './IStateSelector' diff --git a/packages/client/src/redux/temp-test.tsx b/packages/client/src/redux/temp-test.tsx new file mode 100644 index 0000000..0fcd67d --- /dev/null +++ b/packages/client/src/redux/temp-test.tsx @@ -0,0 +1,34 @@ +// import React from 'react' +// import {connect, Omit} from 'react-redux' + +// interface IProps { +// a: number +// } + +// class NumberDisplay extends React.PureComponent { +// render() { +// return `${this.props.a}` +// } +// } + +// // Case 1: this works +// function mapStateToProps(state: any) { +// return {a: 1} +// } + +// const ConnectedNumberDisplay1 = connect(mapStateToProps)(NumberDisplay) + +// export const display1 = + +// // Case 2: this doesn't work +// function wrap( +// mapState: (state: State) => StateProps, +// Component: React.ComponentType, +// ): React.ComponentType< +// Omit +// > { +// return connect(mapState)(Component as any) as any +// } + +// const ConnectedNumberDisplay2 = wrap(mapStateToProps, NumberDisplay) +// export const display2 = diff --git a/packages/client/src/test-utils/TestUtils.tsx b/packages/client/src/test-utils/TestUtils.tsx new file mode 100644 index 0000000..02c1c97 --- /dev/null +++ b/packages/client/src/test-utils/TestUtils.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import T from 'react-dom/test-utils' +import {Connector, IStateSelector} from '../redux' +import {Provider} from 'react-redux' +import {PromiseMiddleware} from '../middleware' +import { + Action, + AnyAction, + DeepPartial, + Middleware, + Reducer, + ReducersMapObject, + Store, + applyMiddleware, + combineReducers, + createStore, +} from 'redux' + +interface IStoreParams> { + reducer: Reducer + state?: DeepPartial + middleware?: Middleware[] +} + +interface IRenderParams { + reducers: ReducersMapObject + state?: DeepPartial + connector: Connector + select: IStateSelector +} + +export class TestUtils { + render(jsx: JSX.Element) { + const component = T.renderIntoDocument(jsx) as React.Component + const node = ReactDOM.findDOMNode(component) + return {component, node} + } + + combineReducers(reducers: ReducersMapObject): Reducer + combineReducers( + reducers: ReducersMapObject, + ): Reducer { + return combineReducers(reducers) + } + + createStore = AnyAction>( + params: IStoreParams, + ): Store { + const middleware = params.middleware || [new PromiseMiddleware().handle] + return createStore( + params.reducer, + params.state, + applyMiddleware(...middleware), + ) + } + + withProvider = AnyAction>( + params: IRenderParams, + ) { + const store = this.createStore({ + reducer: this.combineReducers(params.reducers), + state: params.state, + }) + const Component = params.connector.connect(params.select) + + const render = () => { + return this.render( + + + , + ) + } + + return { + render, + store, + Component, + } + } +} diff --git a/packages/client/src/test-utils/getError.ts b/packages/client/src/test-utils/getError.ts new file mode 100644 index 0000000..6c38bf8 --- /dev/null +++ b/packages/client/src/test-utils/getError.ts @@ -0,0 +1,10 @@ +export async function getError(promise: Promise): Promise { + let error: Error + try { + await promise + } catch (err) { + error = err + } + expect(error!).toBeTruthy() + return error! +} diff --git a/packages/client/src/test-utils/index.ts b/packages/client/src/test-utils/index.ts new file mode 100644 index 0000000..4567837 --- /dev/null +++ b/packages/client/src/test-utils/index.ts @@ -0,0 +1,2 @@ +export * from './TestUtils' +export * from './getError'