Skip to content

Commit

Permalink
Saved Objects testing (#56965) (#58202)
Browse files Browse the repository at this point in the history
* Expose core/public savedObjectsServiceMock

* Test docs for Saved Objects unit and integration tests

* Review comments

* Update api types / docs

Co-authored-by: Rudolf Meijering <skaapgif@gmail.com>
  • Loading branch information
mshustov and rudolf committed Feb 21, 2020
1 parent 0d6ea90 commit 325a4e3
Show file tree
Hide file tree
Showing 8 changed files with 264 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ Constructs a new instance of the `SimpleSavedObject` class
<b>Signature:</b>

```typescript
constructor(client: SavedObjectsClient, { id, type, version, attributes, error, references, migrationVersion }: SavedObjectType<T>);
constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion }: SavedObjectType<T>);
```

## Parameters

| Parameter | Type | Description |
| --- | --- | --- |
| client | <code>SavedObjectsClient</code> | |
| client | <code>SavedObjectsClientContract</code> | |
| { id, type, version, attributes, error, references, migrationVersion } | <code>SavedObjectType&lt;T&gt;</code> | |

262 changes: 251 additions & 11 deletions src/core/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,34 @@

This document outlines best practices and patterns for testing Kibana Plugins.

- [Strategy](#strategy)
- [Core Integrations](#core-integrations)
- [Core Mocks](#core-mocks)
- [Testing Kibana Plugins](#testing-kibana-plugins)
- [Strategy](#strategy)
- [New concerns in the Kibana Platform](#new-concerns-in-the-kibana-platform)
- [Core Integrations](#core-integrations)
- [Core Mocks](#core-mocks)
- [Example](#example)
- [Strategies for specific Core APIs](#strategies-for-specific-core-apis)
- [HTTP Routes](#http-routes)
- [SavedObjects](#savedobjects)
- [Elasticsearch](#elasticsearch)
- [Plugin Integrations](#plugin-integrations)
- [Plugin Contracts](#plugin-contracts)
- [HTTP Routes](#http-routes)
- [Preconditions](#preconditions)
- [Unit testing](#unit-testing)
- [Example](#example-1)
- [Integration tests](#integration-tests)
- [Functional Test Runner](#functional-test-runner)
- [Example](#example-2)
- [TestUtils](#testutils)
- [Example](#example-3)
- [Applications](#applications)
- [Example](#example-4)
- [SavedObjects](#savedobjects)
- [Unit Tests](#unit-tests)
- [Integration Tests](#integration-tests-1)
- [Elasticsearch](#elasticsearch)
- [Plugin integrations](#plugin-integrations)
- [Preconditions](#preconditions-1)
- [Testing dependencies usages](#testing-dependencies-usages)
- [Testing components consuming the dependencies](#testing-components-consuming-the-dependencies)
- [Testing optional plugin dependencies](#testing-optional-plugin-dependencies)
- [Plugin Contracts](#plugin-contracts)

## Strategy

Expand Down Expand Up @@ -540,11 +559,232 @@ describe('renderApp', () => {
});
```
#### SavedObjects
### SavedObjects
_How to test SO operations_
#### Unit Tests
#### Elasticsearch
To unit test code that uses the Saved Objects client mock the client methods
and make assertions against the behaviour you would expect to see.
Since the Saved Objects client makes network requests to an external
Elasticsearch cluster, it's important to include failure scenarios in your
test cases.
When writing a view with which a user might interact, it's important to ensure
your code can recover from exceptions and provide a way for the user to
proceed. This behaviour should be tested as well.
Below is an example of a Jest Unit test suite that mocks the server-side Saved
Objects client:
```typescript
// src/plugins/myplugin/server/lib/short_url_lookup.ts
import crypto from 'crypto';
import { SavedObjectsClientContract } from 'kibana/server';

export const shortUrlLookup = {
generateUrlId(url: string, savedObjectsClient: SavedObjectsClientContract) {
const id = crypto
.createHash('md5')
.update(url)
.digest('hex');

return savedObjectsClient
.create(
'url',
{
url,
accessCount: 0,
createDate: new Date().valueOf(),
accessDate: new Date().valueOf(),
},
{ id }
)
.then(doc => doc.id)
.catch(err => {
if (savedObjectsClient.errors.isConflictError(err)) {
return id;
} else {
throw err;
}
});
},
};

```
```typescript
// src/plugins/myplugin/server/lib/short_url_lookup.test.ts
import { shortUrlLookup } from './short_url_lookup';
import { savedObjectsClientMock } from '../../../../../core/server/mocks';

describe('shortUrlLookup', () => {
const ID = 'bf00ad16941fc51420f91a93428b27a0';
const TYPE = 'url';
const URL = 'http://elastic.co';

const mockSavedObjectsClient = savedObjectsClientMock.create();

beforeEach(() => {
jest.resetAllMocks();
});

describe('generateUrlId', () => {
it('provides correct arguments to savedObjectsClient', async () => {
const ATTRIBUTES = {
url: URL,
accessCount: 0,
createDate: new Date().valueOf(),
accessDate: new Date().valueOf(),
};
mockSavedObjectsClient.create.mockResolvedValueOnce({
id: ID,
type: TYPE,
references: [],
attributes: ATTRIBUTES,
});
await shortUrlLookup.generateUrlId(URL, mockSavedObjectsClient);

expect(mockSavedObjectsClient.create).toHaveBeenCalledTimes(1);
const [type, attributes, options] = mockSavedObjectsClient.create.mock.calls[0];
expect(type).toBe(TYPE);
expect(attributes).toStrictEqual(ATTRIBUTES);
expect(options).toStrictEqual({ id: ID });
});

it('ignores version conflict and returns id', async () => {
mockSavedObjectsClient.create.mockRejectedValueOnce(
mockSavedObjectsClient.errors.decorateConflictError(new Error())
);
const id = await shortUrlLookup.generateUrlId(URL, mockSavedObjectsClient);
expect(id).toEqual(ID);
});

it('rejects with passed through savedObjectsClient errors', () => {
const error = new Error('oops');
mockSavedObjectsClient.create.mockRejectedValueOnce(error);
return expect(shortUrlLookup.generateUrlId(URL, mockSavedObjectsClient)).rejects.toBe(error);
});
});
});
```
The following is an example of a public saved object unit test. The biggest
difference with the server-side test is the slightly different Saved Objects
client API which returns `SimpleSavedObject` instances which needs to be
reflected in the mock.
```typescript
// src/plugins/myplugin/public/saved_query_service.ts
import {
SavedObjectsClientContract,
SavedObjectAttributes,
SimpleSavedObject,
} from 'src/core/public';

export type SavedQueryAttributes = SavedObjectAttributes & {
title: string;
description: 'bar';
query: {
language: 'kuery';
query: 'response:200';
};
};

export const createSavedQueryService = (savedObjectsClient: SavedObjectsClientContract) => {
const saveQuery = async (
attributes: SavedQueryAttributes
): Promise<SimpleSavedObject<SavedQueryAttributes>> => {
try {
return await savedObjectsClient.create<SavedQueryAttributes>('query', attributes, {
id: attributes.title as string,
});
} catch (err) {
throw new Error('Unable to create saved query, please try again.');
}
};

return {
saveQuery,
};
};
```
```typescript
// src/plugins/myplugin/public/saved_query_service.test.ts
import { createSavedQueryService, SavedQueryAttributes } from './saved_query_service';
import { savedObjectsServiceMock } from '../../../../../core/public/mocks';
import { SavedObjectsClientContract, SimpleSavedObject } from '../../../../../core/public';

describe('saved query service', () => {
const savedQueryAttributes: SavedQueryAttributes = {
title: 'foo',
description: 'bar',
query: {
language: 'kuery',
query: 'response:200',
},
};

const mockSavedObjectsClient = savedObjectsServiceMock.createStartContract()
.client as jest.Mocked<SavedObjectsClientContract>;

const savedQueryService = createSavedQueryService(mockSavedObjectsClient);

afterEach(() => {
jest.resetAllMocks();
});

describe('saveQuery', function() {
it('should create a saved object for the given attributes', async () => {
// The public Saved Objects client returns instances of
// SimpleSavedObject, so we create an instance to return from our mock.
const mockReturnValue = new SimpleSavedObject(mockSavedObjectsClient, {
type: 'query',
id: 'foo',
attributes: savedQueryAttributes,
references: [],
});
mockSavedObjectsClient.create.mockResolvedValue(mockReturnValue);

const response = await savedQueryService.saveQuery(savedQueryAttributes);
expect(mockSavedObjectsClient.create).toHaveBeenCalledWith('query', savedQueryAttributes, {
id: 'foo',
});
expect(response).toBe(mockReturnValue);
});

it('should reject with an error when saved objects client errors', async done => {
mockSavedObjectsClient.create.mockRejectedValue(new Error('timeout'));

try {
await savedQueryService.saveQuery(savedQueryAttributes);
} catch (err) {
expect(err).toMatchInlineSnapshot(
`[Error: Unable to create saved query, please try again.]`
);
done();
}
});
});
});
```
#### Integration Tests
To get the highest confidence in how your code behaves when using the Saved
Objects client, you should write at least a few integration tests which loads
data into and queries a real Elasticsearch database.
To do that we'll write a Jest integration test using `TestUtils` to start
Kibana and esArchiver to load fixture data into Elasticsearch.
1. Create the fixtures data you need in Elasticsearch
2. Create a fixtures archive with `node scripts/es_archiver save <name> [index patterns...]`
3. Load the fixtures in your test using esArchiver `esArchiver.load('name')`;
_todo: fully worked out example_
### Elasticsearch
_How to test ES clients_
Expand Down
4 changes: 2 additions & 2 deletions src/core/public/legacy/legacy_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock';
import { LegacyPlatformService } from './legacy_service';
import { applicationServiceMock } from '../application/application_service.mock';
import { docLinksServiceMock } from '../doc_links/doc_links_service.mock';
import { savedObjectsMock } from '../saved_objects/saved_objects_service.mock';
import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service.mock';
import { contextServiceMock } from '../context/context_service.mock';

const applicationSetup = applicationServiceMock.createInternalSetupContract();
Expand Down Expand Up @@ -97,7 +97,7 @@ const injectedMetadataStart = injectedMetadataServiceMock.createStartContract();
const notificationsStart = notificationServiceMock.createStartContract();
const overlayStart = overlayServiceMock.createStartContract();
const uiSettingsStart = uiSettingsServiceMock.createStartContract();
const savedObjectsStart = savedObjectsMock.createStartContract();
const savedObjectsStart = savedObjectsServiceMock.createStartContract();
const fatalErrorsStart = fatalErrorsServiceMock.createStartContract();
const mockStorage = { getItem: jest.fn() } as any;

Expand Down
5 changes: 3 additions & 2 deletions src/core/public/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { i18nServiceMock } from './i18n/i18n_service.mock';
import { notificationServiceMock } from './notifications/notifications_service.mock';
import { overlayServiceMock } from './overlays/overlay_service.mock';
import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock';
import { savedObjectsMock } from './saved_objects/saved_objects_service.mock';
import { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock';
import { contextServiceMock } from './context/context_service.mock';
import { injectedMetadataServiceMock } from './injected_metadata/injected_metadata_service.mock';

Expand All @@ -40,6 +40,7 @@ export { legacyPlatformServiceMock } from './legacy/legacy_service.mock';
export { notificationServiceMock } from './notifications/notifications_service.mock';
export { overlayServiceMock } from './overlays/overlay_service.mock';
export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock';
export { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock';

function createCoreSetupMock({ basePath = '' } = {}) {
const mock = {
Expand Down Expand Up @@ -70,7 +71,7 @@ function createCoreStartMock({ basePath = '' } = {}) {
notifications: notificationServiceMock.createStartContract(),
overlays: overlayServiceMock.createStartContract(),
uiSettings: uiSettingsServiceMock.createStartContract(),
savedObjects: savedObjectsMock.createStartContract(),
savedObjects: savedObjectsServiceMock.createStartContract(),
injectedMetadata: {
getInjectedVar: injectedMetadataServiceMock.createStartContract().getInjectedVar,
},
Expand Down
4 changes: 2 additions & 2 deletions src/core/public/plugins/plugins_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import { injectedMetadataServiceMock } from '../injected_metadata/injected_metad
import { httpServiceMock } from '../http/http_service.mock';
import { CoreSetup, CoreStart, PluginInitializerContext } from '..';
import { docLinksServiceMock } from '../doc_links/doc_links_service.mock';
import { savedObjectsMock } from '../saved_objects/saved_objects_service.mock';
import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service.mock';
import { contextServiceMock } from '../context/context_service.mock';

export let mockPluginInitializers: Map<PluginName, MockedPluginInitializer>;
Expand Down Expand Up @@ -110,7 +110,7 @@ describe('PluginsService', () => {
notifications: notificationServiceMock.createStartContract(),
overlays: overlayServiceMock.createStartContract(),
uiSettings: uiSettingsServiceMock.createStartContract(),
savedObjects: savedObjectsMock.createStartContract(),
savedObjects: savedObjectsServiceMock.createStartContract(),
fatalErrors: fatalErrorsServiceMock.createStartContract(),
};
mockStartContext = {
Expand Down
2 changes: 1 addition & 1 deletion src/core/public/public.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1175,7 +1175,7 @@ export interface SavedObjectsUpdateOptions {

// @public
export class SimpleSavedObject<T extends SavedObjectAttributes> {
constructor(client: SavedObjectsClient, { id, type, version, attributes, error, references, migrationVersion }: SavedObject<T>);
constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion }: SavedObject<T>);
// (undocumented)
attributes: T;
// (undocumented)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const createMock = () => {
return mocked;
};

export const savedObjectsMock = {
export const savedObjectsServiceMock = {
create: createMock,
createStartContract: createStartContractMock,
};
4 changes: 2 additions & 2 deletions src/core/public/saved_objects/simple_saved_object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import { get, has, set } from 'lodash';
import { SavedObject as SavedObjectType, SavedObjectAttributes } from '../../server';
import { SavedObjectsClient } from './saved_objects_client';
import { SavedObjectsClientContract } from './saved_objects_client';

/**
* This class is a very simple wrapper for SavedObjects loaded from the server
Expand All @@ -41,7 +41,7 @@ export class SimpleSavedObject<T extends SavedObjectAttributes> {
public references: SavedObjectType<T>['references'];

constructor(
private client: SavedObjectsClient,
private client: SavedObjectsClientContract,
{ id, type, version, attributes, error, references, migrationVersion }: SavedObjectType<T>
) {
this.id = id;
Expand Down

0 comments on commit 325a4e3

Please sign in to comment.