Skip to content

Commit

Permalink
Implement remaining unit tests (#7)
Browse files Browse the repository at this point in the history
* Write tests for React Router+EUI helper components

* Update generate_breadcrumbs test

- add test suite for generateBreadcrumb() itself (in order to cover a missing branch)
- minor lint fixes
- remove unnecessary import from set_breadcrumbs test

* Write test for get_username util

+ update test to return a more consistent falsey value (null)

* Add test for SetupGuide

* [Refactor] Pull out various Kibana context mocks into separate files

- I'm creating a reusable useContext mock for shallow()ed enzyme components
+ add more documentation comments + examples

* Write tests for empty state components

+ test new usecontext shallow mock

* Empty state components: Add extra getUserName branch test

* Write test for app search index/routes

* Write tests for engine overview table

+ fix bonus bug

* Write Engine Overview tests

+ Update EngineOverview logic to account for issues found during tests :)
  - Move http to async/await syntax instead of promise syntax (works better with existing HttpServiceMock jest.fn()s)
  - hasValidData wasn't strict enough in type checking/object nest checking and was causing the app itself to crash (no bueno)

* Refactor EngineOverviewHeader test to use shallow + to full coverage

- missed adding this test during telemetry work
- switching to shallow and beforeAll reduces the test time from 5s to 4s!

* [Refactor] Pull out React Router history mocks into a test util helper

+ minor refactors/updates

* Add small tests to increase branch coverage

- mostly testing fallbacks or removing fallbacks in favor of strict type interface
- these are slightly obsessive so I'd also be fine ditching them if they aren't terribly valuable
  • Loading branch information
Constance authored and cee-chen committed Jun 26, 2020
1 parent 6356de5 commit f80d64d
Show file tree
Hide file tree
Showing 20 changed files with 741 additions and 123 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import '../../../test_utils/mock_shallow_usecontext';

import React from 'react';
import { shallow } from 'enzyme';
import { EuiEmptyPrompt, EuiCode, EuiLoadingContent } from '@elastic/eui';

jest.mock('../../utils/get_username', () => ({ getUserName: jest.fn() }));
import { getUserName } from '../../utils/get_username';

import { ErrorState, NoUserState, EmptyState, LoadingState } from './';

describe('ErrorState', () => {
it('renders', () => {
const wrapper = shallow(<ErrorState />);
const prompt = wrapper.find(EuiEmptyPrompt);

expect(prompt).toHaveLength(1);
expect(prompt.prop('title')).toEqual(<h2>Cannot connect to App Search</h2>);
});
});

describe('NoUserState', () => {
it('renders', () => {
const wrapper = shallow(<NoUserState />);
const prompt = wrapper.find(EuiEmptyPrompt);

expect(prompt).toHaveLength(1);
expect(prompt.prop('title')).toEqual(<h2>Cannot find App Search account</h2>);
});

it('renders with username', () => {
getUserName.mockImplementationOnce(() => 'dolores-abernathy');
const wrapper = shallow(<NoUserState />);
const prompt = wrapper.find(EuiEmptyPrompt).dive();

expect(prompt.find(EuiCode).prop('children')).toContain('dolores-abernathy');
});
});

describe('EmptyState', () => {
it('renders', () => {
const wrapper = shallow(<EmptyState />);
const prompt = wrapper.find(EuiEmptyPrompt);

expect(prompt).toHaveLength(1);
expect(prompt.prop('title')).toEqual(<h2>There’s nothing here yet</h2>);
});
});

