diff --git a/public/app/features/datasources/state/reducers.test.ts b/public/app/features/datasources/state/reducers.test.ts new file mode 100644 index 00000000000..540089f3e65 --- /dev/null +++ b/public/app/features/datasources/state/reducers.test.ts @@ -0,0 +1,137 @@ +import { reducerTester } from 'test/core/redux/reducerTester'; +import { dataSourcesReducer, initialState } from './reducers'; +import { + dataSourcesLoaded, + dataSourceLoaded, + setDataSourcesSearchQuery, + setDataSourcesLayoutMode, + dataSourceTypesLoad, + dataSourceTypesLoaded, + setDataSourceTypeSearchQuery, + dataSourceMetaLoaded, + setDataSourceName, + setIsDefault, +} from './actions'; +import { getMockDataSources, getMockDataSource } from '../__mocks__/dataSourcesMocks'; +import { LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector'; +import { DataSourcesState } from 'app/types'; +import { PluginMetaInfo } from '@grafana/ui'; + +const mockPlugin = () => ({ + defaultNavUrl: 'defaultNavUrl', + enabled: true, + hasUpdate: true, + id: 'id', + info: {} as PluginMetaInfo, + latestVersion: 'latestVersion', + name: 'name', + pinned: true, + state: 'state', + type: 'type', + module: {}, +}); + +describe('dataSourcesReducer', () => { + describe('when dataSourcesLoaded is dispatched', () => { + it('then state should be correct', () => { + const dataSources = getMockDataSources(0); + + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(dataSourcesLoaded(dataSources)) + .thenStateShouldEqual({ ...initialState, hasFetched: true, dataSources, dataSourcesCount: 1 }); + }); + }); + + describe('when dataSourceLoaded is dispatched', () => { + it('then state should be correct', () => { + const dataSource = getMockDataSource(); + + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(dataSourceLoaded(dataSource)) + .thenStateShouldEqual({ ...initialState, dataSource }); + }); + }); + + describe('when setDataSourcesSearchQuery is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(setDataSourcesSearchQuery('some query')) + .thenStateShouldEqual({ ...initialState, searchQuery: 'some query' }); + }); + }); + + describe('when setDataSourcesLayoutMode is dispatched', () => { + it('then state should be correct', () => { + const layoutMode: LayoutModes = LayoutModes.Grid; + + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(setDataSourcesLayoutMode(layoutMode)) + .thenStateShouldEqual({ ...initialState, layoutMode: LayoutModes.Grid }); + }); + }); + + describe('when dataSourceTypesLoad is dispatched', () => { + it('then state should be correct', () => { + const state: DataSourcesState = { ...initialState, dataSourceTypes: [mockPlugin()] }; + + reducerTester() + .givenReducer(dataSourcesReducer, state) + .whenActionIsDispatched(dataSourceTypesLoad()) + .thenStateShouldEqual({ ...initialState, dataSourceTypes: [], isLoadingDataSources: true }); + }); + }); + + describe('when dataSourceTypesLoaded is dispatched', () => { + it('then state should be correct', () => { + const dataSourceTypes = [mockPlugin()]; + const state: DataSourcesState = { ...initialState, isLoadingDataSources: true }; + + reducerTester() + .givenReducer(dataSourcesReducer, state) + .whenActionIsDispatched(dataSourceTypesLoaded(dataSourceTypes)) + .thenStateShouldEqual({ ...initialState, dataSourceTypes, isLoadingDataSources: false }); + }); + }); + + describe('when setDataSourceTypeSearchQuery is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(setDataSourceTypeSearchQuery('type search query')) + .thenStateShouldEqual({ ...initialState, dataSourceTypeSearchQuery: 'type search query' }); + }); + }); + + describe('when dataSourceMetaLoaded is dispatched', () => { + it('then state should be correct', () => { + const dataSourceMeta = mockPlugin(); + + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(dataSourceMetaLoaded(dataSourceMeta)) + .thenStateShouldEqual({ ...initialState, dataSourceMeta }); + }); + }); + + describe('when setDataSourceName is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(setDataSourceName('some name')) + .thenStateShouldEqual({ ...initialState, dataSource: { name: 'some name' } }); + }); + }); + + describe('when setIsDefault is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .givenReducer(dataSourcesReducer, initialState) + .whenActionIsDispatched(setIsDefault(true)) + .thenStateShouldEqual({ ...initialState, dataSource: { isDefault: true } }); + }); + }); +}); diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts index 20aa58c8594..451e2d4650c 100644 --- a/public/app/features/datasources/state/reducers.ts +++ b/public/app/features/datasources/state/reducers.ts @@ -15,7 +15,7 @@ import { import { LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector'; import { reducerFactory } from 'app/core/redux'; -const initialState: DataSourcesState = { +export const initialState: DataSourcesState = { dataSources: [], dataSource: {} as DataSourceSettings, layoutMode: LayoutModes.List, diff --git a/public/test/core/redux/reducerTester.test.ts b/public/test/core/redux/reducerTester.test.ts new file mode 100644 index 00000000000..871feb7e848 --- /dev/null +++ b/public/test/core/redux/reducerTester.test.ts @@ -0,0 +1,58 @@ +import { reducerFactory, actionCreatorFactory } from 'app/core/redux'; +import { reducerTester } from './reducerTester'; + +interface DummyState { + data: string[]; +} + +const initialState: DummyState = { + data: [], +}; + +const dummyAction = actionCreatorFactory('dummyAction').create(); + +const mutatingReducer = reducerFactory(initialState) + .addMapper({ + filter: dummyAction, + mapper: (state, action) => { + state.data.push(action.payload); + return state; + }, + }) + .create(); + +const okReducer = reducerFactory(initialState) + .addMapper({ + filter: dummyAction, + mapper: (state, action) => { + return { + ...state, + data: state.data.concat(action.payload), + }; + }, + }) + .create(); + +describe('reducerTester', () => { + describe('when reducer mutates state', () => { + it('then it should throw', () => { + expect(() => { + reducerTester() + .givenReducer(mutatingReducer, initialState) + .whenActionIsDispatched(dummyAction('some string')) + .thenStateShouldEqual({ ...initialState, data: ['some string'] }); + }).toThrow(); + }); + }); + + describe('when reducer does not mutate state', () => { + it('then it should not throw', () => { + expect(() => { + reducerTester() + .givenReducer(okReducer, initialState) + .whenActionIsDispatched(dummyAction('some string')) + .thenStateShouldEqual({ ...initialState, data: ['some string'] }); + }).not.toThrow(); + }); + }); +}); diff --git a/public/test/core/redux/reducerTester.ts b/public/test/core/redux/reducerTester.ts new file mode 100644 index 00000000000..f8d8c921767 --- /dev/null +++ b/public/test/core/redux/reducerTester.ts @@ -0,0 +1,79 @@ +import { Reducer } from 'redux'; + +import { ActionOf } from 'app/core/redux/actionCreatorFactory'; + +export interface Given { + givenReducer: (reducer: Reducer>, state: State) => When; +} + +export interface When { + whenActionIsDispatched: (action: ActionOf) => Then; +} + +export interface Then { + thenStateShouldEqual: (state: State) => Then; +} + +interface ObjectType extends Object { + [key: string]: any; +} + +const deepFreeze = (obj: T): T => { + Object.freeze(obj); + + const isNotException = (object: any, propertyName: any) => + typeof object === 'function' + ? propertyName !== 'caller' && propertyName !== 'callee' && propertyName !== 'arguments' + : true; + const hasOwnProp = Object.prototype.hasOwnProperty; + + if (obj && obj instanceof Object) { + const object: ObjectType = obj; + Object.getOwnPropertyNames(object).forEach(propertyName => { + const objectProperty: any = object[propertyName]; + if ( + hasOwnProp.call(object, propertyName) && + isNotException(object, propertyName) && + objectProperty && + (typeof objectProperty === 'object' || typeof objectProperty === 'function') && + Object.isFrozen(objectProperty) === false + ) { + deepFreeze(objectProperty); + } + }); + } + + return obj; +}; + +interface ReducerTester extends Given, When, Then {} + +export const reducerTester = (): Given => { + let reducerUnderTest: Reducer> = null; + let resultingState: State = null; + let initialState: State = null; + + const givenReducer = (reducer: Reducer>, state: State): When => { + reducerUnderTest = reducer; + initialState = { ...state }; + initialState = deepFreeze(initialState); + + return instance; + }; + + const whenActionIsDispatched = (action: ActionOf): Then => { + resultingState = reducerUnderTest(initialState, action); + + return instance; + }; + + const thenStateShouldEqual = (state: State): Then => { + expect(state).toEqual(resultingState); + + return instance; + }; + + const instance: ReducerTester = { thenStateShouldEqual, givenReducer, whenActionIsDispatched }; + + return instance; +};