describe('LoadingState', () => {
it('renders', () => {
const wrapper = shallow(<LoadingState />);

expect(wrapper.find(EuiLoadingContent)).toHaveLength(2);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import '../../../test_utils/mock_rr_usehistory';

import React from 'react';
import { act } from 'react-dom/test-utils';
import { render } from 'enzyme';

import { KibanaContext } from '../../../';
import { mountWithKibanaContext, mockKibanaContext } from '../../../test_utils';

import { EmptyState, ErrorState, NoUserState } from '../empty_states';
import { EngineTable } from './engine_table';

import { EngineOverview } from './';

describe('EngineOverview', () => {
describe('non-happy-path states', () => {
it('isLoading', () => {
// We use render() instead of mount() here to not trigger lifecycle methods (i.e., useEffect)
const wrapper = render(
<KibanaContext.Provider value={{ http: {} }}>
<EngineOverview />
</KibanaContext.Provider>
);

// render() directly renders HTML which means we have to look for selectors instead of for LoadingState directly
expect(wrapper.find('.euiLoadingContent')).toHaveLength(2);
});

it('isEmpty', async () => {
const wrapper = await mountWithApiMock({
get: () => ({
results: [],
meta: { page: { total_results: 0 } },
}),
});

expect(wrapper.find(EmptyState)).toHaveLength(1);
});

it('hasErrorConnecting', async () => {
const wrapper = await mountWithApiMock({
get: () => ({ invalidPayload: true }),
});
expect(wrapper.find(ErrorState)).toHaveLength(1);
});

it('hasNoAccount', async () => {
const wrapper = await mountWithApiMock({
get: () => ({ message: 'no-as-account' }),
});
expect(wrapper.find(NoUserState)).toHaveLength(1);
});
});

describe('happy-path states', () => {
const mockedApiResponse = {
results: [
{
name: 'hello-world',
created_at: 'somedate',
document_count: 50,
field_count: 10,
},
],
meta: {
page: {
current: 1,
total_pages: 10,
total_results: 100,
size: 10,
},
},
};
const mockApi = jest.fn(() => mockedApiResponse);
let wrapper;

beforeAll(async () => {
wrapper = await mountWithApiMock({ get: mockApi });
});

it('renders', () => {
expect(wrapper.find(EngineTable)).toHaveLength(2);
});

it('calls the engines API', () => {
expect(mockApi).toHaveBeenNthCalledWith(1, '/api/app_search/engines', {
query: {
type: 'indexed',
pageIndex: 1,
},
});
expect(mockApi).toHaveBeenNthCalledWith(2, '/api/app_search/engines', {
query: {
type: 'meta',
pageIndex: 1,
},
});
});

describe('pagination', () => {
const getTablePagination = () =>
wrapper
.find(EngineTable)
.first()
.prop('pagination');

it('passes down page data from the API', () => {
const pagination = getTablePagination();

expect(pagination.totalEngines).toEqual(100);
expect(pagination.pageIndex).toEqual(0);
});

it('re-polls the API on page change', async () => {
await act(async () => getTablePagination().onPaginate(5));
wrapper.update();

expect(mockApi).toHaveBeenLastCalledWith('/api/app_search/engines', {
query: {
type: 'indexed',
pageIndex: 5,
},
});
expect(getTablePagination().pageIndex).toEqual(4);
});
});
});

/**
* Test helpers
*/

const mountWithApiMock = async ({ get }) => {
let wrapper;
const httpMock = { ...mockKibanaContext.http, get };

// We get a lot of act() warning/errors in the terminal without this.
// TBH, I don't fully understand why since Enzyme's mount is supposed to
// have act() baked in - could be because of the wrapping context provider?
await act(async () => {
wrapper = mountWithKibanaContext(<EngineOverview />, { http: httpMock });
});
wrapper.update(); // This seems to be required for the DOM to actually update

return wrapper;
};
});
Original file line number Diff line number Diff line change
Expand Up @@ -42,35 +42,40 @@ export const EngineOverview: ReactFC<> = () => {
const [metaEnginesPage, setMetaEnginesPage] = useState(1);
const [metaEnginesTotal, setMetaEnginesTotal] = useState(0);

const getEnginesData = ({ type, pageIndex }) => {
return http.get('/api/app_search/engines', {
const getEnginesData = async ({ type, pageIndex }) => {
return await http.get('/api/app_search/engines', {
query: { type, pageIndex },
});
};
const hasValidData = response => {
return response && response.results && response.meta;
return (
response &&
Array.isArray(response.results) &&
response.meta &&
response.meta.page &&
typeof response.meta.page.total_results === 'number'
); // TODO: Move to optional chaining once Prettier has been updated to support it
};
const hasNoAccountError = response => {
return response && response.message === 'no-as-account';
};
const setEnginesData = (params, callbacks) => {
getEnginesData(params)
.then(response => {
if (!hasValidData(response)) {
if (hasNoAccountError(response)) {
return setHasNoAccount(true);
}
throw new Error('App Search engines response is missing valid data');
const setEnginesData = async (params, callbacks) => {
try {
const response = await getEnginesData(params);
if (!hasValidData(response)) {
if (hasNoAccountError(response)) {
return setHasNoAccount(true);
}

callbacks.setResults(response.results);
callbacks.setResultsTotal(response.meta.page.total_results);
setIsLoading(false);
})
.catch(error => {
// TODO - should we be logging errors to telemetry or elsewhere for debugging?
setHasErrorConnecting(true);
});
throw new Error('App Search engines response is missing valid data');
}

callbacks.setResults(response.results);
callbacks.setResultsTotal(response.meta.page.total_results);
setIsLoading(false);
} catch (error) {
// TODO - should we be logging errors to telemetry or elsewhere for debugging?
setHasErrorConnecting(true);
}
};

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import React from 'react';
import { EuiBasicTable, EuiPagination, EuiButtonEmpty, EuiLink } from '@elastic/eui';

import { mountWithKibanaContext } from '../../../test_utils';
jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() }));
import { sendTelemetry } from '../../../shared/telemetry';

import { EngineTable } from './engine_table';

describe('EngineTable', () => {
const onPaginate = jest.fn(); // onPaginate updates the engines API call upstream

const wrapper = mountWithKibanaContext(
<EngineTable
data={[
{
name: 'test-engine',
created_at: 'Fri, 1 Jan 1970 12:00:00 +0000',
document_count: 99999,
field_count: 10,
},
]}
pagination={{
totalEngines: 50,
pageIndex: 0,
onPaginate,
}}
/>
);
const table = wrapper.find(EuiBasicTable);

it('renders', () => {
expect(table).toHaveLength(1);
expect(table.prop('pagination').totalItemCount).toEqual(50);

const tableContent = table.text();
expect(tableContent).toContain('test-engine');
expect(tableContent).toContain('January 1, 1970');
expect(tableContent).toContain('99,999');
expect(tableContent).toContain('10');

expect(table.find(EuiPagination).find(EuiButtonEmpty)).toHaveLength(5); // Should display 5 pages at 10 engines per page
});

it('contains engine links which send telemetry', () => {
const engineLinks = wrapper.find(EuiLink);

engineLinks.forEach(link => {
expect(link.prop('href')).toEqual('http://localhost:3002/as/engines/test-engine');
link.simulate('click');

expect(sendTelemetry).toHaveBeenCalledWith({
http: expect.any(Object),
product: 'app_search',
action: 'clicked',
metric: 'engine_table_link',
});
});
});

it('triggers onPaginate', () => {
table.prop('onChange')({ page: { index: 4 } });

expect(onPaginate).toHaveBeenCalledWith(5);
});

it('handles empty data', () => {
const emptyWrapper = mountWithKibanaContext(
<EngineTable data={[]} pagination={{ totalEngines: 0 }} />
);
const emptyTable = wrapper.find(EuiBasicTable);
expect(emptyTable.prop('pagination').pageIndex).toEqual(0);
});
});
Loading

0 comments on commit f80d64d

Please sign in to comment.